no-image

JavaScriptで弾幕STGをフルスクラッチで作る その1 ゲームエンジン編

プログラミングといえばゲーム制作ですよね(最近はそうでもない?)。かつてはウェブブラウザ上で動くゲームを作ろうとすると苦労しましたが、最近ではHTML5やES6などの発達によって、作るのもだいぶ楽になりました。ゲーム制作用のJSライブラリなども散見されるようになってきて、JSによるゲーム制作のハードルはどんどん低くなってきています。

ですがやっぱりフルスクラッチ(※自分で全部作ること)で作ってみたいですよね?フルスクラッチ、楽しいですからね。楽しさ100倍です。バグの量も100倍ですが。

というわけでJavaScirptで弾幕シューティングゲームをフルスクラッチで作ってみましょう。対象となるブラウザはChrome51以降です。

ファイルの準備

まずはHTMLファイルとJSファイルを用意しましょう。全てJSから制御するので、HTMLファイルはほぼ空で結構です。

<!DOCTYPE html>
<html lang="ja-jp">
<head>
    <meta charset="UTF-8">
    <title>弾幕シューティング</title>
    <script defer src="engine.js"></script>
    <script defer src="danmaku.js"></script>
</head>
<body>
</body>
</html>

JSファイルは2つ用意します。ゲームエンジンを書くengine.jsと、ゲーム本体を書くdanmaku.jsです。

画像ファイルも用意しましょう。以下のリンクから保存してください。

スプライトファイル

スプライトファイル

透明背景に白色なので何も見えないかもしれませんが、ちゃんと描かれています。

ゲームエンジン

ゲームエンジンというのはゲーム制作用のフレームワーク(枠組み)のことです。ネット上にはフレームワークを作らずに直書きしているサイトが多いですが、枠組みを先に作ってしまったほうが楽に作れます。

ゲームエンジンと言っても大仰なものは作りません。単にオブジェクトを管理したり、自動的に当たり判定を取ってくれるなどするだけの、シンプルなものです。

それではゲームエンジンを作っていきましょう。engine.jsを編集します。制作はすべてstrictモードで行います。

'use strict';

Rectangleクラス

ゲームを制作する上で矩形(くけい)は重要な要素です。描画や当たり判定など様々なところで使用します。ですがJavaScriptにはRectangleクラスがありません。まずはRectangleクラスを自作しましょう。

'use strict';

class Rectangle {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    hitTest(other) {
        const horizontal = (other.x < this.x + this.width) &&
            (this.x < other.x + other.width);
        const vertical = (other.y < this.y + this.height) &&
            (this.y < other.y + other.height);
        return (horizontal && vertical);
    }
}

Rectangleクラスは座標と幅・高さを持つだけのシンプルなクラスです。ついでに当たり判定も実装しておきましょう。矩形同士の当たり判定についてはここでは詳しく解説しませんが、「矩形 当たり判定」で検索すると解説がたくさん出てきます。

Spriteクラス

ゲームは言ってしまえば単なる画像の集合です。ゲームで使われる2D画像のことを、大雑把にはスプライトと言います。厳密にはいろいろ定義があるらしいですけどね。

2Dゲームを作るのにスプライトは不可欠です。まずはこのSpriteクラスを作ってみましょう。

class Sprite {
    constructor(image, rectangle) {
        this.image = image;
        this.rectangle = rectangle;
    }
}

これをRectangleクラスのに書いてください。どんどん下に付け足していきます。

Spriteクラスも単純なクラスです。1枚の画像と、範囲を表すRectangleオブジェクトを受け取ります。範囲を受け取るのは、画像1枚につき1キャラクターとは限らないからです。自機、敵、ショットなどを1枚の画像に収めてしまうのが普通です。その中から一部だけを切り取って使用します。

AssetLoaderクラス

Spriteクラスだけでは「画像はどこから読み込むの?」という問題が出てきます。アセット(画像などのリソース)を読み込むためのクラスを作りましょう。

class AssetLoader {
    constructor() {
        this._promises = [];
        this._assets = new Map();
    }

    addImage(name, url) {
        const img = new Image();
        img.src = url;

        const promise = new Promise((resolve, reject) =>
            img.addEventListener('load', (e) => {
                this._assets.set(name, img);
                resolve(img);
            }));

        this._promises.push(promise);
    }
    
    loadAll() {
        return Promise.all(this._promises).then((p) => this._assets);
    }
    
    get(name) {
        return this._assets.get(name);
    }
}

const assets = new AssetLoader();

本来ならこういうものはstaticクラスにするべきなのですが、JavaScriptでは(現在のところ)staticにできるのはメソッドのみで、中途半端になるので、グローバルなオブジェクト(一番下の行)にしました。

Promiseを使っているのでだいぶわかりにくいですが、要は画像を読み込んで、名前をつけて格納しておくだけのクラスです。読み込んだアセットはgetメソッドで取り出すことができます。

EventDispatcherクラス

JavaScriptを触っていると「イベント」というものに触れることが多いと思います。ゲーム用語の「イベント」ではなく、プログラミング用語の「イベント」です。onclickとかaddEventListenerとかそういうやつです。「何かが起こった時に何かをする」というプログラムを書くとき、イベントは大変便利な仕組みになります。

ゲームでももちろんイベントは大切ですが、JavaScriptではHTML要素でしかイベントは使えません。つまり独自クラスでイベントを使うには、仕組みを自作する必要があります。

この仕組を実現するクラスはEventDispatcherと名付けられることが多いです。EventDispatcherの仕事は、コールバック関数を登録することと、イベントが起こったときにコールバック関数を実行することです。

class EventDispatcher {
    constructor() {
        this._eventListeners = {};
    }

    addEventListener(type, callback) {
        if(this._eventListeners[type] == undefined) {
            this._eventListeners[type] = [];
        }

        this._eventListeners[type].push(callback);
    }

    dispatchEvent(type, event) {
        const listeners = this._eventListeners[type];
        if(listeners != undefined) listeners.forEach((callback) => callback(event));
    }
}

だいたいこんな感じでしょう。addEventListenerでコールバック関数を登録し、dispatchEventでイベントを発火(コールバック関数を実行する)させます。シンプルな仕組みですが、強力です。また、余裕があればremoveEventListenerも実装するといいでしょう。

GameEventクラス

EventDIspatcherから発火させるイベント用のクラスも作っておきましょう。イベントに渡すのは単なるオブジェクトでもいいのですが、やはりクラスとして用意しておいたほうが何かと安心です。

class GameEvent {
    constructor(target) {
        this.target = target;
    }
}

保持しておく内容としてはイベントのターゲットだけで十分だと思います。必要なことが増えたら後で付け足しましょう。

Actorクラス

ゲームはキャラクターの集合とも言えます。キャラクターというのは動きを持ちます。移動したり、弾を撃ったり、死んだり。また、キャラクターはグラフィックを持ちます。画面上にグラフィックを描画できる仕組みが必要です。

こういったものを実現するクラスを作りましょう。このクラスにはActor(役者)と名づけます。Actorは様々なイベントを発生させると考えられるので、先ほど作ったEventDispatcherクラスを継承します。

class Actor extends EventDispatcher {
    constructor(x, y, hitArea, tags = []) {
        super();
        this.hitArea = hitArea;
        this._hitAreaOffsetX = hitArea.x;
        this._hitAreaOffsetY = hitArea.y;
        this.tags = tags;

        this.x = x;
        this.y = y;
    }
    
    update(gameInfo, input) {}

    render(target) {}

    hasTag(tagName) {
        return this.tags.includes(tagName);
    }

    spawnActor(actor) {
        this.dispatchEvent('spawnactor', new GameEvent(actor));
    }

    destroy() {
        this.dispatchEvent('destroy', new GameEvent(this));
    }
    
    get x() {
        return this._x;
    }
    
    set x(value) {
        this._x = value;
        this.hitArea.x = value + this._hitAreaOffsetX;
    }

    get y() {
        return this._y;
    }

    set y(value) {
        this._y = value;
        this.hitArea.y = value + this._hitAreaOffsetY;
    }
}

Actorクラスは座標と当たり判定とタグを持ちます。座標の代入処理は少し特殊になっていて、これは当たり判定がActorと一緒に移動するからです。

タグは当たり判定などのときに使います。ゲームを動かす上で必須ではないので初期値は空になっています。

Actorクラスは毎フレームupdateメソッドが呼び出されます。ゲーム情報と入力を受け取って、いろいろな動きをします。メソッドが空っぽなのは、各Actorごとに挙動が異なるからです。実際に使用するときは、Actorを継承したクラスを作り、updateメソッドをオーバーライドします。

renderメソッドは描画処理です。このメソッドも空です。Actorごとに描画処理が異なるからです。

spawnActorはその名の通り他のActorを発生させるときに使用します。例えば自機がショットを撃つときに使えます。ですがActor自身が他のActorの管理をすることはできないので、単にspawnactorイベントを発生させるだけにして、実際の処理は他のクラスに任せることにします。

destroyも同じです。自身を破棄するメソッドですが、自身を消すことはできないので、イベントだけ発生させてより上位のクラスに実際の処理を任せます。

SpriteActorクラス

Actorごとに処理が異なる……と言っても実際はだいたいスプライトです。なのでスプライトなActorであるSpriteActorを作ってしまいましょう。

class SpriteActor extends Actor {
    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;
        this.width = sprite.rectangle.width;
        this.height = sprite.rectangle.height;
    }
    
    render(target) {
        const context = target.getContext('2d');
        const rect = this.sprite.rectangle;
        context.drawImage(this.sprite.image,
            rect.x, rect.y,
            rect.width, rect.height,
            this.x, this.y,
            rect.width, rect.height);
    }
    
    isOutOfBounds(boundRect) {
        const actorLeft = this.x;
        const actorRight = this.x + this.width;
        const actorTop = this.y;
        const actorBottom = this.y + this.height;

        const horizontal = (actorRight < boundRect.x || actorLeft > boundRect.width);
        const vertical = (actorBottom < boundRect.y || actorTop > boundRect.height);

        return (horizontal || vertical);
    }
}

Actorと異なるのは、スプライトを持つので幅・高さを持つことと、具体的なrenderメソッドを記述できることです。

オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画します。引数の多いメソッドですので、よくわからない場合は検索して調べてください。

isOutOfBoundsメソッドはRectangleオブジェクトの外であるかどうかを判定することができます。SpriteActorクラスでは座標と幅と高さがわかっているので、「Rectanlgeの外か否か」の処理が簡単にできます。

Inputクラス

Actorができたので次は入力周りを作ってみましょう。キー入力を保持するだけのクラスです。

class Input {
    constructor(keyMap, prevKeyMap) {
        this.keyMap = keyMap;
        this.prevKeyMap = prevKeyMap;
    }

    _getKeyFromMap(keyName, map) {
        if(map.has(keyName)) {
            return map.get(keyName);
        } else {
            return false;
        }
    }

    _getPrevKey(keyName) {
        return this._getKeyFromMap(keyName, this.prevKeyMap);
    }

    getKey(keyName) {
        return this._getKeyFromMap(keyName, this.keyMap);
    }

    getKeyDown(keyName) {
        const prevDown = this._getPrevKey(keyName);
        const currentDown = this.getKey(keyName);
        return (!prevDown && currentDown);
    }

    getKeyUp(keyName) {
        const prevDown = this._getPrevKey(keyName);
        const currentDown = this.getKey(keyName);
        return (prevDown && !currentDown);
    }
}

このクラスのオブジェクトがActorのupdateメソッドに渡されます。

やっていることは前回のキー入力と現在のキー入力を保持しているだけです。押下しているかどうかを判定をするgetKeyメソッド、キーを押し込んだかどうかを判定するgetKeyDownメソッド、キーを話したかどうかを判定するgetKeyUpメソッドがあります。

InputReceiverクラス

キー入力を保持するInputクラスはできたので、次は実際にキー入力を検知してInputクラスを生成するクラスを作りましょう。

class InputReceiver {
    constructor() {
        this._keyMap = new Map();
        this._prevKeyMap = new Map();

        addEventListener('keydown', (ke) => this._keyMap.set(ke.key, true));
        addEventListener('keyup', (ke) => this._keyMap.set(ke.key, false));
    }

    getInput() {
        const keyMap = new Map(this._keyMap);
        const prevKeyMap = new Map(this._prevKeyMap);
        this._prevKeyMap = new Map(this._keyMap);
        return new Input(keyMap, prevKeyMap);
    }
}

ブラウザ上でキーを押すと、keydownイベント、keyupイベントが発生するのでそれを受け取って記録します。

getInputメソッドが呼び出されると、前回の入力と現在の入力を使って、Inputオブジェクトを作ります。

Sceneクラス

ゲームはActorの集合ですが、そのActorをまとめあげるものが必要になってきます。ここでは「シーン」と名づけましょう。ゲームは複数のシーンで構成されます。例えばタイトル画面、ゲーム画面、ゲームオーバー画面、などです。

Sceneクラスの仕事は以下です。

  • Actorたちを保持する(追加・削除)
  • Actorたちを更新する
  • 当たり判定を処理する
  • Actorたちのイベントを処理する

だいぶ長くなりますが全て実装してみましょう。

class Scene extends EventDispatcher {
    constructor(name, backgroundColor, renderingTarget) {
        super();

        this.name = name;
        this.backgroundColor = backgroundColor;
        this.actors = [];
        this.renderingTarget = renderingTarget;

        this._destroyedActors = [];
    }

    add(actor) {
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));
        actor.addEventListener('destroy', (e) => this._addDestroyedActor(e.target));
    }

    remove(actor) {
        const index = this.actors.indexOf(actor);
        this.actors.splice(index, 1);
    }
    
    changeScene(newScene) {
        const event = new GameEvent(newScene);
        this.dispatchEvent('changescene', event);
    }

    update(gameInfo, input) {
        this._updateAll(gameInfo, input);
        this._hitTest();
        this._disposeDestroyedActors();
        this._clearScreen(gameInfo);
        this._renderAll();
    }

    _updateAll(gameInfo, input) {
        this.actors.forEach((actor) => actor.update(gameInfo, input));
    }

    _hitTest() {
        const length = this.actors.length;
        for(let i=0; i < length - 1; i++) {
            for(let j=i+1; j < length; j++) {
                const obj1 = this.actors[i];
                const obj2 = this.actors[j];
                const hit = obj1.hitArea.hitTest(obj2.hitArea);
                if(hit) {
                    obj1.dispatchEvent('hit', new GameEvent(obj2));
                    obj2.dispatchEvent('hit', new GameEvent(obj1));
                }
            }
        }
    }

    _clearScreen(gameInfo) {
        const context = this.renderingTarget.getContext('2d');
        const width = gameInfo.screenRectangle.width;
        const height = gameInfo.screenRectangle.height;
        context.fillStyle = this.backgroundColor;
        context.fillRect(0, 0, width, height);
    }

    _renderAll() {
        this.actors.forEach((obj) => obj.render(this.renderingTarget));
    }

    _addDestroyedActor(actor) {
        this._destroyedActors.push(actor);
    }

    _disposeDestroyedActors() {
        this._destroyedActors.forEach((actor) => this.remove(actor));
        this._destroyedActors = [];
    }
}

addメソッドでシーンにActorを追加できます。追加されたActorのspawnactorイベントとdestroyイベントを監視しておき、spawnactorイベントが発生した場合はシーンにActorを追加、destroyイベントが発生した場合はそのActorを削除するようにします。

changeSceneメソッドはSceneを変更します。ですがScene自身がSceneを変更することはできないので、Actorのときと同じようにイベントだけ発生させて、実際の処理は他のクラスに任せます。

updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。

当たり判定処理ですが、まともに実装すると複雑になるので、ここでは単純な総当り方式を使用しています。実用には向かないとは思いますが、Actorが100個ぐらいならなんとかなります。本格的なゲームを作るときは四分木などを使って最適化してください。

GameInformationクラス

ActorやSceneのupdateに渡すゲーム情報クラスを作りましょう。

class GameInformation {
    constructor(title, screenRectangle, maxFps, currentFps) {
        this.title = title;
        this.screenRectangle = screenRectangle;
        this.maxFps = maxFps;
        this.currentFps = currentFps;
    }
}

情報は適宜増やしたり減らしてりしてください。

Gameクラス

さて、いろいろ作ってきましたが、いよいよ総まとめです!Gameクラスを作りましょう。Gameクラスはメインループを持ちます。

class Game {
    constructor(title, width, height, maxFps) {
        this.title = title;
        this.width = width;
        this.height = height;
        this.maxFps = maxFps;
        this.currentFps = 0;

        this.screenCanvas = document.createElement('canvas');
        this.screenCanvas.height = height;
        this.screenCanvas.width = width;

        this._inputReceiver = new InputReceiver();
        this._prevTimestamp = 0;

        console.log(`${title}が初期化されました。`);
    }

    changeScene(newScene) {
        this.currentScene = newScene;
        this.currentScene.addEventListener('changescene', (e) => this.changeScene(e.target));
        console.log(`シーンが${newScene.name}に切り替わりました。`);
    }

    start() {
        requestAnimationFrame(this._loop.bind(this));
    }

    _loop(timestamp) {
        const elapsedSec = (timestamp - this._prevTimestamp) / 1000;
        const accuracy = 0.9; // あまり厳密にするとフレームが飛ばされることがあるので
        const frameTime = 1 / this.maxFps * accuracy; // 精度を落とす
        if(elapsedSec <= frameTime) {
            requestAnimationFrame(this._loop.bind(this));
            return;
        }

        this._prevTimestamp = timestamp;
        this.currentFps = 1 / elapsedSec;

        const screenRectangle = new Rectangle(0, 0, this.width, this.height);
        const info = new GameInformation(this.title, screenRectangle,
                                         this.maxFps, this.currentFps);
        const input = this._inputReceiver.getInput();
        this.currentScene.update(info, input);

        requestAnimationFrame(this._loop.bind(this));
    }
}

Gameクラスのループが始まるとSceneのupdateメソッドが呼びだされます。そしてSceneは各Actorのupdateを呼び出し……という仕組みでゲーム全体が動きます。

メインループにはrequestAnimationFrameを利用しています。これは次のフレームの描画タイミングのとき1回だけコールバック関数を呼び出してくれるものです。1回だけなので毎回呼び出さないといけません。コールバック関数をbindしているのは、実行箇所によってthisが変わってしまうためです。これはJavaScript特有の問題です。「bind this」などで検索して調べてください。

メインループ内ではfps制限をかけています。通常requestAnimationFrameは60fpsで実行されますが、例えば120Hzのモニタを使っていると120fpsで実行される可能性があります。つまり倍速で動きます。なので制限があったほうがいいでしょう。

動作確認

これでゲームエンジンが完成しました!簡単な動作確認をしてみましょう。動作確認用のプログラムはdanmaku.jsに書き込んでいきます。

動作確認用に作るものは、タイトル画面とメイン画面が存在して、メイン画面では自機が動かせるというものにしてみましょう。

Actorを作る

まずはActorを作りましょう。タイトル表示用のActorと、自機(Fighter)のActorです。

'use strict';

class Title extends Actor {
    constructor(x, y) {
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = '25px sans-serif';
        context.fillStyle = 'white';
        context.fillText('弾幕STG', this.x, this.y);
    }
}

class Fighter extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
        const hitArea = new Rectangle(8, 8, 2, 2);
        super(x, y, sprite, hitArea);
        
        this.speed = 2;
    }
    
    update(gameInfo, input) {
        if(input.getKey('ArrowUp')) { this.y -= this.speed; }
        if(input.getKey('ArrowDown')) { this.y += this.speed; }
        if(input.getKey('ArrowRight')) { this.x += this.speed; }
        if(input.getKey('ArrowLeft')) { this.x -= this.speed; }
    }
}

Titleはタイトル表示用のActorです。そのまま「弾幕STG」と表示するだけです。

Fighterは自機になるActorです。キーボード入力を受け取り動くことができます。当たり判定は小さめにしています。

Sceneを作る

タイトル画面とメイン画面、2つのシーンを作りましょう。

class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const fighter = new Fighter(150, 300);
        this.add(fighter);
    }
}

class DanmakuStgTitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', 'black', renderingTarget);
        const title = new Title(100, 200);
        this.add(title);
    }

    update(gameInfo, input) {
        super.update(gameInfo, input);
        if(input.getKeyDown(' ')) {
            const mainScene = new DanmakuStgMainScene(this.renderingTarget);
            this.changeScene(mainScene);
        }
    }
}

DanmakuStgMainSceneはメインとなるシーンです。Fighterをひとつ追加するだけです。

DanmakuStgTitleSceneはタイトル画面のシーンです。Titleを追加し、update時にスペースキーが押されたらメイン画面に移行するようにします。

Gameを作る

Gameをひとつ作ります。

class DanamkuStgGame extends Game {
    constructor() {
        super('弾幕STG', 300, 400, 60);
        const titleScene = new DanmakuStgTitleScene(this.screenCanvas);
        this.changeScene(titleScene);
    }
}

タイトル画面を作りシーンを変更しているだけです。

起動する

Gameができたので起動させましょう。

assets.addImage('sprite', 'sprite.png');
assets.loadAll().then((a) => {
    const game = new DanamkuStgGame();
    document.body.appendChild(game.screenCanvas);
    game.start();
});

ゲームエンジン側で定義したグローバルオブジェクトassetsにスプライト画像を登録して、読み込み終わったらDanmakuStgGameを開始します。

描画先はscreenCanvasプロパティに格納されているので、bodyに追加しましょう。

ブラウザ上で確認する

それではブラウザを開いて動作を確認してみましょう。

danamku-stg

デモを開く

スペースキーで開始、矢印キーで移動ができるはずです。

その2へ続く

JavaScriptで弾幕STGをフルスクラッチで作る その2 ゲーム作り編へ続きます。

ここまでのプログラム

<!DOCTYPE html>
<html lang="ja-jp">
<head>
    <meta charset="UTF-8">
    <title>弾幕シューティング</title>
    <script defer src="engine.js"></script>
    <script defer src="danmaku.js"></script>
</head>
<body>
</body>
</html>
'use strict';

class Rectangle {
    constructor(x, y, width, height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    hitTest(other) {
        const horizontal = (other.x < this.x + this.width) &&
            (this.x < other.x + other.width);
        const vertical = (other.y < this.y + this.height) &&
            (this.y < other.y + other.height);
        return (horizontal && vertical);
    }
}

class Sprite {
    constructor(image, rectangle) {
        this.image = image;
        this.rectangle = rectangle;
    }
}

class AssetLoader {
    constructor() {
        this._promises = [];
        this._assets = new Map();
    }

    addImage(name, url) {
        const img = new Image();
        img.src = url;

        const promise = new Promise((resolve, reject) =>
            img.addEventListener('load', (e) => {
                this._assets.set(name, img);
                resolve(img);
            }));

        this._promises.push(promise);
    }
    
    loadAll() {
        return Promise.all(this._promises).then((p) => this._assets);
    }
    
    get(name) {
        return this._assets.get(name);
    }
}

const assets = new AssetLoader();

class EventDispatcher {
    constructor() {
        this._eventListeners = {};
    }

    addEventListener(type, callback) {
        if(this._eventListeners[type] == undefined) {
            this._eventListeners[type] = [];
        }

        this._eventListeners[type].push(callback);
    }

    dispatchEvent(type, event) {
        const listeners = this._eventListeners[type];
        if(listeners != undefined) listeners.forEach((callback) => callback(event));
    }
}

class GameEvent {
    constructor(target) {
        this.target = target;
    }
}

class Actor extends EventDispatcher {
    constructor(x, y, hitArea, tags = []) {
        super();
        this.hitArea = hitArea;
        this._hitAreaOffsetX = hitArea.x;
        this._hitAreaOffsetY = hitArea.y;
        this.tags = tags;

        this.x = x;
        this.y = y;
    }
    
    update(gameInfo, input) {}

    render(target) {}

    hasTag(tagName) {
        return this.tags.includes(tagName);
    }

    spawnActor(actor) {
        this.dispatchEvent('spawnactor', new GameEvent(actor));
    }

    destroy() {
        this.dispatchEvent('destroy', new GameEvent(this));
    }
    
    get x() {
        return this._x;
    }
    
    set x(value) {
        this._x = value;
        this.hitArea.x = value + this._hitAreaOffsetX;
    }

    get y() {
        return this._y;
    }

    set y(value) {
        this._y = value;
        this.hitArea.y = value + this._hitAreaOffsetY;
    }
}

class SpriteActor extends Actor {
    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;
        this.width = sprite.rectangle.width;
        this.height = sprite.rectangle.height;
    }
    
    render(target) {
        const context = target.getContext('2d');
        const rect = this.sprite.rectangle;
        context.drawImage(this.sprite.image,
            rect.x, rect.y,
            rect.width, rect.height,
            this.x, this.y,
            rect.width, rect.height);
    }
    
    isOutOfBounds(boundRect) {
        const actorLeft = this.x;
        const actorRight = this.x + this.width;
        const actorTop = this.y;
        const actorBottom = this.y + this.height;

        const horizontal = (actorRight < boundRect.x || actorLeft > boundRect.width);
        const vertical = (actorBottom < boundRect.y || actorTop > boundRect.height);

        return (horizontal || vertical);
    }
}

class Input {
    constructor(keyMap, prevKeyMap) {
        this.keyMap = keyMap;
        this.prevKeyMap = prevKeyMap;
    }

    _getKeyFromMap(keyName, map) {
        if(map.has(keyName)) {
            return map.get(keyName);
        } else {
            return false;
        }
    }

    _getPrevKey(keyName) {
        return this._getKeyFromMap(keyName, this.prevKeyMap);
    }

    getKey(keyName) {
        return this._getKeyFromMap(keyName, this.keyMap);
    }

    getKeyDown(keyName) {
        const prevDown = this._getPrevKey(keyName);
        const currentDown = this.getKey(keyName);
        return (!prevDown && currentDown);
    }

    getKeyUp(keyName) {
        const prevDown = this._getPrevKey(keyName);
        const currentDown = this.getKey(keyName);
        return (prevDown && !currentDown);
    }
}

class InputReceiver {
    constructor() {
        this._keyMap = new Map();
        this._prevKeyMap = new Map();

        addEventListener('keydown', (ke) => this._keyMap.set(ke.key, true));
        addEventListener('keyup', (ke) => this._keyMap.set(ke.key, false));
    }

    getInput() {
        const keyMap = new Map(this._keyMap);
        const prevKeyMap = new Map(this._prevKeyMap);
        this._prevKeyMap = new Map(this._keyMap);
        return new Input(keyMap, prevKeyMap);
    }
}

class Scene extends EventDispatcher {
    constructor(name, backgroundColor, renderingTarget) {
        super();

        this.name = name;
        this.backgroundColor = backgroundColor;
        this.actors = [];
        this.renderingTarget = renderingTarget;

        this._destroyedActors = [];
    }

    add(actor) {
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));
        actor.addEventListener('destroy', (e) => this._addDestroyedActor(e.target));
    }

    remove(actor) {
        const index = this.actors.indexOf(actor);
        this.actors.splice(index, 1);
    }
    
    changeScene(newScene) {
        const event = new GameEvent(newScene);
        this.dispatchEvent('changescene', event);
    }

    update(gameInfo, input) {
        this._updateAll(gameInfo, input);
        this._hitTest();
        this._disposeDestroyedActors();
        this._clearScreen(gameInfo);
        this._renderAll();
    }

    _updateAll(gameInfo, input) {
        this.actors.forEach((actor) => actor.update(gameInfo, input));
    }

    _hitTest() {
        const length = this.actors.length;
        for(let i=0; i < length - 1; i++) {
            for(let j=i+1; j < length; j++) {
                const obj1 = this.actors[i];
                const obj2 = this.actors[j];
                const hit = obj1.hitArea.hitTest(obj2.hitArea);
                if(hit) {
                    obj1.dispatchEvent('hit', new GameEvent(obj2));
                    obj2.dispatchEvent('hit', new GameEvent(obj1));
                }
            }
        }
    }

    _clearScreen(gameInfo) {
        const context = this.renderingTarget.getContext('2d');
        const width = gameInfo.screenRectangle.width;
        const height = gameInfo.screenRectangle.height;
        context.fillStyle = this.backgroundColor;
        context.fillRect(0, 0, width, height);
    }

    _renderAll() {
        this.actors.forEach((obj) => obj.render(this.renderingTarget));
    }

    _addDestroyedActor(actor) {
        this._destroyedActors.push(actor);
    }

    _disposeDestroyedActors() {
        this._destroyedActors.forEach((actor) => this.remove(actor));
        this._destroyedActors = [];
    }
}

class GameInformation {
    constructor(title, screenRectangle, maxFps, currentFps) {
        this.title = title;
        this.screenRectangle = screenRectangle;
        this.maxFps = maxFps;
        this.currentFps = currentFps;
    }
}

class Game {
    constructor(title, width, height, maxFps) {
        this.title = title;
        this.width = width;
        this.height = height;
        this.maxFps = maxFps;
        this.currentFps = 0;

        this.screenCanvas = document.createElement('canvas');
        this.screenCanvas.height = height;
        this.screenCanvas.width = width;

        this._inputReceiver = new InputReceiver();
        this._prevTimestamp = 0;

        console.log(`${title}が初期化されました。`);
    }

    changeScene(newScene) {
        this.currentScene = newScene;
        this.currentScene.addEventListener('changescene', (e) => this.changeScene(e.target));
        console.log(`シーンが${newScene.name}に切り替わりました。`);
    }

    start() {
        requestAnimationFrame(this._loop.bind(this));
    }

    _loop(timestamp) {
        const elapsedSec = (timestamp - this._prevTimestamp) / 1000;
        const accuracy = 0.9; // あまり厳密にするとフレームが飛ばされることがあるので
        const frameTime = 1 / this.maxFps * accuracy; // 精度を落とす
        if(elapsedSec <= frameTime) {
            requestAnimationFrame(this._loop.bind(this));
            return;
        }

        this._prevTimestamp = timestamp;
        this.currentFps = 1 / elapsedSec;

        const screenRectangle = new Rectangle(0, 0, this.width, this.height);
        const info = new GameInformation(this.title, screenRectangle,
                                         this.maxFps, this.currentFps);
        const input = this._inputReceiver.getInput();
        this.currentScene.update(info, input);

        requestAnimationFrame(this._loop.bind(this));
    }
}
'use strict';

class Title extends Actor {
    constructor(x, y) {
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = '25px sans-serif';
        context.fillStyle = 'white';
        context.fillText('弾幕STG', this.x, this.y);
    }
}

class Fighter extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
        const hitArea = new Rectangle(8, 8, 2, 2);
        super(x, y, sprite, hitArea);
        
        this.speed = 2;
    }
    
    update(gameInfo, input) {
        if(input.getKey('ArrowUp')) { this.y -= this.speed; }
        if(input.getKey('ArrowDown')) { this.y += this.speed; }
        if(input.getKey('ArrowRight')) { this.x += this.speed; }
        if(input.getKey('ArrowLeft')) { this.x -= this.speed; }
    }
}

class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const fighter = new Fighter(150, 300);
        this.add(fighter);
    }
}

class DanmakuStgTitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', 'black', renderingTarget);
        const title = new Title(100, 200);
        this.add(title);
    }

    update(gameInfo, input) {
        super.update(gameInfo, input);
        if(input.getKeyDown(' ')) {
            const mainScene = new DanmakuStgMainScene(this.renderingTarget);
            this.changeScene(mainScene);
        }
    }
}

class DanamkuStgGame extends Game {
    constructor() {
        super('弾幕STG', 300, 400, 60);
        const titleScene = new DanmakuStgTitleScene(this.screenCanvas);
        this.changeScene(titleScene);
    }
}

assets.addImage('sprite', 'sprite.png');
assets.loadAll().then((a) => {
    const game = new DanamkuStgGame();
    document.body.appendChild(game.screenCanvas);
    game.start();
});