0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tauriでエンジンからゲームを作ってみるAdvent Calendar 2024

Day 12

【Day12】マップを矢印キーで動かしたい!【QAC24】

Last updated at Posted at 2024-12-11

折り返し...ここからが本編ですよ

これまでは、しっかり解説をしていきましたが、これからはぜひ皆様にコードを考えながら作っていただきます。
あまり答えは提供せず、ヒントを提供していきながら、一緒に作っていきましょう。

じゃないと私と全く同じコードになっちゃうし...作ってワクワクしたいじゃないですか

マップを動かしたい?なんか日本語おかしくない?

RPG の醍醐味ともなる、マップを歩き回る機能をこれから作っていきましょう。
キャラクターがいて、マップを自由自裁に歩き回って...

この処理、非常に大変そうじゃないですか?

プレイヤーの動きとは?

まずは皆さん、車を想像してください。

道路を見てください。車が走っていますね。
観測者が静止している場合、当然ながら車(ターゲット)は動いて見え、周りの風景(マップ)は止まって見えます。

では逆に、あなたが車に乗っているとしましょう。
あなたが動いている車に乗っている場合、車(ターゲット)は止まって見え、周りの風景(マップ)が動いて見えます。

このように、観測者の違いによって物の見え方が変わることがあります。

今回はこれを応用して、プレイヤーを動かすのではなく、周りのマップをプレイヤーの進行方向と逆向きに動かすことで、プレイヤーが動いているように見せることを考えていきます。

キーボードイベントを取得する

TypeScript(ブラウザ上)でキーボードイベントを取得するにはdocumentに対してkeydownイベントを登録します。

まずはsrc/lib/map.tsにキーボードイベントを処理する関数(イベントハンドラ)を作成しましょう。

/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タブに表示されます。

開発者ツールはF12Ctrl+Shift+Iで開くことができます。
(ブラウザによって異なる場合があります。)

Tauri でも同様に、Tauri のウィンドウに対してF12で開発者ツールを開くことができます。

イベントを登録しよう

GameMapのコンストラクタ(クラスをnewでインスタンス化した際にだけ呼び出される特殊な関数)に、イベントを登録する処理を書きましょう。

/src/lib/map.ts
export abstract class GameMap {
  ...

  constructor() {
    document.addEventListener("keydown", this.keydown);
  }

  ...
}

このように、document.addEventListenerに対して、1 つ目の引数にイベント名、2 つ目の引数にイベントハンドラを渡すことで、イベントを登録することができます。

が、classのメソッドをイベントハンドラとして登録する際は少し特殊な事例があります。

イベントハンドラで実行される関数は、thisが使えなくなってしまいます。
本来であればthisはそのクラス(GameMap)のインスタンスを指すべきですが、イベントハンドラ内では別のオブジェクトを指すようになってしまいます。

そのため、thisを使う場合は、bindメソッドを使って、this.keydownの中のthisというものがGameMapのインスタンスを指すようにします。

/src/lib/map.ts
export abstract class GameMap {
  ...

  constructor() {
    document.addEventListener("keydown", this.keydown.bind(this));
  }

  ...
}

これで、GameMapのインスタンスを指すthisをイベントハンドラ内で使うことができるようになりました。
実際に実行してみましょう。

$ pnpm tauri dev

これでおおかた、キーボードの矢印キーを押すと、コンソールにが表示されるはずです。

マップを動かす

大切なことは、座標で管理することですよね。

今回、座標の単位はタイルとします。
多分、多くの RPG は一瞬矢印キー押したら、次のタイルまでキーを離していても動き続けると思います。
で、その処理を実装するには、キーを離していても動き続けるようにする必要があります。
そのために、キーボードイベントを監視して、フラグが立ったら動き続けるような感じの、メインループを作成します。

/src/lib/map.ts
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 の描画を実現することができます。
まずは、フレームレートについて定数として定義しましょう。

/src/lib/map.ts
const FrameRate = 60;

そして、setIntervalの第 2 引数に1000 / FrameRateを渡すことで、60FPS の処理を実現することができます。

/src/lib/map.ts
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 作品をプレイして実際に実験しながら仕様を考えてみました。

ちなみに左右同時押しの場合は、先に押されたキーの方向を優先方向として扱い、その優先方向のキーが離されるまでの間は、その方向に動き続けるという仕様でした。

/src/lib/map.ts
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() {
    // ここでマップを動かす
  }

  ...
}

優先方向についてmoveXPrioritymoveYPriorityを作成しました。
このプロパティの型は、リテラル型を使って"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";
}

もし、下矢印キーを押したとき、既に下矢印キーが押されているか、moveYPrioritydownの場合は、何もせずに関数を終了します。何もする必要がないからです。
同様に、上矢印キーを押したとき、既に上矢印キーが押されているか、moveYPriorityupの場合は、何もせずに関数を終了します。

しかし、そうでない場合...まず何も押されていない場合で下矢印キーを押した場合はthis.isArrowDowntrueになり、this.moveYPrioritydownになります。
そのまま離さず、上矢印キーを押すと、this.isArrowUptrueにはなりますが、this.moveYPrioritydownのままです。

これは、3 項演算子ですね。
if 文を 1 行で書けちゃうやつです。

どちらも同じ意味
if (this.isArrowUp) {
  this.moveYPriority = "up";
} else {
  this.moveYPriority = "down";
}

this.moveYPriority = this.isArrowUp ? "up" : "down";

そして、mainの中ではmoveYPriority(moveXPriority)でマップを動かす処理を書いていきます。

/src/lib/map.ts
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 に描画してください。
そしたら、moveYPrioritydownなら、そのピクセル座標をタイルサイズ / (1000 / FrameRate)増やすことで、main 内の処理がちょうど 1000ms で 1 タイル進むようになります。
ただ、小数点の計算になるため、ちょうど 1000ms で 1 タイル進まない場合があります。
そのため、1 タイル分進んだ...ではなく、1 タイル分以上進んだ場合に、先ほどのアニメーション用ピクセル座標を 0 にして、描画座標を 1 変える処理を書けばいいですよね。

ちなみに、インターバルが有効かどうかを調べるには、そのインターバルの返り値をifの条件式にそのまま突っ込めばいいと思います。
但し、その場合はclearIntervalをした後に、そのインターバルの返り値をnullにしておかないと、ifの条件式がtrueのままになってしまうので注意してください。

ここの部分のコードについては、Day18 以降(主人公配置後)の記事で書いておきます。
理由は簡単で、主人公に関するコードもこの後mainに入れていくのですが、今そのコードしか持ってないうえに、主人公を抜いたら動かなくなるんじゃね?ってかもう抜き出せねぇよ!っていう感じだからです。

とりあえず、今回はここで終了です。

おわり

明日までの課題として、最低限「矢印キーを押したら、その方向とは逆向きにマップを動かす」ということだけは実装しておきましょう。

ちなみに、そのマップの現在位置を管理するプロパティ名 (変数名) はしっかり考えた方がいいです。
actorposという名前で管理していましたが、右キー押したらactorposXは減るのに、実際 Actor のポジションとしては増えているのが正しいから...というぐちゃぐちゃなことが起こります (1 敗)

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?