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 11

【Day11】Promiseを使ってタイルを正しく描画しよう【QAC24】

Last updated at Posted at 2024-12-10

なぜ正確に描画されなかった?

前回まで、GameGameMapを作り上げて、画面描画が出来そうな感じでした。

しかし、実際にpnpm tauri devで実行したウィンドウにはタイルは描画されていませんでした。

なぜなのでしょうか。

画像の読み込みが終わってない?

今回の肝はGameMapクラスのgenerateMapTilesメソッドです。

abstract class GameMap {
  ...

  protected generateMapTiles() {
    for (let l = 0; l < this.tiles.length; l++) {
      if (!this.realtiles[l]) this.realtiles[l] = [];
      for (let t = 0; t < this.tiles[l].length; t++) {
        const tile = this.tiles[l][t];
        fakeTileToRealTile(tile.tile).then((realTile) => {
          this.realtiles[l][t] = {
            x: tile.x,
            y: tile.y,
            tile: realTile,
          };
        });
      }
    }
  }

  ...
}

このメソッドでMapTileからDrawableTileに変換しています。
答えは単純で、fakeTileToRealTile関数が非同期処理を行っているためです。

いやいや、thenを使って処理を行うから動くんでしょ?って思われるかもしれません。

非同期処理の「投げっぱなし」とは?

Day 7 で以下のように書いております。

async関数をawaitを付けずにそのまま実行すると、本当の値(今回ならTile)ではなくPromiseが返ってきます。
また、awaitを付けないと、処理完了まで止まることはなく、どんどん次の処理へと進んでいってしまいます。

これは、thenだろうと同じです。
この、async関数なのにawaitを使わないせいで、処理が止まらずに次の処理へと進んでしまうことを「非同期処理の投げっぱなし」と言います。

fakeTileToRealTile(tile.tile).then((realTile) => {
  this.realtiles[l][t] = {
    x: tile.x,
    y: tile.y,
    tile: realTile,
  };
});

このコードは、fakeTileToRealTile関数が終わったらthen内の処理を実行するということです。
この時、realTilePromise<RealTile>ではなく、RealTileになります。これは、thenを使ったためにPromiseが解決されているからです。
しかしながら、「終わったらthen内の処理をしてね」ということが書いてあるだけで、それを理解したforループは終わるのは待たずに次のループへと進みます。

最終的にこの関数では、then内の処理が全て終わることはなく、ループが終わり、generateMapTilesメソッドが終了してしまいます。
そのため、this.realtilesgenerateMapTilesメソッドが終了したときには不完全な場合があります。

その不完全な状態でdrawを行ったらどうなるかはわかりますよね~...

では、どうすればいいのでしょうか?

Promiseを使ってみよう

Promiseは、非同期処理を行うためのクラスです。
今までのPromise<T>と同じPromiseです。
このクラスを使うことで、非同期処理をより高度に扱うことが出来ます。

では、もう早速答えを指し示してしまいましょう。

abstract class GameMap {
  ...

  protected generateMapTiles() {
    let loadable: number = 0;
    let loaded: number = 0;

    for (let l = 0; l < this.tiles.length; l++) {
      if (!this.realtiles[l]) this.realtiles[l] = [];
      for (let t = 0; t < this.tiles[l].length; t++) {
        const tile = this.tiles[l][t];
        loadable++;
        fakeTileToRealTile(tile.tile).then((realTile) => {
          this.realtiles[l][t] = {
            x: tile.x,
            y: tile.y,
            tile: realTile,
          };
          loaded++;
        });
      }
    }

    return new Promise((resolve) => {
      const interval = setInterval(() => {
        if (loaded === loadable) {
          clearInterval(interval);
          resolve();
        }
      }, 100);
    });
  }

  ...
}

まず、読み込まれるべきタイルの数をloadableという変数で管理します。
そして、then内で読み込みが終わったらloadedを増やします。
つまり、loadableloadedが一致したら全てのタイルが読み込まれたということです。

return new Promiseとありますが、これはPromiseインスタンスを返すということです。
Promiseは、resolverejectを引数に取るコールバック関数を引数に取ります。
そのうちrejectは省略可能です。

コールバック関数の中身
const interval = setInterval(() => {
  if (loaded === loadable) {
    clearInterval(interval);
    resolve();
  }
}, 100);

このresolverejectは、指定したコールバック関数の中で呼び出される関数になります。
そのうち、resolveを呼び出すと、処理を解決したことにし、Promiseを完了させます。
rejectは、処理が何らかの理由で失敗してしまったことにして、Promiseを失敗させます。(呼び出し側でエラーとなる)

今回は、setIntervalという、インターバルで処理を行うものを使って、100msごとに確認しています。
loadedloadableが一致したら、clearIntervalでインターバルの処理を終了させて(もうこれ以降はインターバル処理が行われなくなるものであって、即座にすべてのインターバル処理が停止するようなものではない。)、resolveを呼び出してPromiseを完了させます。

Promiseが完了」すると、このgenerateMapTilesを呼び出した側でいうと、await generateMapTiles()が終了し、次の処理へと進むことが出来ます。

じゃあそれを反映させてメインを書き直そう

もう一度、お試しのためだけにgenerateMapTilespublicにしましょう。

src/main.ts
import { Game } from "./lib/game";
import { SampleMap } from "./maps/sample";

let game: Game;

const main = async () => {
  game = new Game();
  game.changeMap(SampleMap);
  await game.map?.generateMapTiles();
  game.map?.draw();
};

window.addEventListener("DOMContentLoaded", () => {
  main();
});

ちなみに、このコードにおけるmain関数もasync関数になります。
window.addEventListener("DOMContentLoaded", () => { ... })の中でmain関数を呼び出していますが、awaitは付けず、投げっぱなしで処理しています。
投げっぱなしでも問題がないのは、それ以降に処理がないからです。

これで、タイルが正しく描画されるはずです。

$ pnpm tauri dev

まとめ

少し高度な非同期処理を行うために、Promiseを使ってみました。

ちなみになんですが、「そもそもfakeTileToRealTileawaitで呼び出せば、うまくいくのでは?」とお思いでしょうか。
確かに、以下のコードでも描画は出来ます。

abstract class GameMap {
  ...

  protected async generateMapTiles() {
    for (let l = 0; l < this.tiles.length; l++) {
      if (!this.realtiles[l]) this.realtiles[l] = [];
      for (let t = 0; t < this.tiles[l].length; t++) {
        const tile = this.tiles[l][t];
        this.realtiles[l][t] = {
          x: tile.x,
          y: tile.y,
          tile: await fakeTileToRealTile(tile.tile);,
        };
      }
    }
  }

  ...
}

一方で、これではfakeTileToRealTileの処理が終わるまで次の処理に進みません。
つまり、1タイルごとに画像を読み込み、処理を進めていくことになります。

今回のPromiseを使ったコードであれば、全てのタイルの読み込み処理を一度に行うため、並列的に画像読み込み処理が行われます。
そのため、より素早くタイルを読み込むことが出来るのです。多分。
(あんまりI/Oには詳しくないのだ...)

0
0
6

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?