はじめに
前回、Update と Render を使ってボールをランダムに動かす事ができました。
今回は、キー入力に応じてボールを打つようにしてみます。
ゲームパッド
今回作っている物はゲームコンソールではなく普通の PC で動作するためキーボードで操作することを前提にしています。
とはいえ、せっかくなので仮想的なゲームパッドを想定しておきます。
スーファミのコントローラーをモチーフにしつつ [start] [select] ボタンを無くしました。
このゲームパッドを示すクラスを作ります。
コード
クラスの宣言はこのような感じです。
type
// ボタンの種類
TPadButton = (Left, Up, Right, Down, A, B, X, Y, L, R);
// ゲームパッドを表す型
TGamePad = record
private type
TButtonInfo = record
FIsPressed: Boolean;
FHoldTimeWatch: TStopwatch;
FHoldTime: Integer;
end;
private var
FButtons: array [TPadButton] of TButtonInfo;
public
// ボタンが押されたときに呼ぶ
procedure Press(const AButton: TPadButton);
// ボタンが離されたときに呼ぶ
procedure Release(const AButton: TPadButton);
// ボタンが押されているか確認する
function IsPressed(const AButton: TPadButton): Boolean;
// ボタンが押されていた時間 [msec]
function GetHoldTime(const AButton: TPadButton): Integer;
end;
実装部は↓の通りで、単純に各ボタンが押されたときに FIsPressed を True にしたり、押されていた時間を計測しているだけです。
function TGamePad.GetHoldTime(const AButton: TPadButton): Integer;
begin
Result := FButtons[AButton].FHoldTime;
end;
function TGamePad.IsPressed(const AButton: TPadButton): Boolean;
begin
Result := FButtons[AButton].FIsPressed;
end;
procedure TGamePad.Press(const AButton: TPadButton);
begin
with FButtons[AButton] do
begin
if FIsPressed then
FHoldTime := FHoldTimeWatch.ElapsedMilliseconds
else
begin
FIsPressed := True;
FHoldTime := 0;
FHoldTimeWatch.Reset;
FHoldTimeWatch.Start;
end;
end;
end;
procedure TGamePad.Release(const AButton: TPadButton);
begin
with FButtons[AButton] do
begin
FHoldTimeWatch.Stop;
FIsPressed := False;
FHoldTime := FHoldTimeWatch.ElapsedMilliseconds;
end;
end;
GamePad は人間の入力を扱うためゲームのフレームレート外で動作します。
そのため、押されていた時間をフレームレートと連動して測ることはできません。
たとえば、自機を移動するといった時も 100[msec] でどのぐらい移動するのか、といった別の時間軸を必要とします。
GameMaster で管理する
TGamePad のインスタンスを GameMaster のプロパティとして公開して、どこからでも参照できるようにします。
TGameMaster = class
(略)
private var
(略)
FPad: TGamePad;
(略)
public
property Pad: TGamePad read FPad;
キーボードとゲームパッドを結びつける
ゲームパッドをキーボードと関連付けます。
具体的には以下のキーが押された時、ゲームパッドのボタンが押されたことにします。
※「未割り当て」はキーを割り当てていません
ゲームパッドのボタン | キーボード |
---|---|
Up | ↑ |
Down | ↓ |
Left | ← |
Right | → |
L | 未割り当て |
R | 未割り当て |
X | 未割り当て |
Y | 未割り当て |
A | Space |
B | Shift |
ハードウェアと仮想的なゲームパッドを結びつける方法は、メリットが1つあります。
それは、操作のカスタマイズです。
例えば、下記のようにスペースキーを押した時 A ボタンの機能が発動されるようになっていた時
スペースキー → A ボタン
これを下記のように変更すると
スペースキー → B ボタン
スペースキーを押した時に B ボタンの機能が発動されるようにできるのです。
これを利用してキーとボタンの関係をいくつか定義しておけば、ユーザー好みの設定で遊べるようになります(もちろん全てのキーを自由にカスタマイズできてもかまいません)
それでは、実際にキーボードでキーが押された時、GamePad のボタンが押された処理を呼ぶ様にします。
そのためには TForm の OnKeyDown / OnkeyUp を使います。
各キーが押されたタイミングで press
を呼び、離したタイミングで release
を呼んでいます。
procedure TfrmMain.FormKeyDown(
Sender: TObject;
var Key: Word;
var KeyChar: WideChar;
Shift: TShiftState);
begin
// キーボードのキーが押されたらゲームパッドのボタンを押す
case Key of
vkLeft: FGameMaster.Pad.Press(TPadButton.Left);
vkRight: FGameMaster.Pad.Press(TPadButton.Right);
vkUp: FGameMaster.Pad.Press(TPadButton.Up);
vkDown: FGameMaster.Pad.Press(TPadButton.Down);
vkShift: FGameMaster.Pad.Press(TPadButton.B);
end;
case KeyChar of
' ': FGameMaster.Pad.Press(TPadButton.A);
end;
end;
procedure TfrmMain.FormKeyUp(
Sender: TObject;
var Key: Word;
var KeyChar: WideChar;
Shift: TShiftState);
begin
// キーボードのキーが離されたらゲームパッドのボタンを離す
case Key of
vkLeft: FGameMaster.Pad.Release(TPadButton.Left);
vkRight: FGameMaster.Pad.Release(TPadButton.Right);
vkUp: FGameMaster.Pad.Release(TPadButton.Up);
vkDown: FGameMaster.Pad.Release(TPadButton.Down);
vkShift: FGameMaster.Pad.Release(TPadButton.B);
end;
case KeyChar of
' ': FGameMaster.Pad.Release(TPadButton.A);
end;
end;
ボールを発射する
では、A ボタンが押されたときにボールを発射するようにして見ます。
TGameMaster の TLoopThread 内に下記の様に書いておきます。
(TBallObject は初期位置などを後から設定出来るように改修されています)
while (not Terminated) and (not Application.Terminated) do
begin
(略)
// 入力検知
if FGameMaster.Pad.IsPressed(TPadButton.A) then
begin
// ボールを生成
var B := TBallObject.Create(FGameMaster);
B.SetParam(
TBallColor(Random(3)), // ボールの色
100, // ボールの X 座標
100, // ボールの Y 座標
50, // ボールの直径
Random(10) + 1, // ボールの X 方向の速度
Random(10) + 1 // ボールの Y 方向の速度
);
end;
実行してスペースキーを押してみると…
1回キーを押しただけなのに、大量にボールが生成されました。
これは、LoopThread 内部は常に回っているため IsPressed で A ボタンが押されている間は、ボールが生成され続けるためです。
そのため、ボールが1つ生成されたら Release されるまでボールを生成しないようにする必要があります。
ボールの生成を制御する
ボールの生成を制御するために、新たに TBallLauncher クラスを作成します。
このクラスは Press → Release という遷移を取った時だけボールを生成します。
つまり、1回押すと1個ボールが生成される仕組みです。
type
TBallLauncher = class(TGameObject)
private var
FCanSpawnBall: Boolean; // ボールを生成可能か示すフラグ
public
procedure Start; override;
procedure Update; override;
end;
先ほど GameMaster に書いたボールの生成処理をこちらに移動します。
procedure TBallLauncher.Start;
begin
FCanSpawnBall := True; // 初期化時ボールを生成可能にします
end;
procedure TBallLauncher.Update;
begin
// A ボタンが押されているとき
if GameMaster.Pad.IsPressed(TPadButton.A) then
begin
// ボールが生成不可のときはそのまま抜けます
if not FCanSpawnBall then
Exit;
FCanSpawnBall := False;
// ボールを生成
var B := TBallObject.Create(GameMaster);
B.SetParam(
TBallColor(Random(3)),
100,
100,
50,
Random(10) + 1,
Random(10) + 1
);
end
else
begin
// A ボタンが離された場合は生成可能にします
FCanSpawnBall := True;
end;
end;
end.
TBallLauncher のように実体を持たないものにも GameObject は利用できます。
押しっぱなしにしても1つしか生成されなくなりました!
砲台を作りボールを飛ばす
TBallLauncher を拡張して、砲台を作ります。
(ちょっと上の note で実体が無くても利用できますと書いたのに実体を作ります…)
砲台なので、A ボタンを押した時、ボールを打ち出します。
A ボタンを押す度に下記の状態を上から順々に進めていき、最後に発射します。
状態 | 動作 |
---|---|
Stop | ボタンが押されたら Prepare に移動 |
Prepare | Color に遷移する前の準備 |
Color | 色選択:色が 16 フレーム毎に変わります |
Inflate | 大きさ選択:大きさが変わります |
Angle | 角度選択:射出線が出るので適切な角度を選びます |
Speed | 速度選択:射出線の長さが長ければ長いほど速くなります |
Launch | 発射! |
以下、これを実装した物です。
(アニメーション GIF にしたことで動きがガクガクになっていますが、もっと滑らかに動きます)
大分ゲームらしくなりましたね!
記事が長くなってしまったため、動作についてはソースコードを見てください。
基本は今までと同じで Update で計算をして Render で描画しています。
uGameMaster コード全文
unit uGameMaster;
interface
uses
System.Classes
, System.Diagnostics
, System.Types
, System.SysUtils
, System.Generics.Collections
, FMX.Graphics
, uGamePad
;
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>;
FPad: TGamePad;
FBallLauncher: 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;
property Pad: TGamePad read FPad;
property BallLauncher: TGameObject read FBallLauncher;
end;
TGameObject = class
private var
FGameMaster: TGameMaster;
FX: Single;
FY: Single;
FOldX: Single;
FOldY: Single;
FWidth: Single;
FHeight: Single;
private
function GetDX: Single;
function GetDY: Single;
protected
property GameMaster: TGameMaster read FGameMaster;
public
constructor Create(const AGM: TGameMaster); reintroduce; virtual;
destructor Destroy; override;
procedure Move(const AX, AY: Single);
procedure Start; virtual;
procedure Update; virtual;
procedure Render; virtual;
procedure Finish; virtual;
public
property X: Single read FX write FX;
property Y: Single read FY write FY;
property OldX: Single read FOldX write FOldX;
property OldY: Single read FOldY write FOldY;
property Width: Single read FWidth write FWidth;
property Height: Single read FHeight write FHeight;
property DX: Single read GetDX;
property DY: Single read GetDY;
end;
implementation
uses
System.UITypes
, System.Math
, FMX.Forms
, FMX.Types
, uBallLauncher
;
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;
FBallLauncher := TBallLauncher.Create(Self); // GameObject なので自動的に破棄
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;
if (FWidth = 0) or (FHeight = 0) then
begin
FX := AX;
FY := AY;
end
else
begin
FX := EnsureRange(AX, 0, FGameMaster.CanvasR.Width - FWidth);
FY := EnsureRange(AY, 0, FGameMaster.CanvasR.Height - FHeight);;
end;
end;
procedure TGameObject.Render;
begin
// 継承先で変更
end;
procedure TGameObject.Start;
begin
// 継承先で変更
end;
procedure TGameObject.Update;
begin
// 継承先で変更
end;
end.
uBallObject コード全文
unit uBallObject;
interface
uses
System.SysUtils
, System.Types
, System.UITypes
, uGameMaster
, PK.Utils.Log
;
type
TBallColor = (Red, Green, Blue);
TBallObject = class(TGameObject)
private var
FVX: Single;
FVY: Single;
FColor: TAlphaColor;
FDiameter: Single;
FVisible: Boolean;
public
procedure SetParam(
const AColor: TBallColor;
const AX, AY, ADiameter, AVX, AVY: Single);
procedure Start; override;
procedure Render; override;
procedure Update; override;
procedure Assign(const ASource: TBallObject);
procedure ChangeSpeed(const AVX, AVY: Single);
procedure ChangeColor(const AColor: TBallColor);
property Color: TAlphaColor read FColor;
property Visible: Boolean read FVisible write FVisible;
end;
implementation
const
BALL_COLOR_VALUES: array [TBallColor] of TAlphaColor = (
TAlphaColors.Deeppink,
TAlphaColors.Greenyellow,
TAlphaColors.Dodgerblue
);
{ TBallObject }
procedure TBallObject.Assign(const ASource: TBallObject);
begin
FColor := ASource.FColor;
X := ASource.X;
Y := ASource.Y;
FDiameter := ASource.FDiameter;
FVX := ASource.FVX;
FVY := ASource.FVY;
end;
procedure TBallObject.ChangeColor(const AColor: TBallColor);
begin
FColor := BALL_COLOR_VALUES[AColor];
end;
procedure TBallObject.ChangeSpeed(const AVX, AVY: Single);
begin
FVX := AVX;
FVY := AVY;
end;
procedure TBallObject.Render;
begin
if FVisible then
begin
GameMaster.Canvas.Fill.Color := FColor;
GameMaster.Canvas.FillEllipse(RectF(X, Y, X + FDiameter, Y + FDiameter), 1);
end;
end;
procedure TBallObject.SetParam(
const AColor: TBallColor;
const AX, AY, ADiameter, AVX, AVY: Single);
begin
FColor := BALL_COLOR_VALUES[AColor];
X := AX;
Y := AY;
FDiameter := ADiameter;
FVX := AVX;
FVY := AVY;
end;
procedure TBallObject.Start;
begin
FVisible := True;
end;
procedure TBallObject.Update;
begin
var R := GameMaster.CanvasR;
R.Right := R.Right - FDiameter;
R.Bottom := R.Bottom - FDiameter;
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.
uBallLauncher コード全文
unit uBallLauncher;
interface
uses
System.SysUtils
, uGameMaster
, uGamePad
, uBallObject
;
type
TBallState = (Stop, Prepare, Color, Inflate, Angle, Speed, Launch);
TBallLauncher = class(TGameObject)
private const
BALL_DIAMETER_MAX = 50;
POD_WIDTH = 80;
POD_HEIGHT = 8;
ANGLE_MIN = 15;
ANGLE_MAX = 165;
SPEED_MIN = 3;
SPEED_MAX = 24;
SPEED_K = 5;
COLOR_WAIT = 16;
SELF_WIDTH = 80;
SELF_HEIGHT = 8;
SELF_SPEED = 8;
private var
FBall: TBallObject;
FBallState: TBallState;
FBallColor: TBallColor;
FBallAngle: Single;
FBallSpeed: Single;
FBallDiameter: Single;
FSign: Integer;
FColorWait: Integer;
FPressed: Boolean;
public
procedure Start; override;
procedure Update; override;
procedure Render; override;
end;
implementation
uses
System.Types
, System.UITypes
, System.Math
, System.Math.Vectors
, FMX.Graphics
, PK.Utils.Log
;
{ TBallLauncher }
procedure TBallLauncher.Render;
begin
with GameMaster, Canvas do
begin
Fill.Kind := TBrushKind.Solid;
Fill.Color := TAlphaColors.White;
FillRect(RectF(X, Y, X + POD_WIDTH, Y + POD_HEIGHT), 1);
case FBallState of
TBallState.Angle, TBallState.Speed:
begin
Stroke.Kind := TBrushKind.Solid;
Stroke.Dash := TStrokeDash.Dash;
Stroke.Thickness := 1;
Stroke.Color := FBall.Color;
var OX := FBall.X + FBallDiameter / 2;
var OY := FBall.Y + FBallDiameter / 2;
var C, S: Single;
SinCos(DegToRad(FBallAngle), S, C);
var Sp := FBallSpeed * SPEED_K;
DrawLine(PointF(OX, OY), PointF(OX + Sp * C, OY - Sp * S), 1);
end;
end;
end;
end;
procedure TBallLauncher.Start;
begin
FBall := TBallObject.Create(GameMaster);
FBall.Visible := False;
Width := SELF_WIDTH;
Height := SELF_HEIGHT;
end;
procedure TBallLauncher.Update;
procedure IncValue(var AValue: Single; const AMin, AMax: Single);
begin
AValue := AValue + FSign;
if (AValue < AMin) then
begin
AValue := AMin;
FSign := -FSign;
end;
if (AValue > AMax) then
begin
AValue := AMax;
FSign := -FSign;
end;
end;
begin
if Y = 0 then
begin
// 初期化: 位置を中央下部に
Move(
(GameMaster.CanvasR.Width - Width) / 2,
GameMaster.CanvasR.Height - Height);
end;
if GameMaster.Pad.IsPressed(TPadButton.Left) then
begin
// 砲台を動かす
Move(X - SELF_SPEED, Y);
end;
if GameMaster.Pad.IsPressed(TPadButton.Right) then
begin
// 砲台を動かす
Move(X + SELF_SPEED, Y);
end;
if GameMaster.Pad.IsPressed(TPadButton.A) then
begin
// A ボタンが押されたら
FPressed := True;
case FBallState of
TBallState.Stop:
begin
// 準備に移動
FBallState := TBallState.Prepare;
end;
end;
end
else
begin
// A ボタンが離されていたら
case FBallState of
TBallState.Prepare:
begin
// ボール準備
if FPressed then
begin
FBallState := TBallState.Color;
FBallColor := TBallColor.Red;
FBallDiameter := BALL_DIAMETER_MAX;
FColorWait := 0;
FBall.Visible := True;
end;
end;
// 色選択
TBallState.Color:
begin
if FPressed then
begin
FBallState := Inflate;
FSign := -1;
end;
Inc(FColorWait);
if FColorWait = COLOR_WAIT then
begin
FColorWait := 0;
var C := Ord(FBallColor) + 1;
if (C > Ord(High(TBallColor))) then
FBallColor := Low(TBallColor)
else
Inc(FBallColor);
FBall.ChangeColor(FBallColor);
end;
end;
// サイズ選択
TBallState.Inflate:
begin
if FPressed then
begin
FBallState := Angle;
FSign := 2;
FBallSpeed := SPEED_MAX;
end;
IncValue(FBallDiameter, 0, BALL_DIAMETER_MAX);
end;
// 角度選択
TBallState.Angle:
begin
if FPressed then
begin
FBallState := Speed;
FSign := 1;
end;
IncValue(FBallAngle, ANGLE_MIN, ANGLE_MAX);
end;
// 速度選択
TBallState.Speed:
begin
if FPressed then
FBallState := TBallState.Launch;
IncValue(FBallSpeed, SPEED_MIN, SPEED_MAX);
end;
// ボールを発射
TBallState.Launch:
begin
var B := TBallObject.Create(GameMaster);
B.Assign(FBall);
var C, S: Single;
SinCos(DegToRad(FBallAngle), S, C);
B.ChangeSpeed(C * FBallSpeed, -S * FBallSpeed);
FBall.Visible := False;
FBallState := TBallState.Stop;
end;
end;
FPressed := False;
// 状態を反映
FBall.SetParam(
FBallColor,
X + (Width - FBallDiameter) / 2,
Y - FBallDiameter, FBallDiameter,
0,
0);
end;
end;
end.
まとめ
今回、ユーザー入力を扱う方法を紹介しました。
是非、キー押して色々変わるようにしてみてください!