LoginSignup
117
115

ブラウザで動くレトロゲームを作ってみた

Posted at

はじめに

マイコンBASICマガジン(ベーマガ)でプログラミングを学んだ身としてはべーマガの投稿プログラムっぽいゲームを作りたくなるときがたまーにあります。そんな折、仕事でWebフロントエンドを任され、いろいろ調べながら仕事を進めたのですが、このときの知見を利用すればベーマガ的なゲームを作れそうだったので作ってみることにしました。

ご存じでないヤングな皆さんのため簡単に説明すると、ベーマガは主に1980年代~2000年代前半まで発刊されたパソコン雑誌です。他のパソコン雑誌と大きく異なっていた特徴は読者が投稿したプログラムのソースコードが紙面に多く掲載されていたことで、そのコードを自分のパソコンに打ち込む(写経する)ことでプログラムを動かせました。そして掲載されるプログラムの多くはゲーム(いまで言うレトロゲームの類)で、たいていのプログラムは紙面の 2 ~ 4 ページ程度に収まっていたと思います。その程度のサイズでそれなりのゲームを動かせたのですから、今にして思えばある意味凄い時代でした。

お題

作るものはもちろんベーマガの投稿プログラムによくあったシンプルなゲームです。今回は対戦ボン〇ーマンもどきを作ってみることにしました。ルールがシンプルでも対戦だと熱くなれるものです。

ソースコードとか

この記事で解説しているゲームのソースコードは公開しています。プレイ動画を見ることもできます。

またブラウザで実際に動かすことができます。

技術選定

使う技術は以下のようになります。

Web フロントエンドなので基本的には JavaScript や TypeScript を使うのが一般的です。今回は型情報を扱える TypeScript を選択しました。
ほとんどの処理は TypeScript で書くことができますが、比較的重い処理をするのには向いていません。今回作るゲームは対戦ゲームですが、コンピュータプレイヤーとも対戦できるようにしたいので、コンピュータの思考ルーチンを Rust で書いて Wasm にコンパイルすることにしました。
画面への描画は PixiJS というライブラリを使うことにしました。WebGL を使って 2D グラフィックを描画するライブラリとして高い評価を得ているようです。
BGM や効果音の再生には howler.js というライブラリを使うことにしました。こちらも定番ライブラリのようです。

ゲームを作るならゲームエンジンを使えば良いのでは?と思う方も多いかと思います。それはまったくの正論で、Unity なり Unreal Engine なり Godot なり使えば、コーディングを極力避けゲームデザインに集中したゲーム開発が可能なはずです。でもこの記事を書くモチベーションはベーマガの投稿プログラムのようなゲームを作ることですのでコードを書いて実現することにこだわります!

開発環境

開発環境としては以下を使うことにしました。

VSCode は超定番のコードエディタですね。個人的にはサジェスチョンのポップアップがうざいのでいくつか設定をいじって使っています。
Vite は Web フロントエンド用のビルドツールです。TypeScript のコードを JavaScrpt に変換したり、ライブラリのコードとガッチャンコして一つのファイルにしてくれたりする頼もしい味方です。
Vercel は Web アプリのホスティングサービスです。非商用なら無料で使えます。GitHub と連携できて、簡単な設定をするだけで git push 即デプロイができるようになります。

なお開発マシンは Windows でも Mac でも Linux でも大丈夫です。ただし Node.js を使えるようにしておきましょう。

素材収集

ゲームを作ろうとしたときに意外と厄介なのは画像やサウンドの素材を揃えることです。いや、人によっては自前で描いたり作曲したりできちゃったりするのかもしれませんが私のような凡才にそんな芸当はできないわけです。でもネットには無料で公開されている素材が結構ありますので非常に有り難く使わせてもらうことにします。
(無料と言っても細かい条件があったりしますので必ず利用規約を確認しましょう)

画像

まず画像は以下のサイトの素材を使わせていただくことにしました。

ゲームに使えるドット絵がたくさんあって今回作ろうとしている 2D ゲームにはとても向いています。めちゃくちゃ助かります。
ですが今回作るゲームに必要な画像をすべて集めることはできませんでした。これはもう仕方ありませんので自分で描くことにしました。

サウンド

効果音や BGM は以下のサイトの素材を使わせていただくことにしました。

こちらもゲームに使える効果音や BGM が揃っていてとても助かります。色々な条件で検索をかけられるのでゲームに合った音を探しましょう。

プロジェクトの作成

素材が揃ったところでプロジェクトを作成します。今回は Vite というビルドツールを使うのでその作法に従うことにします。以下のコマンドで TypeScript を使ったプロジェクト (vanilla-ts テンプレート使用) を作成します。

npm create vite@latest web-bomber -- --template vanilla-ts

web-bomber というフォルダが作られその配下がプロジェクトの中身となります。そうしたらまずは必要なパッケージをインストールします。

cd web-bomber
npm install

とりあえずこれでプロジェクトの作成はできました。

必要なライブラリの組み込み

さっき書いたように今回のゲームでは PixiJShowler.js というライブラリを使います。ですのでこれらライブラリを使えるようにします。それには以下のコマンドを使います。

npm install pixi.js howler @types/howler

ちなみに最後の @types/howler は howler.js 用の型定義ファイルです。howler.js 自体は JavaScript で書かれているため TypeScript で扱おうとすると型情報がなくて使いにくかったりします。ですが有名どころのライブラリには大抵有志によって型定義ファイルが作られているので、それをインストールすることで TypeScript から型情報ありで使えるようになります。

不要なファイルの削除

プロジェクト作成時にいくつかのファイルがテンプレートから自動生成されますが、以下のファイルは不要なので削除します。

  • public/vite.svg
  • src/counter.ts
  • src/style.css
  • src/typescript.svg

index.html の修正

プロジェクトルートにある index.html に修正を加えます。

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Bomber Mates</title>
    <style>
      canvas {
          display: block;
      }
    </style>
  </head>
  <body style="margin: 0;">
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

変更したのはアイコンへの <link> タグと不要な <div> タグの削除と <title> の変更、それと <body> のマージン削除です。特に <body> のマージン削除は重要で、これをしないとゲーム画面の上下左右に余計な空白ができちゃったりします。

エントリポイントの作成

プログラムのエントリポイントは src/main.ts になります。このファイルはすでに作られていますが中身を以下のように書き換えます。

src/main.ts
import { Application } from 'pixi.js'

const app = new Application({
  resizeTo: window,
  antialias: true,
})
window.document.body.appendChild(app.view as any)

このコードは PixiJS の Application クラスをインスタンス化し画面を作成します。この画面にスプライト等を追加することでゲーム画面を作ることになります。

とりあえず実行

ここまでできたらいったん実行してみます。Vite でデバッグ実行するには以下のコマンドを実行します。

npm run dev

するとビルドが実行され、エラーがなければ以下のような出力が現れます。

  VITE v4.5.0  ready in 829 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

Local: のところに書かれている URL をブラウザで開くことでこのプログラムを実行することができます(つまり Vite によって Web サーバが実行されているわけです)。真っ黒な画面が表示されたら成功です。

ちなみにこの後コードを追加していくわけですが、Vite はホットリロードに対応しているためコードを書き換えて保存すると自動的にブラウザ側に変更が反映されます。

画面遷移とアニメーションの仕組みを作る

ゲームアプリには画面遷移とアニメーションは欠かせません。今回作るゲームでは以下の3つの画面を想定します。

  • ローディング画面:アプリ起動時に画像やサウンドデータを読み込む画面
  • メニュー画面:ゲームモードを選択する画面
  • ゲーム画面:ゲーム中の画面

そこで簡単な画面遷移の仕組みを作ります(ついでにアニメーションの仕組みも作ります)。まず「画面」を表現するためのベースクラス Screen を定義します。

src/Screen.ts
import { Application, Container } from "pixi.js";

export class Screen {
    static readonly WIDTH = 800
    static readonly HEIGHT = 480

    app: Application
    baseStage: Container

    constructor(app: Application) {
        this.app = app
        this.baseStage = new Container()
        app.stage.addChild(this.baseStage)
    }

    onClose(): void {
        this.baseStage.removeFromParent()
    }

    onNextFrame(): void {
        // 画面サイズに合わせてスケールさせる
        this.app.stage.scale.x = window.innerWidth / Screen.WIDTH
        this.app.stage.scale.y = window.innerHeight / Screen.HEIGHT
    }
}

画面サイズは幅 800 、高さ 480 固定にしています。この数字の単位は使う画像素材のピクセルに合わせています。今回使う画像素材では各キャラが 32 × 32 ピクセルで描かれているためそれに合わせた画面サイズにしました。また Container という型のプロパティ baseStage がありますが、これは型の名前の通り PixiJS の描画要素を複数入れられるコンテナになります。この画面で描画するものをすべてこのコンテナに突っ込む想定です。こうすることで、画面遷移時に遷移前の画面で表示していた描画要素を簡単に破棄できるようになります。onNextFrame() はアニメーションのための関数で、フレームごとに呼ばれる想定です。

ベースクラスができたのでこれを継承した各画面のクラスを作ります。この時点では中身は空です。

src/loading_screen/LoadingScreen.ts
import { Application } from "pixi.js"
import { Screen } from "../Screen"

export class LoadingScreen extends Screen {
    constructor(app: Application) {
        super(app)
    }
}
src/main_menu/MainMenuScreen.ts
import { Application } from "pixi.js"
import { Screen } from "../Screen"

export class MainMenuScreen extends Screen {
    constructor(app: Application) {
        super(app)
    }
}
src/game_screen/GameScreen.ts
import { Application } from "pixi.js"
import { Screen } from "../Screen"

export class GameScreen extends Screen {
    constructor(app: Application) {
        super(app)
    }
}

次にこの Screen クラスのインスタンスをハンドルする処理を main.ts に追加します。

src/main.ts
let currentScreen: Screen|null = null

export function startMainMenu() {
    if (currentScreen != null) {
        currentScreen!.onClose()
        app.stage.removeChildren()
    }
    currentScreen = new MainMenuScreen(app)
}

export function startGame() {
    if (currentScreen != null) {
        currentScreen!.onClose()
        app.stage.removeChildren()
    }
    currentScreen = new GameScreen(app)
}

currentScreen = new LoadingScreen(app)

app.ticker.maxFPS = 60
app.ticker.add(() => {
    currentScreen?.onNextFrame()
})

currentScreen は現在表示している画面のインスタンスを保持することになります。関数 startMainMenu()startGame() はそれぞれメニュー画面、ゲーム画面への画面遷移を行います。
またアニメーションを行うために app.ticker を使います。これはディスプレイのリフレッシュ(フレームの書き換え)時に呼ばれるコールバック関数をセットできる機能で、たとえばリフレッシュレートが 60fps のディスプレイでは基本的に秒間 60 回呼ばれるコールバックを作ることができます(ただし重い処理をしていると呼び出しの時間間隔が長くなる所謂処理落ちが発生します)。しかしリフレッシュレートはディスプレイによって違うので、ゲーム開発ではどんなリフレッシュレートでもまともに動くよう実装する必要があります。その手法はいくつかあり、一般的には「前回描画してからの経過時間の分だけキャラを動かす」ような実装が用いられますが、この手法では考慮することが増えてしまうため、今回は「フレームレートを 60fps に固定する」想定の実装にします。

app.ticker.maxFPS = 60 の指定は最大フレームレートを指定する(制限する)ものですので「固定」という意味とは本来異なります。しかしリフレッシュレートが 60fps を下回るディスプレイは現在はほとんどありません。ですのでフレームごとに実行する処理が十分短時間で終われば、この指定で「固定」を実現できます。

リソースの読み込み

スプライトシートの作成

次に画像やサウンドのデータを読み込むロジックを実装します…と言いたいところですが、その前にやらなければならないことがあります。それはスプライトシートの作成です。

ゲームで使う画像の数はとても多くなるのが普通です。今回作るゲームも内容はシンプルですが画像の数はそこそこあります。それらの画像を全部別々のファイルとして扱うと、それらすべての画像をひとつずつ読み込まなければならなくなり効率が良くありません。そのためそれらの画像を一つの画像ファイルにまとめてしまい、1回の読み込み処理で多数の画像を一気に読み込んでしまうということをよくやります。この「一つにまとめた画像」のことをスプライトシートと言います。PixiJS はスプライトシートに対応しており、アニメーションするキャラを簡単に扱うことができます。

というわけで素材の画像と自作の画像をくっつけて作成したのが以下の画像です。

chars.png

ただし PixiJS でスプライトシートを使う場合は画像ファイルとは別に「スプライトシートのどの位置にどのキャラの画像があるのか」を定義した JSON ファイルを用意する必要があります。この JSON の構造は以下のような感じになります。

sprite_sheet.json
{
    "frames": {
        "bomb1.png": {
            "frame": {"x":1,"y":1,"w":32,"h":32},
            "rotated": false,
            "trimmed": false,
            "spriteSourceSize": {"x":0,"y":0,"w":32,"h":32},
            "sourceSize": {"w":32,"h":32}
        },
        "bomb2.png": {
            "frame": {"x":1,"y":35,"w":32,"h":32},
            "rotated": false,
            "trimmed": false,
            "spriteSourceSize": {"x":0,"y":0,"w":32,"h":32},
            "sourceSize": {"w":32,"h":32}
        },
        "bomb3.png": {
            "frame": {"x":1,"y":69,"w":32,"h":32},
            "rotated": false,
            "trimmed": false,
            "spriteSourceSize": {"x":0,"y":0,"w":32,"h":32},
            "sourceSize": {"w":32,"h":32}
        },
        ....
    },
    "animations": {
        "bomb": ["bomb1.png","bomb2.png","bomb3.png"],
        ....
    },
    "meta": {
        "image": "sprite_sheet.png",
        "format": "RGBA8888",
        "size": {"w":238,"h":238},
        "scale": "1"
    }
}

スプライトシート作成時の重要なポイントとして Extrude edges という概念があります。2D のゲームでは複数の画像をタイル状に並べて描画することで隙間のない大きな画像を表現することがあります。今回作るゲームでは「爆発」の描画がこれに当たります。

ところが何も考えずにこれを実装すると、画像と画像の間に細かい隙間ができてしまうことがあります。原因はスケーリングの処理にあり、画像を拡大描画するときに小数点以下の座標に必ずしも妥当な描画がされるわけではないために起こります。この問題を解消するために使われるのが Extrude edges という概念で、具体的にはスプライトシートの「本来のキャラ画像の領域」から上下左右に 1 ~ 2 ピクセル(大抵は 1 ピクセルで十分だと思います)だけ余分な領域を作り、そこに「本来のキャラ画像」を「伸ばして」描いておきます。こうするとスケーリングしても隙間なく描画されるようになります。

リソースファイルの配置

スプライトシートのファイルとサウンドファイルを public ディレクトリ配下に配置します。このディレクトリ内に置いたファイルは Web サーバが無制限に公開することになります。たとえば public/hoge.png に置いた画像ファイルは http://your_host/hoge.png でアクセスできるようになります。

リソース一式読み込みの実装

上で公開したリソースファイルを本体プログラムで読み込むようにします。

src/AppResource.ts
import { Assets, Spritesheet } from "pixi.js"
import { Howl } from "howler"

const ALL_RESOURCE_COUNT = 8

let loadedResouceCount = 0

export let spritesheet: Spritesheet|null = null
export const sounds = {
    startGame: new Howl({ src: ['sounds/start_game.webm', 'sounds/start_game.mp3'], volume: 0.5, onload: onLoadResouce }),
    explosion: new Howl({ src: ['sounds/explosion.webm', 'sounds/explosion.mp3'], onload: onLoadResouce }),
    setBomb: new Howl({ src: ['sounds/set_bomb.webm', 'sounds/set_bomb.mp3'], onload: onLoadResouce }),
    walk: new Howl({ src: ['sounds/walk.webm', 'sounds/walk.mp3'], loop: true, onload: onLoadResouce }),
    powerUp: new Howl({ src: ['sounds/power_up.webm', 'sounds/power_up.mp3'], onload: onLoadResouce }),
    crash: new Howl({ src: ['sounds/crash.webm', 'sounds/crash.mp3'] ,onload: onLoadResouce }),
    bgm: new Howl({ src: ['sounds/Daily_News.webm', 'sounds/Daily_News.mp3'], loop: true, volume: 0.7, onload: onLoadResouce }),
}

let progressFunc: (allCount: number, finishCount: number) => void

export function load(progressCallback: (allCount: number, finishCount: number) => void) {
    progressFunc = progressCallback
    // スプライトシートの読み込み
    Assets.load('images/sprite_sheet.json').then(sheet => {
        spritesheet = sheet
        onLoadResouce()
    })
}

function onLoadResouce() {
    loadedResouceCount++
    progressFunc(ALL_RESOURCE_COUNT, loadedResouceCount)
}    

このモジュールが読み込まれると複数のサウンドファイルの読み込みが始まります。サウンドの読み込みには Howl のコンストラクタを使います。webm 形式のファイルと mp3 形式のファイルを指定しているのは「 webm 形式に対応しているデバイスなら webm 形式のファイルを使い、そうでないなら mp3 形式のファイルを使う」という意味になります。これは webm 形式の方が圧縮率が高く、ほぼ同じ音質なら webm 形式を使った方が読み込み時間が短く済むためです。ですが webm 形式は比較的新しい規格のため、対応していないデバイス(あるいはブラウザ)では mp3 形式を使うようにしています。
次に load() 関数でスプライトシートを読み込むようにしています。サウンドファイルの読み込みと異なりわざわざ読み込み用の関数を用意しているのは、スプライトシートの読み込みは PixiJS の初期化が終わった後でなければいけないためです。ついでにこの関数ではリソースファイルの読み込みの進捗状況(何個中何個まで読み込み終わったか)を受け取るためのコールバック関数を渡せるようにしています。

ローディング画面の実装

読み込みのロジックができたのでそれを実行するローディング画面を実装します。

src/loading_screen/LoadingScreen.ts
import { Application, Text } from "pixi.js"
import { Screen } from "../Screen"
import { startMainMenu } from "../main"
import * as AppResource from "../AppResource"

export class LoadingScreen extends Screen {
    text: Text

    constructor(app: Application) {
        super(app)

        // 背景色
        app.renderer.background.color = 0x000000

        // テキスト
        this.text = new Text("LOADING: 0%", { fontFamily: 'Arial', fontSize: 32, fill: 0xe0e0e0, fontWeight: 'bold' })
        this.text.x = (Screen.WIDTH - this.text.width) / 2 
        this.text.y = Screen.HEIGHT / 2 - 16
        this.baseStage.addChild(this.text)
        this.setText(1, 0)

        AppResource.load((allCount, finishCount) => {
            this.setText(allCount, finishCount)
            if (allCount == finishCount) {
                startMainMenu()
            }
        })
    }

    private setText(allCount: number, finishCount: number) {
        const p = Math.floor(finishCount * 100 / allCount + 0.5)
        this.text.text = `LOADING: ${p}%`
    }
}

ローディング画面ではリソース読み込みの進捗をパーセンテージで表示します。PixiJS でテキストを表示するには Text クラスのオブジェクトを生成して描画対象となっているコンテナ(ここでは this.baseStage )に追加します。ちなみに Text クラスは Sprite クラスを継承しているためスプライトの一種として扱うことができます。

OpenGL や WebGL でゲームを作る場合、テキストの表示に悩まされることがあります。というのも OpenGL や WebGL 自体にはテキストを描画する API がなく(当然と言えば当然ですが)、開発者は何らかの方法でテキストの画像を生成してその画像を描画する必要があるためです。ですが PixiJS はそんな面倒なことを自動的にやってくれるため簡単にテキストを扱えます。パフォーマンスが気になる場合やプラットフォームに依存しないフォントを使いたい場合はビットマップフォントを読み込むこともできます。

メニュー画面の実装

メニュー画面ではゲームモードを選択できるようにします。ゲームモードには以下の 4 種類があるものとして、これらのうちどれか一つを選ぶとゲームが開始されるようにします。

  • 人間対人間
  • AI 対人間
  • 人間対 AI
  • AI 対 AI

( AI というと深層学習モデルを想像してしまうかもしれませんがここでは単純にコンピュータプレイヤーの意味です。また「人間対 AI 」と「 AI 対人間」の違いは AI が受け持つプレイヤーが赤と青のどちらなのかだけです)

メニューアイテムを定義する

まずプレイヤーの種類を表す列挙型 PlayerType を定義します。

src/PlayerType.ts
export enum PlayerType {
    HUMAN, AI
}

そのうえでメニュー画面で選択できるアイテムのクラス MenuItem を定義します。

src/main_menu/MainMenuScreen.ts
class MenuItem {
    screenText: string
    playerType1: PlayerType
    playerType2: PlayerType

    constructor(screenText: string, playerType1: PlayerType, playerType2: PlayerType) {
        this.screenText = screenText
        this.playerType1 = playerType1
        this.playerType2 = playerType2
    }
}

そうすればメニュー画面に表示するアイテムは以下のような配列にできます。

src/main_menu/MainMenuScreen.ts
export class MainMenuScreen extends Screen {
    private static readonly menuItems: MenuItem[] = [
        new MenuItem("HUMAN VS AI", PlayerType.HUMAN, PlayerType.AI),
        new MenuItem("AI VS HUMAN", PlayerType.AI, PlayerType.HUMAN),
        new MenuItem("HUMAN VS HUMAN", PlayerType.HUMAN, PlayerType.HUMAN),
        new MenuItem("AI VS AI", PlayerType.AI, PlayerType.AI),
    ]
    ...
}

メニュー画面で使う画像の読み込みと表示

次にメニュー画面で使う画像の読み込みを実装します。「え?リソースの読み込みはさっきやったじゃん?」と思うかもしれませんが、あれは読み込んだ画像は実はゲーム画面でキャラの画像だけです。メニュー画面にはゲームロゴの画像と操作方法を説明画像を表示するのでそれらの画像を読み込みます。PixiJS で画像ファイルを読み込み表示するには以下のようにします。

src/main_menu/MainMenuScreen.ts
export class MainMenuScreen extends Screen {
    ...
    private logoSprite: Sprite|null = null
    private controlsSprite: Sprite|null = null
    
    constructor(app: Application) {
        ...
        // ロゴと操作画像の読み込み
        Assets.add('logo', 'images/logo.png')
        Assets.add('controls', 'images/controls.png')
        Assets.load(['logo', 'controls']).then(res => {
            // ロゴ
            this.logoSprite = new Sprite(res['logo'])
            this.logoSprite.x = (Screen.WIDTH - this.logoSprite.width) / 2
            this.logoSprite.y = Screen.HEIGHT / 4 - this.logoSprite.height / 2
            this.baseStage.addChild(this.logoSprite)

            // 操作系
            this.controlsSprite = new Sprite(res['controls'])
            this.controlsSprite.x = Screen.WIDTH - this.controlsSprite.width - 10
            this.controlsSprite.y = Screen.HEIGHT - this.controlsSprite.height - 10
            this.baseStage.addChild(this.controlsSprite)
            ...

PixiJS で画像を描画するにはその画像をスプライトにする必要があります。そしてスプライトを生成するには画像をテクスチャにする必要があります。画像をテクスチャとして読み込むには Assets.add() メソッドに画像ファイルへのパスを登録しておいて Assets.load() メソッドを呼びます。複数の画像ファイルを読み込みたい場合は Assets.add() をファイルの数だけ呼んでから Assets.load() を呼べば一気に読み込むことができます。読み込んだ画像のテクスチャオブジェクトは連想配列として返されます。各テクスチャオブジェクトを Sprite クラスのコンストラクタに渡すことでスプライトオブジェクトを生成できます。

キー入力で操作できるようにする

次に、メニュー画面ではキー入力に対応する必要があります。具体的には以下の操作ができるようにします。

  • 上カーソルキーまたは W キー押下でカーソルを上に移動させる
  • 下カーソルキーまたは S キー押下でカーソルを下に移動させる
  • スペースキーまたは 1 キーまたはスラッシュキー押下でゲーム画面に遷移させる

キー入力に対応するには DOM オブジェクトに対してキーイベントのリスナーを登録します。

src/main_menu/MainMenuScreen.ts
let pressedKeyCode = ''

function onKeyDown(event: KeyboardEvent) {
    pressedKeyCode = event.code
}

export class MainMenuScreen extends Screen {
    ...
    private cursor = 0

    constructor(app: Application) {
        ...
        this.setCursorLocation()
        window.document.body.addEventListener('keydown', onKeyDown)            
        ...
    }
    
    onClose() {
        super.onClose()
        window.document.body.removeEventListener('keydown', onKeyDown)
    }

    onNextFrame() {
        super.onNextFrame()
        const keyCode = pressedKeyCode
        pressedKeyCode = ''
        switch (keyCode) {
            case 'ArrowUp':
            case 'KeyW':
                this.cursor--
                if (this.cursor < 0) this.cursor = MainMenuScreen.menuItems.length - 1
                this.setCursorLocation()
                break
            case 'ArrowDown':
            case 'KeyS':
                this.cursor++
                if (this.cursor >= MainMenuScreen.menuItems.length) this.cursor = 0
                this.setCursorLocation()
                break
            case 'Slash':
            case 'Digit1':
            case 'Space':
                this.startGame()
                break
        }
    }

    private setCursorLocation() {
        // カーソル位置をセットする
    }

    private startGame() {
        // ゲーム画面に遷移させる
    }

コンストラクタで keydown イベントにリスナーを張り onClose() メソッドでそのリスナーを解除しています( onClose() はメニュー画面から別の画面に遷移したときに呼ばれます)。このイベントリスナーである onKeyDown() メソッドでは押されたキーのキーコードを pressedKeyCode という変数に保存しているだけで、この変数は onNextFrame() メソッド内で読み込まれて押されたキーに合わせた処理がされます。なんだか回りくどいことをしているように見えます(なぜ onKeyDown() 内で直接処理しないのか?的な)が、これはゲーム開発ではよく使われる手法で、アニメーション処理が onNextFrame() 内で毎フレーム行われるためそれと同期させるためです(同期させないと違和感のある動くになったりします)。

ゲーム画面への画面遷移

スペースキー等が押されたらゲーム画面に遷移させる必要がありますが、いきなりパッと遷移してしまうと唐突感が半端ないので以下の画面効果を再生することにします。

  • ジングル(効果音)を再生する。
  • その間選択されたメニューアイテムを点滅させる。
  • 時間を見計らって(ジングルの再生がほぼ終わるタイミングで)画面全体を暗くフェードアウトさせる。
src/main_menu/MainMenuScreen.ts
import { startGame } from "../main";

export class MainMenuScreen extends Screen {
    ...
    private goToGameState = -1
    ...
    onNextFrame() {
        super.onNextFrame()

        if (this.goToGameState >= 0) {
            this.goToGameState--
            this.itemText[this.cursor].tint = (this.goToGameState % 12) < 6 ? 0xffffff : 0x808080
            if (this.goToGameState < 30) this.baseStage.alpha = this.goToGameState / 30
            if (this.goToGameState <= 0) {
                const item = MainMenuScreen.menuItems[this.cursor]
                startGame(item.playerType1, item.playerType2)
            }
            return
        }
        ...
    }
    
    private startGame() {
        this.goToGameState = 204
        AppResource.sounds.startGame.play()
    }

スペースキー等が押されると MainMenuScreen#startGame() メソッドが呼ばれるので、このメソッドでジングルを再生しています。サウンドファイルは読み込み済みですので単純に play() メソッドを呼ぶだけで再生できます。そしてそれと同時に goToGameState というプロパティに 204 という数値を代入していますが、このプロパティはゲーム画面に遷移するまでのカウントダウンを表します。onNextFrame() ではこのプロパティを 1 ずつ減らしていて、0 になったらゲーム画面に遷移するようにしています。onNextFrame() は60 分の 1 秒ごとに呼ばれるので、204 ÷ 60 = 3.4 秒でタイムアウトすることになります。3.4 秒でタイムアウトするようにしているのは、ジングルの音が鳴り終わるのがだいたいそれくらいだからです。さらに onNextFrame() 内ではメニューアイテムのテキストを点滅させるため itemTexttint プロパティに色コードをセットしています。tint はスプライトに着色するためのプロパティなので、これだけで色を変えることができます。また最後の 30 フレーム( 0.5 秒間)は baseStage のアルファ値を減らしていくことで画面全体を暗くフェードアウトさせています。

ゲーム画面の実装

さて、やっとゲーム画面の実装に取り掛かれます(長かった…)。

ゲーム用スプライト基底クラス

まずはスプライトの基底クラス LightSprite を定義します。PixiJS にはスプライトを扱うための Sprite クラスが存在しますが、それをラップする形で今回作るゲーム用に扱いやすくしておきます。

src/game_screen/LightSprite.ts
export abstract class LightSprite {
    gameScreen: GameScreen
    width: number = Constants.CHARACTER_SIZE
    height: number = Constants.CHARACTER_SIZE

    constructor(gameScreen: GameScreen) {
        this.gameScreen = gameScreen
    }

    abstract sprite(): Sprite

    get x(): number {
        return this.sprite().x
    }

    get y(): number {
        return this.sprite().y
    }

    onNextFrame(): boolean { return false }
}

sprite() メソッドと onNextFrame() メソッドをオーバーライドして使う想定の抽象クラスです。sprite() は実際に描画するスプライトを返す必要があります。onNextFrame() は 60 分の 1 秒間隔で呼ばれる想定で、このスプライトの位置やその他の状態を変化させるコードを実装します。また onNextFrame() の戻り値は「このスプライトを破棄するべきか否か」を表し true の場合はゲーム画面から消えることとします。

壁, 爆弾, 爆発, パワーアップアイテムのスプライト

上で定義した LightSprite クラスを継承して壁, 爆弾, 爆発, パワーアップアイテムのスプライトを実装します。さすがにこれらのコードを全部掲載するわけにはいきませんので詳細は GitHub の方を見てください。ここではいくつかのポイントだけ説明します。

まず壁(Wall)から。壁には壊せる壁と壊せない壁があるのでそれを isBreakable プロパティで表しています。

src/game_screen/Wall.ts
export class Wall extends LightSprite {
    isBreakable: boolean

    constructor(gameScreen: GameScreen, x: number, y: number, isBreakable: boolean) {
        super(gameScreen)
        this.isBreakable = isBreakable
        ...
    }

また壁が壊れるときはいきなり消すのではなくアニメーションさせています。meltState プロパティがアニメーションの状態を表していて、 30 フレームに渡って「赤く着色して少しずつ透明になっていく」ようにしています。

src/game_screen/Wall.ts
export class Wall extends LightSprite {
    private static TIME_TO_MELT = 30

    private meltState = 0

    onNextFrame(): boolean {
        if (this.meltState > 0) {
            this.meltState++;
            if (this.meltState >= Wall.TIME_TO_MELT) {
                // 一定の確率でパワーアップアイテムが出る
                if (Math.random() < 0.1) {
                    this.gameScreen.powerUpItems.push(new PowerUpItem(this.gameScreen, this.x, this.y))
                }
                this._sprite.removeFromParent()
                return true
            }
            this._sprite.tint = 0xff0000
            this._sprite.alpha = 1 - this.meltState / Wall.TIME_TO_MELT
        }
        return false
    }

    isMelting(): boolean {
        return this.meltState > 0
    }

    startMelting() {
        if (this.meltState == 0) {
            this.meltState = 1
        }
    }
}

次に爆弾(Bomb)です。爆弾は 3 個の絵(テクスチャ)を繰り返し切り替えることでアニメーション描画させます。このようなアニメーションを行いには PixiJS の AnimatedSprite クラスを使います。このクラスは Sprite クラスを継承しており、複数のテクスチャを保持してそれらを切り替えて描画する機能を持っています。

src/game_screen/Bomb.ts
export class Bomb extends LightSprite {
    private moveTime = 0
    private _sprite: AnimatedSprite

    constructor(gameScreen: GameScreen, x: number, y: number, power: number) {
        super(gameScreen)
        this._sprite = new AnimatedSprite(gameScreen.spritesheet!.animations['bomb'])
        ...
    }

    onNextFrame(): boolean {
        this.moveTime += 1 / 60
        this._sprite.currentFrame = Math.floor(this.moveTime / 0.2) % 3
        ...
    }
}

残りの爆発(Explosion)とパワーアップアイテム(PowerUpItem)も同様に実装することができます。

ゲーム画面の実装(1): ゲーム開始時の状態の生成

ではここまでで実装できたスプライトを使ってゲーム画面を作ってみましょう。Screen クラスを継承した GameScreen クラスとして作っていきます。

src/game_screen/GameScreen.ts
export class GameScreen extends Screen {
    static readonly MAP_WIDTH = 25
    static readonly MAP_HEIGHT = 15

    // プレイヤーのスプライトを入れるコンテナ
    playerContainer = new Container()
    // プレイヤー以外のスプライトを入れるコンテナ
    itemContainer = new Container()

    spritesheet: Spritesheet|null = null

    // スプライトの配列
    walls = Array<Wall>()
    bombs = Array<Bomb>()
    explosions = Array<Explosion>()
    powerUpItems = Array<PowerUpItem>()

    constructor(app: Application, playerType1: PlayerType, playerType2: PlayerType) {
        super(app)

        // 背景色
        app.renderer.background.color = 0x009900

        this.spritesheet = AppResource.spritesheet
        this.baseStage.addChild(this.itemContainer)
        this.baseStage.addChild(this.playerContainer)

        this.startGame()
    }

    private startGame() {
        this.playerContainer.removeChildren()
        this.itemContainer.removeChildren()

        // 外壁の生成
        this.walls.splice(0)
        for (let x = 0; x < GameScreen.MAP_WIDTH; x++) {
            const xf = x * Constants.CHARACTER_SIZE
            this.walls.push(new Wall(this, xf, 0, false))
            this.walls.push(new Wall(this, xf, 14 * Constants.CHARACTER_SIZE, false))
        }
        for (let y = 1; y < GameScreen.MAP_HEIGHT - 1; y++) {
            const yf = y * Constants.CHARACTER_SIZE;
            this.walls.push(new Wall(this, 0, yf, false))
            this.walls.push(new Wall(this, 24 * Constants.CHARACTER_SIZE, yf, false))
        }

        // 壁の生成
        for (let y = 1; y < GameScreen.MAP_HEIGHT - 1; y++) {
            const yf = y * Constants.CHARACTER_SIZE
            for (let x = 1; x < GameScreen.MAP_WIDTH - 1; x++) {
                const xf = x * Constants.CHARACTER_SIZE
                if (x % 2 == 0 && y % 2 == 0) {
                    // 壊せない壁
                    this.walls.push(new Wall(this, xf, yf, false))
                } else {
                    // 壊せる壁
                    if (x < 3 && y < 3 || x > GameScreen.MAP_WIDTH - 4 && y > GameScreen.MAP_HEIGHT - 4) {
                        // プレイヤー出現位置の近くには壁は作らない
                    } else if (Math.random() < 0.5) {
                        this.walls.push(new Wall(this, xf, yf, true))
                    }
                }
            }
        }

        // その他のオブジェクトの初期化
        this.bombs.splice(0)
        this.explosions.splice(0)
        this.powerUpItems.splice(0)

        // 効果音の初期化
        AppResource.sounds.explosion.stop()
        AppResource.sounds.setBomb.stop()
        AppResource.sounds.walk.stop()
        AppResource.sounds.powerUp.stop()
        AppResource.sounds.crash.stop()

        // BGMの再生
        AppResource.sounds.bgm.play()
    }

ここまでのコードで以下のことを行っています。

  • ゲーム開始時の壁の生成
  • BGMの再生開始

まずプロパティとして playerContaineritemContainer というスプライトコンテナを生成しています。それぞれプレイヤーのスプライトとそれ以外のスプライトを入れるためのコンテナになります。なぜ二つに分けているかというと、プレイヤーのスプライトは必ず他のスプライトより「上」に表示したいからです。コンストラクタでこれらのコンテナを baseStage に追加 (addChild()) していますが playerContainer の方を後に追加しています。後に追加したスプライトの方が「上」に表示される性質があるため、こうすることで playerContainer に入れるプレイヤーのスプライトが他のスプライトより「上」に表示されるようになります。

次に壁、爆弾、爆発、パワーアップアイテムのスプライトを保持するための配列 walls, bombs, explosions, powerUpItems をプロパティとして用意しています。これらはすべて LightSprite のサブクラスなので一つの配列にまとめてしまうのもアリですが、後で個別の処理を書く必要があるためあえて配列を分けています。

startGame() はゲーム開始時の処理を行うためのメソッドです。各スプライトの配列の初期化と効果音の初期化、壁の初期状態の生成、BGM の再生開始を行っています。

ゲーム画面の実装(2): 各スプライトの状態変化の実装

次に各スプライトの状態変化を実装していきます。

src/game_screen/GameScreen.ts
export class GameScreen extends Screen {
    onNextFrame() {
        super.onNextFrame()

        // パワーアップアイテム、壁、爆発の状態変化
        this.nextFrame(this.powerUpItems)
        this.nextFrame(this.walls)
        this.nextFrame(this.explosions)

        // 爆弾の状態変化
        const newExplodeBomb = new Array<Bomb>()
        for (let i = this.bombs.length - 1; i >= 0; i--) {
            const bomb = this.bombs[i]
            if (bomb.onNextFrame()) {
                // 爆発した
                this.bombs.splice(i, 1)
                bomb.sprite().removeFromParent()
                // 爆発した爆弾をリストに入れておく
                newExplodeBomb.push(bomb)
            }
        }

        // 爆発の生成
        if (newExplodeBomb.length > 0) {
            AppResource.sounds.explosion.play()
            newExplodeBomb.forEach(bomb => {
                this.explosions.push(new Explosion(this, bomb.x, bomb.y, ExplosionPosition.CENTER))
                this.expandExplosion(bomb, -1, 0)
                this.expandExplosion(bomb, 1, 0)
                this.expandExplosion(bomb, 0, -1)
                this.expandExplosion(bomb, 0, 1)
            })
        }
    }

    private nextFrame(sprites: LightSprite[]) {
        for (let i = sprites.length - 1; i >= 0; i--) {
            const sprite = sprites[i]
            if (sprite.onNextFrame()) {
                sprites.splice(i, 1)
            }
        }
    }

    /**
     * 指定した爆弾位置から指定した方向に爆発を生成する
     */
    private expandExplosion(bomb: Bomb, xx: number, yy: number) {
        for (let n = 1; n <= bomb.power; n++) {
            const px = bomb.x + xx * n * Constants.CHARACTER_SIZE
            const py = bomb.y + yy * n * Constants.CHARACTER_SIZE

            // 壁があるか?
            for (let i = this.walls.length - 1; i >= 0; i--) {
                const wall = this.walls[i]
                if (wall.x == px && wall.y == py) {
                    // 壁の破壊
                    if (wall.isBreakable) {
                        wall.startMelting()
                    }
                    return
                }
            }
            // 爆弾があったら誘爆する
            for (let i = this.bombs.length - 1; i >= 0; i--) {
                const b = this.bombs[i]
                if (b.x == px && b.y == py) {
                    b.remainTime = 1
                    return
                }
            }
            // パワーアップアイテムがあったら破壊する
            for (let i = this.powerUpItems.length - 1; i >= 0; i--) {
                const item = this.powerUpItems[i]
                if (item.x == px && item.y == py) {
                    this.powerUpItems.splice(i, 1)
                    item.sprite().removeFromParent()
                    return
                }
            }
            // 新しい爆発を生成する
            let position: ExplosionPosition
            if (xx == 0) {
                if (n == bomb.power) {
                    if (yy > 0) position = ExplosionPosition.BOTTOM; else position = ExplosionPosition.TOP
                } else {
                    position = ExplosionPosition.VERTICAL
                }
            } else {
                if (n == bomb.power) {
                    if (xx > 0) position = ExplosionPosition.RIGHT; else position = ExplosionPosition.LEFT
                } else {
                    position = ExplosionPosition.HORIZONTAL
                }
            }
            this.explosions.push(new Explosion(this, px, py, position))
        }
    }

スプライトのフレームごとの状態変化は基本的に LightSprite#onNextFrame() に実装しているためそれを呼べば良いことになります。それをしているのが nextFrame() メソッドです。しかし爆弾が爆発したときは「爆弾のスプライトを消す」だけでなく「爆発のスプライトを生成する」処理も行う必要があるため GameScreen クラス側に書いています。というのも、仮に Bomb#onNextFrame() 内で爆発のスプライトを生成するようにしてしまうと、複数の爆弾がまったく同時に爆発した場合に、処理の順番によって処理結果が変わってしまうという問題が生じてしまうためです。これを避けるために GameScreen 側で「このフレームで爆発する爆弾」のリストを生成してから一気に爆発スプライトを生成しています。

プレイヤーキャラのスプライト

最後に一番重要なプレイヤーキャラのスプライトを実装します。プレイヤーキャラも他のスプライト同様 LightSprite クラスを継承する形で実装していきます。

src/game_screen/Player.ts
enum PlayerDirection {
    DOWN, LEFT, UP, RIGHT
}

export class Player extends LightSprite {
    private playerNumber: number
    private playerOperation: PlayerOperation
    private _sprite: AnimatedSprite
    private _x: number
    private _y: number
    private direction: PlayerDirection = PlayerDirection.DOWN
    power = 1

    constructor(gameScreen: GameScreen, playerNumber: number, playerOperation: PlayerOperation, x: number, y: number) {
        super(gameScreen)

        this.playerNumber = playerNumber
        this.playerOperation = playerOperation
        this._sprite = new AnimatedSprite(gameScreen.spritesheet!.animations['pl' + (playerNumber + 1)])
        this._sprite.x = x
        this._sprite.y = y
        this._x = x
        this._y = y
        gameScreen.playerContainer.addChild(this._sprite)
    }

    sprite(): Sprite {
        return this._sprite
    }

    get x(): number {
        return this._x
    }

    get y(): number {
        return this._y
    }

playerNumber プロパティはこのスプライトがプレイヤー1かプレイヤー2のどちらなのかを表します(0=プレイヤー1、1=プレイヤー2)。playerOperation はこのプレイヤーキャラへの入力操作(上下左右への移動や爆弾の設置といった操作情報)を受け取るためのインタフェースです。またプレイヤーキャラは上下左右の「向き」があるのでそれを表す列挙型 PlayerDirection を定義して direction プロパティに現在の向きを持たせています。
_x, _y はこのキャラの現在位置を表す座標ですが、他のスプライトと異なり _sprite オブジェクトが持つ座標情報とは別に独自のプロパティを持っているのは「表示位置」と「実際の位置」を個別に持ちたいためです。というのもプレイヤーキャラは何らかの表示エフェクトとして「実際の位置」とは異なる位置に表示することがあり得るためです。

次にプレイヤーキャラの移動処理を書いていきます。

src/game_screen/Player.ts
    moveForNextFrame() {
        // 移動前の位置を保存しておく
        const oldX = this._x
        const oldY = this._y

        this.playerInput = this.playerOperation.getPlayerInput()
        switch (this.playerInput.move) {
            case PlayerMove.LEFT:
                this.direction = PlayerDirection.LEFT
                this._x -= Player.WALK_SPEED
                break
            case PlayerMove.RIGHT:
                this.direction = PlayerDirection.RIGHT
                this._x += Player.WALK_SPEED
                break
            case PlayerMove.DOWN:
                this.direction = PlayerDirection.DOWN
                this._y += Player.WALK_SPEED
                break
            case PlayerMove.UP:
                this.direction = PlayerDirection.UP
                this._y -= Player.WALK_SPEED
                break
        }

        // 壁や爆弾との当たり判定
        const detectedObjects = Array<LightSprite>();
        [this.gameScreen.walls, this.gameScreen.bombs].forEach(objs => {
            objs.forEach(obj => {
                // ぶつかっているいて
                if (Math.abs(obj.x - this._x) < Constants.CHARACTER_SIZE && Math.abs(obj.y - this._y) < Constants.CHARACTER_SIZE) {
                    // かつ同じ升目になくて
                    if (obj.x / Constants.CHARACTER_SIZE != Math.floor((this._x + Constants.CHARACTER_SIZE / 2) / Constants.CHARACTER_SIZE) ||
                        obj.y / Constants.CHARACTER_SIZE != Math.floor((this._y + Constants.CHARACTER_SIZE / 2) / Constants.CHARACTER_SIZE)) {
                        // かつ移動先にあると「衝突した」
                        if (this.playerInput?.move == PlayerMove.LEFT && obj.x < this._x ||
                            this.playerInput?.move == PlayerMove.RIGHT && obj.x > this._x ||
                            this.playerInput?.move == PlayerMove.UP && obj.y < this._y ||
                            this.playerInput?.move == PlayerMove.DOWN && obj.y > this._y) {
                            detectedObjects.push(obj)
                        }
                    }
                }
            })
        })
        if (detectedObjects.length > 0) {
            this._x = oldX
            this._y = oldY
        }
        if (detectedObjects.length == 1) {
            const obj = detectedObjects[0]
            if (this.playerInput.move == PlayerMove.LEFT ||
                    this.playerInput.move == PlayerMove.RIGHT) {
                if (this._y < obj.y) this._y -= Player.WALK_SPEED
                if (this._y > obj.y) this._y += Player.WALK_SPEED
            }
            else if (this.playerInput.move == PlayerMove.UP ||
                    this.playerInput.move == PlayerMove.DOWN) {
                if (this._x < obj.x) this._x -= Player.WALK_SPEED
                if (this._x > obj.x) this._x += Player.WALK_SPEED
            }
        }

        // 実際に移動させる
        this._sprite.x = this._x
        this._sprite.y = this._y
    }

プレイヤーキャラは基本的にユーザ操作に従って上下左右に移動しますが壁や爆弾といった障害物がある場所には移動できないようにする必要があります。そこでまず移動前の座標を oldXoldY に退避しておいてからユーザ操作に従って座標を移動させ、移動先の座標が障害物と重なっていたら退避させておいた座標に戻すという処理を書いています。
ですがそれだけだとスムーズな移動操作ができなくなってしまいます。たとえば下の画像を見てください。

image.png

プレイヤーキャラの Y 座標が壁の Y 座標に一部重なっているため右に移動したくてもできない状況です。右に移動するにはもう少し上に移動して壁の Y 座標とまったく重ならない位置まで移動する必要がありますが、そこまで細かく操作するのは困難でしょう。そこで以下の処理を追加しています。

  • 移動先の座標に重なる障害物が1つだけの場合、以下の処理をする。
    • 左右に移動しようとしていた場合、障害物の Y 座標がプレイヤーキャラより上だったらプレイヤーキャラを下に移動させ、逆に障害物の Y 座標がプレイヤーキャラより下だったらプレイヤーキャラを上に移動させる。
    • 上下に移動しようとしていた場合、障害物の X 座標がプレイヤーキャラより左だったらプレイヤーキャラを右に移動させ、逆に障害物の X 座標がプレイヤーキャラより右だったらプレイヤーキャラを左に移動させる。

この処理により、たとえば上図に例示した状況の場合、右に移動しようとしたらプレイヤーキャラは勝手に上に移動するようになり、壁と重ならない高さまで移動したら右に移動するようになります。

次はパワーアップアイテムとの当たり判定、爆弾の設置、爆発との当たり判定の処理です。

src/game_screen/Player.ts
    onNextFrame(): boolean {
        const step = Math.floor(this.moveTime / 0.2) % 3
        this._sprite.currentFrame = this.direction * 3 + step

        // パワーアップアイテムとの当たり判定
        const powerUpItems: Array<PowerUpItem> = this.gameScreen.powerUpItems
        for (let i = powerUpItems.length - 1; i >= 0; i--) {
            const powerUpItem = powerUpItems[i]
            if (Math.abs(powerUpItem.x - this._x) < Constants.CHARACTER_SIZE &&
                    Math.abs(powerUpItem.y - this._y) < Constants.CHARACTER_SIZE) {
                powerUpItems.splice(i, 1)
                powerUpItem.sprite().removeFromParent()
                this.power++
                AppResource.sounds.powerUp.play()
                this.gameScreen.setPlayerText(this.playerNumber)
            }
        }

        // 爆弾の設置
        if (this.playerInput?.fire) {
            const bx = Math.floor((this._x + Constants.CHARACTER_SIZE / 2) / Constants.CHARACTER_SIZE) * Constants.CHARACTER_SIZE
            const by = Math.floor((this._y + Constants.CHARACTER_SIZE / 2) / Constants.CHARACTER_SIZE) * Constants.CHARACTER_SIZE
            let okFlag = true
            for (let i = this.gameScreen.bombs.length - 1; i >= 0; i--) {
                const bomb = this.gameScreen.bombs[i]
                if (bomb.x == bx && bomb.y == by) {
                    okFlag = false
                    break
                }
            }
            if (okFlag) {
                this.gameScreen.bombs.push(new Bomb(this.gameScreen, bx, by, this.power))
                AppResource.sounds.setBomb.play()
            }
        }

        // 爆発との当たり判定
        for (let i = this.gameScreen.explosions.length - 1; i >= 0; i--) {
            const explosion = this.gameScreen.explosions[i]
            if (Math.abs(explosion.x - this._x) < 28 && Math.abs(explosion.y - this._y) < 28) {
                this.deathState = 1
                AppResource.sounds.bgm.stop()
                AppResource.sounds.crash.play()
                if (this.walkSoundIsPlaying) {
                    this.walkSoundIsPlaying = false
                    AppResource.sounds.walk.stop(this.walkSoundId)
                }
                break
            }
        }

        return false
    }

特に難しいことはしていません。プレイヤーキャラの座標がパワーアップアイテムと重なっていたらそのパワーアップアイテムを消しプレイヤーのパワーを上げます。プレイヤーが爆弾設置ボタンを押していたら、プレイヤーキャラの座標にすでに爆弾がないかチェックしてもしなかったら爆弾を設置します。プレイヤーキャラの座標が爆発と重なっていたら死にます。

思考ルーチンの実装

先にも書きましたが今回作るゲームではコンピュータプレイヤーとも対戦できるようにします。そのためコンピュータプレイヤーの思考ルーチンを作る必要があります。

思考ルーチンは Rust で書いて Wasm にコンパイルします。もちろん TypeScript で思考ルーチンを書くこともできますが Wasm に比べると実行時の処理速度が圧倒的に遅いですからアルゴリズムの選択肢が大きく制限されてしまいます。できるだけ高速に処理したいアルゴリズムの実装には Wasm を使う方が良いでしょう。なお Wasm にコンパイルできる言語はいろいろありますが速度とメモリ安全性が高いとされる Rust を使うことにしました。

環境構築

まず Rust の開発に必要なツールをインストールします。以下のページに従ってインストールしてください。

次に wasm-pack というツールをインストールします。これは Rust のコードを Wasm にコンパイルしたり npm 用にパッケージングしたりするのに使います。インストールするには以下のコマンドを実行します。

cargo install wasm-pack

Rust プロジェクトの作成

それでは思考ルーチンを実装するための Rust プロジェクトを作成しましょう。これまで作成していた vite プロジェクトのルートディレクトリで以下のコマンドを実行します。

cargo new --lib bomber-ai

これで bomber-ai というサブディレクトリが作成され、その中が Rust プロジェクトになります。

プロジェクトが作成されたら Wasm にビルドするよう設定ファイル Cargo.toml を修正します。

bomber-ai/Cargo.toml
[package]
name = "bomber-ai"
version = "0.1.0"
edition = "2021"

[lib]                     # 追加
crate-type = ["cdylib"]   # 追加

[dependencies]
wasm-bindgen = "0.2"      # 追加

ここまでできたらとりあえずビルドを実行できることを確認してみましょう。

cd bomber-ai
wasm-pack build --target web

アルゴリズムの考案とコーディング

アルゴリズムを細かく説明していくと長くなりすぎるので(すでに十分長い気もするけど)割愛しますがざっくり以下のような内容にしました。

  • 自キャラの位置から移動到達可能なすべての場所に対して、その場所にたどり着くことによって得られるスコアを計算し、もっともスコアが高くなる場所へ移動しようとする。
  • 移動到達可能な場所およびその場所への経路の算出は一般的な経路探索アルゴリズムを用いる。
  • スコアの計算には主に以下の要素を考慮する。
    • その場所までの移動距離
    • パワーアップアイテムをゲットできるか
    • 効率よく壁を破壊できる位置か
    • 爆弾の爆発に巻き込まれる可能性
    • 相手キャラの邪魔ができるか

TypeScript との接続

さて、 Rust で思考ルーチンを書けましたが、それを TypeScript から呼べるようにする必要があります。それにはまず Rust 側のコードに一部手を加え、公開したい構造体 (struct) や実装 (impl) などに #[wasm_bindgen] 属性を設定します。

bomber-ai/src/ai/ai_player.rs
#[wasm_bindgen]
pub struct AIPlayer {
    previous_my_position: Position,
    previous_want_to_mode: bool,
    opponent_stress_weight_plus: i32,
    opponent_position_is_not_passable_timer: i32,
}

#[wasm_bindgen]
impl AIPlayer {
    pub fn new() -> AIPlayer {
        AIPlayer {
            previous_my_position: Position {x: 0, y: 0},
            previous_want_to_mode: false,
            opponent_stress_weight_plus: 0,
            opponent_position_is_not_passable_timer: 0,
        }
    }

    pub fn get_player_input(
        &mut self,
        walls: &[i32],
        power_up_items: &[i32],
        bombs: &[i32],
        explosions: &[i32],
        player_pos_x: i32,
        player_pos_y: i32,
        opponent_pos_x: i32,
        opponent_pos_y: i32,
        player_power: i32,
        opponent_is_dead: bool,
    ) -> i32 {
        ...
    }

これで AIPlayer 構造体とその関連関数 new() およびメソッド get_player_input() が公開されます。AIPlayer は思考ルーチンの状態を保持するための構造体です。get_player_input() はゲームの状況(自キャラ、相手キャラ、壁、爆弾、パワーアップアイテムの位置情報等)を引数として受け取り、その状況での自キャラの操作情報(上下左右への移動や爆弾の設置)を返却します。

この状態でビルドすると公開した構造体や関数が TypeScript からも呼べるようになります。さっそくビルドしてみましょう。

wasm-pack build --target web

ビルド結果は pkg ディレクトリに出力されます。いくつかファイルが作られていますが、その中にある bomber_ai.jsbomber_ai.d.ts の中身を見ると AIPlayer クラスと new(), get_player_input() メソッドが JavaScript (TypeScript) として作られており、そこから Wasm のコードが呼ばれるようになっていることが分かります。そのため本体プログラムからはこの JavaScript (TypeScript) のクラス/メソッドを使えば良いことになります。

では本体プログラムからこれらのクラスやメソッドを使えるようにしましょう。実は pkg ディレクトリ配下に出力されるファイルは Node.js のモジュールになっているため npm install コマンドで npm プロジェクトに簡単に組み込むことができます。

cd ..
npm install ./bomber-ai/pkg/ --no-save

--no-save オプションは追加モジュールを依存関係として package.json に書き込まないようにするための指定です。ローカルで作成した Node.js モジュールを使いたい(node_modules ディレクトリにインストールしたい)だけなのでこのような指定をします。

せっかくなので Rust 側のコードをビルドして本体プロジェクトにインストールする一連の操作を npm run コマンドで実行できるようにしておきましょう。

package.json
{
  ...
  "scripts": {
    "wasm": "wasm-pack build ./bomber-ai --target web && npm install ./bomber-ai/pkg/ --no-save",
    ...
  },
  ...
}

こうしておけば npm run wasm コマンドで Rust 側のビルドができるようになります。

これで本体プログラムから Rust 側のコードを呼べるようになります。Rust 側の思考ルーチンを呼んでコンピュータプレイヤーが人間っぽく動くようにしましょう。

src/game_screen/AIPlayerOperation.ts
import { AIPlayer } from 'bomber-ai';

export class AIPlayerOperation implements PlayerOperation {
    playerNumber: number
    gameScreen: GameScreen
    aiPlayer: AIPlayer = AIPlayer.new()

    constructor(gameScreen: GameScreen, playerNumber: number) {
        this.gameScreen = gameScreen
        this.playerNumber = playerNumber
    }

    free(): void {
        this.aiPlayer.free()
    }

    getPlayerInput(): PlayerInput {
        ...
        const operation = this.aiPlayer.get_player_input(
            walls,
            powerUpItems,
            bombs,
            explosions,
            player.x,
            player.y,
            opponent.x,
            opponent.y,
            player.power,
            opponent.isDead()
        )
        ...
    }
}

Rust 側で構造体を公開した場合、自動的に free() というメソッドが作成されるようです。これは構造体のインスタンスに割り当てたメモリを解放するためのメソッドのようです。使い終わったインスタンスは確実にこのメソッドで解放するようにしましょう。

ローカルで実行

いろいろ端折ってきましたがひとまず実装は完了です。さっそく遊んでみましょう。

プログラムをローカルで実行するには上の方でも書いた npm run dev コマンドで行うことができますし、以下のように「ビルドしてから実行」することもできます。

npm run build
npm run preview

いずれの場合もローカル環境で簡易的な Web サーバが起動し、ブラウザからアクセスできるようになります。

Vercel へのデプロイ

正常に動作することが確認できたら世界中の誰もが遊べるよう公開してみます。

Web サービスの公開は大変だと思っている方もいるかもしれませんが、今回作ったゲームはそれほど大変ではありません。というのもこのゲームはバックエンドのサーバを使うようなものではなく、ブラウザが読み込んだファイルだけで動作するものだからです。つまり静的なファイルをホストできる Web サーバさえあれば、比較的簡単に公開できます。要は HTML で作った自作ホームページを公開するのと同じです。

このような目的に使えるホスティングサービスはたくさんありますが、ここでは Vercel を使ってみます。Vercel は非商用なら無料で利用できます。

Vercel には GitHub との連携機能があります。これを使うと GitHub に push したソースを自動的に Vercel 側でデプロイしてくれるようになります。しかも Vercel 側でプロジェクトの種類を自動判別して最低限のビルドもしてくれちゃいます。ものすごく便利なのですが今回作ったプロジェクトでは Vite と Rust を組み合わせた特殊な構成のため自動判別に任せると正しくビルドできません。そこでプロジェクトのルートディレクトリに vercel.json という Vercel の設定ファイルを作り、そこにビルドコマンドを記述しておきます。

vercel.json
{
  "buildCommand": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source \"$HOME/.cargo/env\" && curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y && npm run wasm && npm run build"
}

ここで設定しているコマンドは Rust と wasm-pack をインストールしてから Rust のコードをビルドし、最後に Vite のビルドコマンドを実行します。

ここまでできたらプロジェクトを GitHub に push して Vercel と連携させます。Vercel のプロジェクトの作成方法や GitHub との連携方法はすでに多くの解説記事がありますのでそちらを参考にしてください。基本的には Vercel の画面の指示に従っていけばべきると思います。

Vercel のプロジェクトを作成し GitHub との連携を設定すると自動的にビルドおよびデプロイが実行されます。正常にデプロイされると以下のような画面になります。中央左のスクリーンショットか右上の Visit ボタンをクリックするとデプロイしたページに遷移できます。

image.png

以上で出来上がりです。以下のリンクからゲームを起動できます!

おわりに

というわけで、いまでもベーマガの投稿プログラムみたいなゲームは作れますよ!という話でした。

はい、分かってます。無理がありますね。ベーマガにこんな長ったらしいソースを掲載できるわけがありません。思考ルーチンとか細部にこだわりすぎたかもしれません…(泣)

ただ「いまでもこういうことは普通にできる」ということは書けたかなと思います。Web 全盛のこの時代、こういうことをするのは主流ではなさそうですが、「こんなことができるとは知らなかった」という人はぜひチャレンジしてみてほしいです!楽しいですよ!

117
115
2

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
117
115