LoginSignup
2
1

Delphi で学ぶ古典ゲームの仕組み 第3回:オブジェクトを動かしてみよう

Last updated at Posted at 2024-04-26

はじめに

前回でゲームループが完成したので、動くキャラクターを作っていきます。

オブジェクト

ゲームで動く物を Star Force を例に考えるてみると…

image.png

  • 自機
  • 爆発
  • アイテム
  • 地上の構造物
  • 陸地

ざっとこのような物が存在する事がわかります。
しかもどれも刻々と位置を変えたりアニメーションが変わったりします。
つまり、位置やアニメーションのカレントフレームなどの「状態」を持っています。
これらの物は「状態を持ったオブジェクト」ということになります。

状態を持ったオブジェクト

ゲーム中に表示されるほとんどの物が状態を持っている事がわかりました。
ということは、状態を更新するための Update メソッドを持たせないといけません。

ということで、これら状態を持ったオブジェクトのベースクラス TGameObject を作ります。
uGameMaster に新たに TGameObject を定義します。
TGameObject は Start, Finish, Update, Render を用意します。
また多くのオブジェクトが座標を必要としているため座標を保持する X, Y と前回の座標値 OldX, OldY, その差分 DX, DY というプロパティと Move というメソッドを定義します。

宣言
type
  TGameObject = class
  private var
    FGameMaster: TGameMaster;
    FX: Single;
    FY: Single;
    FOldX: Single;
    FOldY: Single;
  private
    function GetDX: Single;
    function GetDY: Single;
  protected
    procedure Start; virtual;
    procedure Update; virtual;
    procedure Render; virtual;
    procedure Finish; virtual;
  protected
    property GameMaster: TGameMaster read FGameMaster;
  public
    constructor Create(const AGM: TGameMaster); reintroduce;
    destructor Destroy; override;
    procedure Move(const AX, AY: Single);
  public
    property X: Single read FX write FY;
    property Y: Single read FY write FY;
    property OldX: Single read FOldX write FOldX;
    property OldY: Single read FOldY write FOldY;
    property DX: Single read GetDX;
    property DY: Single read GetDY;
  end;

次に実装です。
ここでは生成時に GameMaster に自分を追加する処理を書いています。
(今はまだ機能がありませんが)AddObject で追加されたオブジェクトが GameMaster の Render / Update から呼ばれる想定です。

実装
implementation

{ TGameObject }

constructor TGameObject.Create(const AGM: TGameMaster);
begin
  inherited Create;

  FGameMaster := AGM;
  FGameMaster.AddObject(Self); // GameMaster に自身を追加

  Start; // 継承先オブジェクトで初期化の機会を与える
end;

destructor TGameObject.Destroy;
begin
  Finish; // 継承先オブジェクトで終了処理の機械を与える

  FGameMaster.RemoveObject(Self); // GameMaster から自身を削除

  inherited;
end;

procedure TGameObject.Finish;
begin
  // 継承先で変更
end;

function TGameObject.GetDX: Single;
begin
  Result := FX - FOldX;
end;

function TGameObject.GetDY: Single;
begin
  Result := FY - FOldY;
end;

procedure TGameObject.Move(const AX, AY: Single);
begin
  FOldX := FX;
  FOldY := FY;

  FX := AX;
  FY := AY;
end;

procedure TGameObject.Render;
begin
  // 継承先で変更
end;

procedure TGameObject.Start;
begin
  // 継承先で変更
end;

procedure TGameObject.Update;
begin
  // 継承先で変更
end;

end.

オブジェクトの管理

次に TGameMaster 側の処理を変更します。

TGameObject の管理用 FObjects を追加します。
FObjects に GameObject を追加するためのメソッド AddObject / RemoveObject を追加します。

TGameMaster 宣言
  TGameMaster = class
    ()
  private var
    ()
    FObjects: TList<TGameObject>; // これを追加
  public
    constructor Create(const ACanvas: TCanvas); reintroduce;
    destructor Destroy; override; // これも追加
  public
    // この2つを追加
    procedure AddObject(const AObj: TGameObject);
    procedure RemoveObject(const AObj: TGameObject);
  public
    // 外部から描画できるように Canvas / Canvas R をプロパティとして公開
    property Canvas: TCanvas read FCanvas;
    property CanvasR: TRectF read FCanvasR;
  end;

コンストラクタで FObjects を生成してデストラクタで廃棄しています。
AddObject で FObjects に GameObject を追加し、RemoveObject で GameObject を削除しています。

AddObject/RemoveObject の実装
procedure TGameMaster.AddObject(const AObj: TGameObject);
begin
  // 追加
  FObjects.Add(AObj);
end;

constructor TGameMaster.Create(const ACanvas: TCanvas);
begin
  inherited Create;

  // 生成
  FObjects := TList<TGameObject>.Create;

  FCanvas := ACanvas;
  FLoop := TLoopThread.Create(Self);
end;

destructor TGameMaster.Destroy;
begin
  FLoop.Terminate;
  while not FLoop.Finished do
    Sleep(100);

  // 廃棄
  FObjects.Free;

  inherited;
end;

procedure TGameMaster.RemoveObject(const AObj: TGameObject);
begin
  // 削除
  FObjects.Remove(AObj);
end;

オブジェクトの更新と描画

TLoopThread の Update / Render から各 GameObject の Render / Update を呼ぶ様に変更します。

Render / Update の修正
procedure TGameMaster.TLoopThread.Render;
begin
  FGameMaster.FCanvas.BeginScene;
  try
    try
      // 背景のクリア
      FGameMaster.FCanvas.Clear(TAlphaColors.Black);

      // 各描画処理を呼ぶ
      for var Obj in FGameMaster.FObjects do
         Obj.Render;

      // FPS 表示
      ()
    except
    end;
  finally
    FGameMaster.FCanvas.EndScene;;
  end;

  // FPS 更新処理
  ()
end;

procedure TGameMaster.TLoopThread.Update;
begin
  // 各更新処理を呼ぶ
  for var Obj in FGameMaster.FObjects do
     Obj.Update;
end;

これで準備が整いました。

オブジェクトを移動させる

ためしに、簡単なオブジェクトを作ってみましょう。
円を表示し移動して、画面の端まで来たら跳ね返える物を作ります。

※GIF アニメにキャプチャしているので滑らかではありませんが、実際はもっとずっと滑らかに動きます。

BallObject の作成

図の円を示すオブジェクトを TBallObject とします。
そして、TBallObject を記述するユニットを uBallObject とします。

まずは宣言部です。

宣言部
unit uBallObject;

interface

uses
  System.SysUtils
  , System.Types
  , System.UITypes
  , uGameMaster
  ;

type
  TBallObject = class(TGameObject)
  private var
    FVX: Single;         // X速度 [pixel / frame]
    FVY: Single;         // Y速度 [pixel / frame]
    FBoundsRect: TRectF; // 円の外接四角形
    FColor: TAlphaColor; // 円の色
  protected
    procedure Start; override;
    procedure Render; override;
    procedure Update; override;
  end;

Start,Render,Update を override して実際の処理を書いていきます。
実装部です。

実装部
procedure TBallObject.Render;
begin
  // 円を描画する
  GameMaster.Canvas.Fill.Color := FColor;
  GameMaster.Canvas.FillEllipse(
    RectF(X, Y, X + FBoundsRect.Width, Y + FBoundsRect.Height),
    1
  );
end;

procedure TBallObject.Start;
begin
  // 初期設定
  // 速度はランダム
  FVX := Random(10) + 1;
  FVY := Random(10) + 1;

  // 大きさもランダム
  var S := Random(30) + 5;
  FBoundsRect := RectF(0, 0, S, S);

  // 色もランダム
  FColor := $ff_00_00_00 or UInt32(Random($1_00_00_00));

  // 初期位置もランダム
  var R := GameMaster.CanvasR;
  Move(Random(Trunc(R.Width)), Random(Trunc(R.Height)));
end;

procedure TBallObject.Update;
begin
  // Canvas外に出た時は符号を反転する
  var R := GameMaster.CanvasR;
  R.Right := R.Right - FBoundsRect.Width;
  R.Bottom := R.Bottom - FBoundsRect.Height;

  if (X < 0) or (X > R.Width) then
    FVX := -FVX;

  if (Y < 0) or (Y > R.Height) then
    FVY := -FVY;

  // FVX, FVY で表される速度で進んだ時の位置を計算
  Move(X + FVX, Y + FVY);
end;

次に実際に TBallObject を生成する処理です。
これは単なるサンプルのため Main フォーム側に書きましょう。

呼び出し部
procedure TfrmMain.FormCreate(Sender: TObject);
begin
  FGameMaster := TGameMaster.Create(Canvas);

  // TBallObject を生成
  for var i := 0 to 100 do
    TBallObject.Create(FGameMaster);
end;

ちなみに、ここでは 100 個生成していますがうちの環境(Core i7 14700K, RTX 4060Ti, Win64 Release Build)では 5000 個程度まで 60 [FPS] で動作しました。10000 個だと 45 [FPS]ぐらいでした。

TGameObject を継承して Update, Render など適切なメソッドを override するだけで簡単に動くオブジェクトを実装できることが理解できたかと思います。

ここまでのコード

uGameMaster と uBallObject のソースコード全文です。

uGameMaster コード全文
GameMaster
unit uGameMaster;

interface

uses
  System.Classes
  , System.Diagnostics
  , System.Types
  , System.SysUtils
  , System.Generics.Collections
  , FMX.Graphics
  ;

type
  TGameObject = class;

  TGameMaster = class
  private type
    TLoopThread = class(TThread)
    private var
      FFPS: Double;
      FFrameCount: Integer;
      FFPSWatch: TStopWatch;
      FGameMaster: TGameMaster;
    private
      procedure Update;
      procedure Render;
    protected
      procedure Execute; override;
    public
      constructor Create(const AGM: TGameMaster); reintroduce;
    end;
  private var
    FLoop: TLoopThread;
    FCanvas: TCanvas;
    FCanvasR: TRectF;
    FObjects: TList<TGameObject>;
  private
    procedure CalcCanvasRect;
  public
    constructor Create(const ACanvas: TCanvas); reintroduce;
    destructor Destroy; override;
  public
    procedure Start;
    procedure AddObject(const AObj: TGameObject);
    procedure RemoveObject(const AObj: TGameObject);
  public
    property Canvas: TCanvas read FCanvas;
    property CanvasR: TRectF read FCanvasR;
  end;

  TGameObject = class
  private var
    FGameMaster: TGameMaster;
    FX: Single;
    FY: Single;
    FOldX: Single;
    FOldY: Single;
  private
    function GetDX: Single;
    function GetDY: Single;
  protected
    procedure Start; virtual;
    procedure Update; virtual;
    procedure Render; virtual;
    procedure Finish; virtual;
  protected
    property GameMaster: TGameMaster read FGameMaster;
  public
    constructor Create(const AGM: TGameMaster); reintroduce;
    destructor Destroy; override;
    procedure Move(const AX, AY: Single);
  public
    property X: Single read FX write FY;
    property Y: Single read FY write FY;
    property OldX: Single read FOldX write FOldX;
    property OldY: Single read FOldY write FOldY;
    property DX: Single read GetDX;
    property DY: Single read GetDY;
  end;

implementation

uses
  System.UITypes
  , FMX.Forms
  , FMX.Types
  ;

type
  TStopwatchHelper = record helper for TStopwatch
  private
    function GetElapsedMicroSec: Double;
  public
    property ElapsedMicroSec: Double read GetElapsedMicroSec;
  end;

{ TStopwatchHelper }

function TStopwatchHelper.GetElapsedMicroSec: Double;
begin
  Result := Elapsed.Ticks * 1000 / TStopwatch.Frequency;
end;

{ TGameMaster }

procedure TGameMaster.AddObject(const AObj: TGameObject);
begin
  FObjects.Add(AObj);
end;

procedure TGameMaster.CalcCanvasRect;
begin
  FCanvasR := RectF(0, 0, FCanvas.Width, FCanvas.Height);
end;

constructor TGameMaster.Create(const ACanvas: TCanvas);
begin
  inherited Create;

  FObjects := TList<TGameObject>.Create;

  FCanvas := ACanvas;
  CalcCanvasRect;

  FLoop := TLoopThread.Create(Self);
end;

destructor TGameMaster.Destroy;
begin
  FLoop.Terminate;
  while not FLoop.Finished do
    Sleep(100);

  var Objects := FObjects;
  for var Obj in Objects do
    Obj.Free;

  FObjects.Free;

  inherited;
end;

procedure TGameMaster.RemoveObject(const AObj: TGameObject);
begin
  FObjects.Remove(AObj);
end;

procedure TGameMaster.Start;
begin
  if not FLoop.Started then
    FLoop.Start;
end;

{ TGameLoop.TLoopThread }

constructor TGameMaster.TLoopThread.Create(const AGM: TGameMaster);
begin
  inherited Create(True);

  FGameMaster := AGM;
  FreeOnTerminate := True;
end;

procedure TGameMaster.TLoopThread.Execute;
begin
  // 初期化処理
  var SW: TStopwatch;
  var SleepWatch: TStopwatch;
  var AdjustTime := 0.0;

  FFrameCount := 0;
  FFPSWatch := TStopwatch.StartNew;

  // アプリケーションやスレッドが終了したら終わらせる
  while (not Terminated) and (not Application.Terminated) do
  begin
    SW.Reset;
    SW.Start;

    // 描画矩形の計算
    FGameMaster.CalcCanvasRect;

    // 更新・描画処理を呼び出す
    Update;
    Render;

    SW.Stop;

    // 余り時間
    var SleepTime := 16.67 - SW.ElapsedMicroSec - AdjustTime;

    if SleepTime > 0 then
    begin
      SleepWatch.Reset;
      SleepWatch.Start;

      Sleep(Round(SleepTime));

      SleepWatch.Stop;

      AdjustTime := SleepWatch.ElapsedMicroSec - SleepTime;
    end
    else
      AdjustTime := 0;
  end;

  // 終了処理
end;

procedure TGameMaster.TLoopThread.Render;
begin
  FGameMaster.FCanvas.BeginScene;
  try
    try
      // 背景のクリア
      FGameMaster.FCanvas.Clear(TAlphaColors.Black);

      // 各描画処理を呼ぶ
      for var Obj in FGameMaster.FObjects do
         Obj.Render;

      // FPS 表示
      FGameMaster.FCanvas.Fill.Color := TAlphaColors.White;
      FGameMaster.FCanvas.Fill.Kind := TBrushKind.Solid;
      FGameMaster.FCanvas.FillText(
        RectF(
          6,
          FGameMaster.FCanvasR.Bottom - 28,
          FGameMaster.FCanvas.Width,
          FGameMaster.FCanvasR.Bottom
        ),
        Format('FPS: %.3f', [FFPS]),
        False,
        1,
        [],
        TTextAlign.Leading,
        TTextAlign.Center);
    except
    end;
  finally
    FGameMaster.FCanvas.EndScene;;
  end;

  // FPS 更新処理
  Inc(FFrameCount);
  var FPSElapsed := FFPSWatch.ElapsedMilliseconds;
  if FPSElapsed > 999 then
  begin
    FFPS := FFrameCount * 1000 /  FPSElapsed;

    FFrameCount := 0;
    FFPSWatch.Reset;
    FFPSWatch.Start;
  end;
end;

procedure TGameMaster.TLoopThread.Update;
begin
  // 各更新処理を呼ぶ
  for var Obj in FGameMaster.FObjects do
     Obj.Update;
end;

{ TGameObject }

constructor TGameObject.Create(const AGM: TGameMaster);
begin
  inherited Create;

  FGameMaster := AGM;
  FGameMaster.AddObject(Self);

  Start;
end;

destructor TGameObject.Destroy;
begin
  Finish;

  FGameMaster.RemoveObject(Self);

  inherited;
end;

procedure TGameObject.Finish;
begin
  // 継承先で変更
end;

function TGameObject.GetDX: Single;
begin
  Result := FX - FOldX;
end;

function TGameObject.GetDY: Single;
begin
  Result := FY - FOldY;
end;

procedure TGameObject.Move(const AX, AY: Single);
begin
  FOldX := FX;
  FOldY := FY;

  FX := AX;
  FY := AY;
end;

procedure TGameObject.Render;
begin
  // 継承先で変更
end;

procedure TGameObject.Start;
begin
  // 継承先で変更
end;

procedure TGameObject.Update;
begin
  // 継承先で変更
end;

end.
uBallObject コード全文
BallObject
unit uBallObject;

interface

uses
  System.SysUtils
  , System.Types
  , System.UITypes
  , uGameMaster
  ;

type
  TBallObject = class(TGameObject)
  private var
    FVX: Single;
    FVY: Single;
    FBoundsRect: TRectF;
    FColor: TAlphaColor;
  protected
    procedure Start; override;
    procedure Render; override;
    procedure Update; override;
  end;

implementation

{ TBallObject }

procedure TBallObject.Render;
begin
  GameMaster.Canvas.Fill.Color := FColor;
  GameMaster.Canvas.FillEllipse(
    RectF(X, Y, X + FBoundsRect.Width, Y + FBoundsRect.Height),
    1)
  ;
end;

procedure TBallObject.Start;
begin
  FVX := Random(10) + 1;
  FVY := Random(10) + 1;

  var S := Random(30) + 5;
  FBoundsRect := RectF(0, 0, S, S);

  FColor := $ff_00_00_00 or UInt32(Random($1_00_00_00));

  var R := GameMaster.CanvasR;
  Move(Random(Trunc(R.Width)), Random(Trunc(R.Height)));
end;

procedure TBallObject.Update;
begin
  var R := GameMaster.CanvasR;
  R.Right := R.Right - FBoundsRect.Width;
  R.Bottom := R.Bottom - FBoundsRect.Height;

  if (X < 0) or (X > R.Width) then
    FVX := -FVX;

  if (Y < 0) or (Y > R.Height) then
    FVY := -FVY;

  Move(X + FVX, Y + FVY);
end;

end.

まとめ

自機、敵キャラ、など最初に上げた状態を持ったオブジェクトを TGameObject として制御する仕組みを紹介しました。
良かったら色々動かして試してみてください。

第4回につづく

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1