折り返し...ここからが本編ですよ
これまでは、しっかり解説をしていきましたが、これからはぜひ皆様にコードを考えながら作っていただきます。
あまり答えは提供せず、ヒントを提供していきながら、一緒に作っていきましょう。
じゃないと私と全く同じコードになっちゃうし...作ってワクワクしたいじゃないですか
マップを動かしたい?なんか日本語おかしくない?
RPG の醍醐味ともなる、マップを歩き回る機能をこれから作っていきましょう。
キャラクターがいて、マップを自由自裁に歩き回って...
この処理、非常に大変そうじゃないですか?
プレイヤーの動きとは?
まずは皆さん、車を想像してください。
道路を見てください。車が走っていますね。
観測者が静止している場合、当然ながら車(ターゲット)は動いて見え、周りの風景(マップ)は止まって見えます。
では逆に、あなたが車に乗っているとしましょう。
あなたが動いている車に乗っている場合、車(ターゲット)は止まって見え、周りの風景(マップ)が動いて見えます。
このように、観測者の違いによって物の見え方が変わることがあります。
今回はこれを応用して、プレイヤーを動かすのではなく、周りのマップをプレイヤーの進行方向と逆向きに動かすことで、プレイヤーが動いているように見せることを考えていきます。
キーボードイベントを取得する
TypeScript(ブラウザ上)でキーボードイベントを取得するにはdocument
に対してkeydown
イベントを登録します。
まずはsrc/lib/map.ts
にキーボードイベントを処理する関数(イベントハンドラ)を作成しましょう。
export abstract class GameMap {
...
protected keydown(e: KeyboardEvent) {
if (e.code === "ArrowDown") {
console.log("↓");
} else if (e.code === "ArrowUp") {
console.log("↑");
} else if (e.code === "ArrowLeft") {
console.log("←");
} else if (e.code === "ArrowRight") {
console.log("→");
}
}
...
}
そういえばconsole.log
についてのお話を忘れていたので、軽く触れておきます。
console.log
とは?
console.log
は、ブラウザのコンソールにメッセージを出力する関数です。
ブラウザのコンソールというのは、今この Qiita を開いているブラウザの開発者ツールのConsole
タブに表示されます。
開発者ツールはF12
やCtrl+Shift+I
で開くことができます。
(ブラウザによって異なる場合があります。)
Tauri でも同様に、Tauri のウィンドウに対してF12
で開発者ツールを開くことができます。
イベントを登録しよう
GameMap
のコンストラクタ(クラスをnew
でインスタンス化した際にだけ呼び出される特殊な関数)に、イベントを登録する処理を書きましょう。
export abstract class GameMap {
...
constructor() {
document.addEventListener("keydown", this.keydown);
}
...
}
このように、document.addEventListener
に対して、1 つ目の引数にイベント名、2 つ目の引数にイベントハンドラを渡すことで、イベントを登録することができます。
が、class
のメソッドをイベントハンドラとして登録する際は少し特殊な事例があります。
イベントハンドラで実行される関数は、this
が使えなくなってしまいます。
本来であればthis
はそのクラス(GameMap
)のインスタンスを指すべきですが、イベントハンドラ内では別のオブジェクトを指すようになってしまいます。
そのため、this
を使う場合は、bind
メソッドを使って、this.keydown
の中のthis
というものがGameMap
のインスタンスを指すようにします。
export abstract class GameMap {
...
constructor() {
document.addEventListener("keydown", this.keydown.bind(this));
}
...
}
これで、GameMap
のインスタンスを指すthis
をイベントハンドラ内で使うことができるようになりました。
実際に実行してみましょう。
$ pnpm tauri dev
これでおおかた、キーボードの矢印キーを押すと、コンソールに↓
や↑
、←
、→
が表示されるはずです。
マップを動かす
大切なことは、座標で管理することですよね。
今回、座標の単位はタイルとします。
多分、多くの RPG は一瞬矢印キー押したら、次のタイルまでキーを離していても動き続けると思います。
で、その処理を実装するには、キーを離していても動き続けるようにする必要があります。
そのために、キーボードイベントを監視して、フラグが立ったら動き続けるような感じの、メインループを作成します。
export abstract class GameMap {
...
private mainLoopInterval: number = 0;
protected async main() {
// ここでマップを動かす
}
constructor() {
document.addEventListener("keydown", this.keydown.bind(this));
this.mainLoopInterval = setInterval(this.main.bind(this), 100);
}
...
}
setInterval
は、指定した間隔で関数を繰り返し実行する関数です。前にも言ったよね?
ちなみにsetInterval
は、clearInterval
で止めることができますが、これに渡す値は普通のnumber
の値です。
なので、大きく管理するプロパティmainLoopInterval: number
を作成して、setInterval
の戻り値を代入しています。
main
を 100ms 毎に動かし、中でマップ処理を書いて行きたい...のですが...
さっきからマジックナンバーみたいに使っちゃってる 100ms って何ですか???
フレームレートを考えよう
じゃ、60FPS で。
FPS とは、Frames Per Second の略で、1 秒間に何回画面が更新されるかを表す単位です。
なので、Canvas を 60 分の 1 秒毎に(必要があるならclear
->)draw
することで、60FPS の描画を実現することができます。
まずは、フレームレートについて定数として定義しましょう。
const FrameRate = 60;
そして、setInterval
の第 2 引数に1000 / FrameRate
を渡すことで、60FPS の処理を実現することができます。
export abstract class GameMap {
...
private mainLoopInterval: number = 0;
protected async main() {
// ここでマップを動かす
}
constructor() {
document.addEventListener("keydown", this.keydown.bind(this));
this.mainLoopInterval = setInterval(this.main.bind(this), 1000 / FrameRate);
}
...
}
これで 60FPS 処理が行われるはずです。
キャラの動きを考える
もし、キーボードの左キーと右キーを同時押ししたらどうなるでしょうか?
今回私は、とある RPG Maker 作品をプレイして実際に実験しながら仕様を考えてみました。
ちなみに左右同時押しの場合は、先に押されたキーの方向を優先方向として扱い、その優先方向のキーが離されるまでの間は、その方向に動き続けるという仕様でした。
export abstract class GameMap {
...
private isArrowDown: boolean = false;
private isArrowUp: boolean = false;
private isArrowLeft: boolean = false;
private isArrowRight: boolean = false;
private moveXPriority: "left" | "right" | null = null;
private moveYPriority: "up" | "down" | null = null;
protected keydown(e: KeyboardEvent) {
if (e.code === "ArrowDown") {
if (this.isArrowDown || this.moveYPriority === "down") return;
this.isArrowDown = true;
this.moveYPriority = this.isArrowUp ? "up" : "down";
} else if (e.code === "ArrowUp") {
if (this.isArrowUp || this.moveYPriority === "up") return;
this.isArrowUp = true;
this.moveYPriority = this.isArrowDown ? "down" : "up";
} else if (e.code === "ArrowLeft") {
if (this.isArrowLeft || this.moveXPriority === "left") return;
this.isArrowLeft = true;
this.moveXPriority = this.isArrowRight ? "right" : "left";
} else if (e.code === "ArrowRight") {
if (this.isArrowRight || this.moveXPriority === "right") return;
this.isArrowRight = true;
this.moveXPriority = this.isArrowLeft ? "left" : "right";
}
}
protected async main() {
// ここでマップを動かす
}
...
}
優先方向についてmoveXPriority
とmoveYPriority
を作成しました。
このプロパティの型は、リテラル型を使って"left" | "right" | null
としています。
つまり、"left"
か"right"
かnull
のいずれかであることを示しています。
X 成分と Y 成分に分けて考えましょう。
if (e.code === "ArrowDown") {
if (this.isArrowDown || this.moveYPriority === "down") return;
this.isArrowDown = true;
this.moveYPriority = this.isArrowUp ? "up" : "down";
} else if (e.code === "ArrowUp") {
if (this.isArrowUp || this.moveYPriority === "up") return;
this.isArrowUp = true;
this.moveYPriority = this.isArrowDown ? "down" : "up";
}
もし、下矢印キーを押したとき、既に下矢印キーが押されているか、moveYPriority
がdown
の場合は、何もせずに関数を終了します。何もする必要がないからです。
同様に、上矢印キーを押したとき、既に上矢印キーが押されているか、moveYPriority
がup
の場合は、何もせずに関数を終了します。
しかし、そうでない場合...まず何も押されていない場合で下矢印キーを押した場合はthis.isArrowDown
がtrue
になり、this.moveYPriority
がdown
になります。
そのまま離さず、上矢印キーを押すと、this.isArrowUp
がtrue
にはなりますが、this.moveYPriority
がdown
のままです。
これは、3 項演算子ですね。
if 文を 1 行で書けちゃうやつです。
if (this.isArrowUp) {
this.moveYPriority = "up";
} else {
this.moveYPriority = "down";
}
this.moveYPriority = this.isArrowUp ? "up" : "down";
そして、main
の中ではmoveYPriority
(moveXPriority
)でマップを動かす処理を書いていきます。
export abstract class GameMap{
...
protected async main() {
if (this.moveYPriotity === "down") {
// 下に動かす処理 (正確にはマップを上に動かす処理)
} else if (this.moveYPriotity === "up") {
// 上に動かす処理 (正確にはマップを下に動かす処理)
}
if (this.moveXPriority === "left") {
// 左に動かす処理 (正確にはマップを右に動かす処理)
} else if (this.moveXPriority === "right") {
// 右に動かす処理 (正確にはマップを左に動かす処理)
}
}
...
}
で、これをコードとして書くと信じられないぐらい長くなるうえに、答えになってしまうので、ぜひ自分で考えてみてください。
もちろん、以下に沢山ヒントを差し上げましょう。
まず、必要なこととしては、「マップの描画位置をずらす処理」「マップのずれがタイルサイズと同じぐらいになったら、ずれを 0 にして、描画座標を 1 変える処理」「描画座標が 1 変わったときに、キーボードが離されていたのであれば動きを止める処理」ですかね?
GameMap
に対して、アニメーション用のピクセル座標を記憶するプロパティを作りましょう。
さらに、draw
の中で、そのピクセル座標を加味した計算をしてから Canvas に描画してください。
そしたら、moveYPriority
がdown
なら、そのピクセル座標をタイルサイズ / (1000 / FrameRate)
増やすことで、main 内の処理がちょうど 1000ms で 1 タイル進むようになります。
ただ、小数点の計算になるため、ちょうど 1000ms で 1 タイル進まない場合があります。
そのため、1 タイル分進んだ...ではなく、1 タイル分以上進んだ場合に、先ほどのアニメーション用ピクセル座標を 0 にして、描画座標を 1 変える処理を書けばいいですよね。
ちなみに、インターバルが有効かどうかを調べるには、そのインターバルの返り値をif
の条件式にそのまま突っ込めばいいと思います。
但し、その場合はclearInterval
をした後に、そのインターバルの返り値をnull
にしておかないと、if
の条件式がtrue
のままになってしまうので注意してください。
ここの部分のコードについては、Day18 以降(主人公配置後)の記事で書いておきます。
理由は簡単で、主人公に関するコードもこの後main
に入れていくのですが、今そのコードしか持ってないうえに、主人公を抜いたら動かなくなるんじゃね?ってかもう抜き出せねぇよ!っていう感じだからです。
とりあえず、今回はここで終了です。
おわり
明日までの課題として、最低限「矢印キーを押したら、その方向とは逆向きにマップを動かす」ということだけは実装しておきましょう。
ちなみに、そのマップの現在位置を管理するプロパティ名 (変数名) はしっかり考えた方がいいです。
actorpos
という名前で管理していましたが、右キー押したらactorpos
のX
は減るのに、実際 Actor のポジションとしては増えているのが正しいから...というぐちゃぐちゃなことが起こります (1 敗)