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 8

【Day8】タイルを描画してみよう【QAC24】

Last updated at Posted at 2024-12-07

そのタイル、あなたならどうやってデータとして保管する?

前回の記事で 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 のファイルとして(多分)保存しておくことになるのですが、このHTMLImageElementdraw関数をゴロゴロと保存するのはどうなのでしょうか?
特に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;
};

FakeTileRealTileと違い、imageの代わりにpathを持っています。
これは、getTileに渡すための情報を持っているからですね~

これでもいいのですが、xywidthheightRealTileFakeTileで共通しているので、これらを共通化して新しい型を作りましょう。

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) として定義し、FakeTileRealTileでそれを継承することで、コードの重複を減らすことが出来ます。

(本来の Abstract のプログラミング的な意味とは少し離れている気もしますが、本物のプログラミング的な Abstract についても別の記事で紹介していきます。)

相互に変換可能にしてあげよう

現在、getTile関数はpathxywidthheightを引数に取り、RealTile (正確にはPromise<RealTile>) を返しています。
これは、FakeTileRealTileに変換する関数のように見えますが、それでは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に変換する関数を入れちゃいましょう。

そのためには、RealTilegetFakeTile関数の定義を追加し、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とほぼ同じ内容になりました。
これで、タイル部分は完璧ですかね。

次回からマップについて作り始めていきたいと思います。

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?