そのタイル、あなたならどうやってデータとして保管する?
前回の記事で Tile Type を作成しました。
type Tile = {
image: HTMLImageElement;
x: number;
y: number;
width: number;
height: number;
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width?: number,
height?: number,
) => void;
};
ゲームのマップには、このタイル情報をどこかに保存する必要があります。
最終的にはマップデータにこのタイルデータを埋め込んで、さらにこれらを TypeScript のファイルとして(多分)保存しておくことになるのですが、このHTMLImageElement
やdraw
関数をゴロゴロと保存するのはどうなのでしょうか?
特にHTMLImageElement
に関してはそもそも DOM なので保存することは出来ないでしょう。
ただ、getTile
関数があるのでそれを活用するための新しい型を作っていきましょう。
シリアライズ
とは?
TypeScript 含め、様々なプログラミング言語において JSON (JavaScript Object Notation
) というデータ形式が対応しています。
JSON とは、JavaScript のオブジェクトをほぼそのまま文字列として表現したもので、多くの言語にてデータを保管する目的で使用されています。
他にも、WebAPI で使用されていて......兎にも角にも!
データを文字列としてあらわすときに JSON は非常によく使われています!
TypeScript において、データを JSON 文字列に変換する関数のJSON.stringify
がデフォルトで用意されており、何も考えずに使うことが出来ます。
この時、JSON 化できない関数などのデータを含んでいると、JSON.stringify
は不正確なデータを返します。(JSON 化されたものを元に戻そうとしても元のデータと一致しない)
これと逆で、JSON 化してからデータの状態に戻しても元のデータと一致するようなデータをシリアライズ
可能なデータと言います。
TypeScript において、何を基準にシリアライズ可能になっているかというと、そのデータの型がすべてを決めています。
(Rust ではSerialize
トレイトを実装しているかどうかで判断されます)
新しい Tile Type を作ろう
getTile
関数を活用できるような、新しいシリアライズ可能な型を作っていきましょう。
そのため、これまでのTile
型の名前をRealTile
(描画できちゃう Real な Tile) と改名して、その対となるFakeTile
(描画できない Fake な Tile) を作りましょう。
type FakeTile = {
path: string;
x: number;
y: number;
width: number;
height: number;
};
type RealTile = {
image: HTMLImageElement;
x: number;
y: number;
width: number;
height: number;
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width?: number,
height?: number,
) => void;
};
FakeTile
はRealTile
と違い、image
の代わりにpath
を持っています。
これは、getTile
に渡すための情報を持っているからですね~
これでもいいのですが、x
、y
、width
、height
はRealTile
とFakeTile
で共通しているので、これらを共通化して新しい型を作りましょう。
type AbstractTile = {
x: number;
y: number;
width: number;
height: number;
};
type FakeTile = {
path: string;
} & AbstractTile;
type RealTile = {
image: HTMLImageElement;
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width?: number,
height?: number,
) => void;
} & AbstractTile;
このように、共通部分をAbstractTile
(抽象的な Tile) として定義し、FakeTile
とRealTile
でそれを継承することで、コードの重複を減らすことが出来ます。
(本来の Abstract のプログラミング的な意味とは少し離れている気もしますが、本物のプログラミング的な Abstract についても別の記事で紹介していきます。)
相互に変換可能にしてあげよう
現在、getTile
関数はpath
、x
、y
、width
、height
を引数に取り、RealTile
(正確にはPromise<RealTile>
) を返しています。
これは、FakeTile
をRealTile
に変換する関数のように見えますが、それではFakeTile
を引数にとることが通常望ましいと考えます。
そのため、FakeTile
からRealTile
に変換する関数を作りましょう。
const fakeToRealTile = async (fakeTile: FakeTile): Promise<RealTile> => {
return await getTile(
fakeTile.path,
fakeTile.x,
fakeTile.y,
fakeTile.width,
fakeTile.height
);
};
なんかショートカットみたい。
また、その逆としてRealTile
からFakeTile
に変換するものも作りたいですね。
RealTile
はシリアライズ出来ないことを事前に考えているので、RealTile
の中にFakeTile
に変換する関数を入れちゃいましょう。
そのためには、RealTile
にgetFakeTile
関数の定義を追加し、getTile
の中を修正しましょう。
type RealTile = {
image: HTMLImageElement;
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width?: number,
height?: number,
) => void;
getFakeTile: () => FakeTile;
} & AbstractTile;
...
const getTile = async (
path: string,
x: number,
y: number,
width: number,
height: number
): Promise<RealTile> => {
const image = new Image();
image.src = path;
const tileInfo = {
x,
y,
width,
height,
};
await image.decode();
return {
image,
...tileInfo,
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width: number = tileInfo.width,
height: number = tileInfo.height,
) => {
ctx.drawImage(
image,
tileInfo.x,
tileInfo.y,
tileInfo.width,
tileInfo.height,
x,
y,
width,
height
);
},
getFakeTile: () => {
return {
path,
...tileInfo,
};
},
};
};
getTile
の下の方にgetFakeTile
関数を追加し、FakeTile
を返すようにしました。
仕上げ!
タイルサイズが32であることはすでに決まっていることなので、x座標やy座標、サイズの単位をpxからタイルにした方が後々扱いやすくなると思うので、途中に計算過程を含ませましょう。
const getTile = async (
path: string,
x: number,
y: number,
width: number,
height: number
): Promise<RealTile> => {
const image = new Image();
image.src = imagePath;
const tileInfo = {
x: x * TileSize,
y: y * TileSize,
width: width * TileSize,
height: height * TileSize,
};
await image.decode();
return {
image,
...tileInfo,
draw: (
x: number,
y: number,
ctx: CanvasRenderingContext2D,
width: number = tileInfo.width,
height: number = tileInfo.height,
) => {
ctx.drawImage(
image,
tileInfo.x,
tileInfo.y,
tileInfo.width,
tileInfo.height,
x,
y,
width,
height
);
},
getFakeTile: () => {
return {
path,
...tileInfo,
};
},
};
};
getTile
の引数の単位をタイルとして、中でTileSize
を掛け算することで、px単位に変換しています。
draw
の内部では計算処理が行われておらず、px単位になっているのには理由があります。
タイルは32x32ですが、実際に描画される際にはアニメーションなどが行われます。
32px進むまでに線形補間が行われるため、px毎に描画されることになります。
そのため、draw
関数の引数はpx単位で受け取るようにしています。
おわりに
私のレポジトリのtile.ts
とほぼ同じ内容になりました。
これで、タイル部分は完璧ですかね。
次回からマップについて作り始めていきたいと思います。