なぜ正確に描画されなかった?
前回まで、Game
とGameMap
を作り上げて、画面描画が出来そうな感じでした。
しかし、実際に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
内の処理を実行するということです。
この時、realTile
はPromise<RealTile>
ではなく、RealTile
になります。これは、then
を使ったためにPromise
が解決されているからです。
しかしながら、「終わったらthen
内の処理をしてね」ということが書いてあるだけで、それを理解したfor
ループは終わるのは待たずに次のループへと進みます。
最終的にこの関数では、then
内の処理が全て終わることはなく、ループが終わり、generateMapTiles
メソッドが終了してしまいます。
そのため、this.realtiles
はgenerateMapTiles
メソッドが終了したときには不完全な場合があります。
その不完全な状態で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
を増やします。
つまり、loadable
とloaded
が一致したら全てのタイルが読み込まれたということです。
return new Promise
とありますが、これはPromise
インスタンスを返すということです。
Promise
は、resolve
とreject
を引数に取るコールバック関数を引数に取ります。
そのうちreject
は省略可能です。
const interval = setInterval(() => {
if (loaded === loadable) {
clearInterval(interval);
resolve();
}
}, 100);
このresolve
とreject
は、指定したコールバック関数の中で呼び出される関数になります。
そのうち、resolve
を呼び出すと、処理を解決したことにし、Promise
を完了させます。
reject
は、処理が何らかの理由で失敗してしまったことにして、Promise
を失敗させます。(呼び出し側でエラーとなる)
今回は、setInterval
という、インターバルで処理を行うものを使って、100msごとに確認しています。
loaded
とloadable
が一致したら、clearInterval
でインターバルの処理を終了させて(もうこれ以降はインターバル処理が行われなくなるものであって、即座にすべてのインターバル処理が停止するようなものではない。)、resolve
を呼び出してPromise
を完了させます。
「Promise
が完了」すると、このgenerateMapTiles
を呼び出した側でいうと、await generateMapTiles()
が終了し、次の処理へと進むことが出来ます。
じゃあそれを反映させてメインを書き直そう
もう一度、お試しのためだけにgenerateMapTiles
をpublic
にしましょう。
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
を使ってみました。
ちなみになんですが、「そもそもfakeTileToRealTile
をawait
で呼び出せば、うまくいくのでは?」とお思いでしょうか。
確かに、以下のコードでも描画は出来ます。
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には詳しくないのだ...)