JavaScriptでゲーム作り「7:フィールドの背景スクロール」

フィールドの背景スクロールに挑戦

(2019.2.16執筆)

フィールドをプレイヤーが移動する度に、一緒にスクリーン座標も移動させて、背景スクロールができるように。 広大なマップを、プレイヤーを中心とした画面サイズで切り取って、その部分だけスクリーンに表示させるようにする。

......RPGでよくある、背景スクロールに挑戦です。


ベースにした古都先生のゲームエンジンは弾幕STG。シューティングゲーム用に組まれてる動作の仕組みを、RPGに沿うように少しずつ改変する必要がある。その第一歩目が、プレイヤーの移動に合わせた背景スクロールです。 現状の、弾幕STGの仕様を見ながら、改変するポイントをピックアップしてみようと思います。

どこを改変するべきか予測をたてる

  1. Actorクラスの描画座標を、Scroller要素で調整できるようにする
  2. Playerクラスの座標と、Scroller要素を関連付ける
  3. Sceneクラスの描画関連で、各アクターのScroller要素をアップデート


何となく、見通しとしてはこの3つな感じです。
順番に行きます。

各Actorの描画に、Scroller要素を加える

class SpriteActor extends Actor {//画像を当てはめた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) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        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 + scroller.x, this.y + scroller.y , // アクターの描画位置を、自身の座標からスクローラーの座標を差し引いて調整
            rect.width, rect.height);
    }
}

// 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
const scroller = { x: 0, y: 0 }; // scroller.xやscroller.yの値は、各シーンから調整する

まずはグローバル領域に、スクロール用のオブジェクトを記述しておきます。
// 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
const scroller = { x: 0, y: 0 }; // scroller.xやscroller.yの値は、プレイヤー座標から取得。各シーンからも調整する

SpriteActorクラスのrender()修正

スクロール要素の値は、各アクターを描画する際に使用します。例えばSpriteActorクラスでは
    render(target) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        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 + scroller.x, this.y + scroller.y , // アクターの描画位置を、自身の座標からスクローラーの座標を差し引いて調整
            rect.width, rect.height);
    }

SpriteActorの場合、描画の開始位置をscroller.x、scroller.yだけずらしてスクリーンに描画する設定です。
            this.x + scroller.x, this.y + scroller.y , // アクターの描画位置を、スクローラーの座標分ずらして調整

PlayerActionクラスのrender()修正

PlayerActionクラスの描画にも、scroller要素を加えます。
render(target) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.beginPath();
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.arc(rect.x + scroller.x, rect.y + scroller.y, rect.radius, 0, Math.PI*2, false);
        context.fill();
    }

PlayerActionも、円を描画する位置をscroller.x、scroller.yだけずらしてスクリーンに描画します。
        context.arc(rect.x + scroller.x, rect.y + scroller.y, rect.radius, 0, Math.PI*2, false);

LineActorのrender()修正

    render(target) {
        const context = target.getContext('2d');
        context.strokeStyle = this._color;
        context.beginPath(); //描画位置の初期化
        context.moveTo(this.x + scroller.x, this.y + scroller.y); //ラインの始点座標
        context.lineTo(this.x2 + scroller.x, this.y2 + scroller.y); //ラインの終点座標
        context.stroke(); //線を引く
    }

またLineActorの場合は、scroller.xやscroller.yを用いてこのように描画することにします。
        context.moveTo(this.x + scroller.x, this.y + scroller.y); //ラインの始点座標
        context.lineTo(this.x2 + scroller.x, this.y2 + scroller.y); //ラインの終点座標

他の図形クラスの描画も同様です。各点の座標からそれぞれscroller.xとscroller.yだけ差し引くようにする設計です。
これで背景スクロールによる描画の準備が整いました。

絶対座標に描画するActorはそのまま

尚、メッセージウィンドウなど、絶対位置に描画するオブジェクトクラスは記述をそのままにしておきます。

Playerクラスの座標を、外部からどうやって読み込むか

scroller.x、scroller.yの値は、Playerクラスの座標を元に取得しようと考えています。が、ちょっと困ったことがあります。 それは、追加したシーンのconstructor()内でしかPlayerにアクセスできないことです。
class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        const player = new Player(150, 200);
        this.add(npc);
        this.add(player);
    }
}
こちら確認です。始まりのメインシーンの記述の中で。
        const player = new Player(150, 200);

作成したプレイヤークラスのオブジェクトは、const playerで定義されてます。 感覚的には、playerという変数を通じて、player.xだったりplayer.yだったり、各プロパティにアクセスできるはずですが...

。。。関数にはクロージャーという概念があって、なんとMainSceneの外からは、、もっと言えばMainSceneのconstructor{}から一歩でも外に出てしまったら、もうplayer.xにアクセスできなくなってしまうのです。しかも、シーン内のconstで定義したplayerは、このシーンのみの役割で途切れます。

一つのシーンが終わって、次のシーンにプレイヤーを移動させる場合、新しいシーンで再度
NewScene extends Scene {
    constructor(renderingTarget) {
        const player = new Player(150, 200); //ここ
        this.add(player);
    }
と、プレイヤーを再定義しなければならない。

このとき、以前のMainSceneで定義したplayerと、NewSceneで再度定義しなおしたplayerは、関係が切り離されてる...全くの別物扱いだと思われます。関数の中の定義は、それぞれ{}の内でのみ機能し、その役割を終えるから。。

たぶん、ここは要修正。。。RPGの場合、シーン毎に別々のプレイヤーオブジェクトが作られるのではなく、唯一のプレイヤーオブジェクトが、各シーンを飛び越えて動き回るのだから。なので前もってconst playerの定義を、広域変数として展開しておきたいと思います。

playerをグローバル領域に展開する必要がある

playerを広域変数で登録できれば、複数のシーンにまたがって同一のアクターが活躍できる。そしてプレイヤーの各プロパティも自由に取得できていい感じです。シーンにplayerを追加する際、予めグローバル領域に定義したplayerを指名できるよう記述してみます。こんな感じでどうでしょう??

danmaku.js ⇒ 最後の方に

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.screenCanvas);
    document.body.appendChild(timeCounter);
    game.start();
});

const player = new Player(150, 200); //此処を追加した

そして、メインシーンにplayerをそのまま追加する。
class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        this.add(npc);
        this.add(player); //ここを修正

        const line2 = new LineActor(20, 150,   240, 40);
        this.add(line2);
        const line3 = new LineActor(350, 70,   0, 220,   true);
        this.add(line3);
    }
}
うまくいくかな?? 結果はこんな感じ。。。

画像が読み込めない


。。。

プレイヤー画像が表示されない。NPCちゃんまでは表示されてるのに。。
このエラーは、playerの画像がうまく読み込めなかったということか。

おそらく原因は、画像を格納してるAssetsが非同期処理で扱われてるからだろうと思う。
画像を読み込み終わってから、画像を使ったSpriteActorクラスを定義しないと、imageが見つかりません...となるんだろう。困りました...



試しに、シーンに追加する際にプレイヤー画像を再定義してみる。
class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        this.add(npc);

        player.sprite.image = assets.get('player'); //画像を再定義
        this.add(player); //ここで追加

        const line2 = new LineActor(20, 150,   240, 40);
        this.add(line2);
        const line3 = new LineActor(350, 70,   0, 220,   true);
        this.add(line3);
    }
}

画像が読み込めた!
すると、上手く表示された...!
やはり画像を読み込んだ後で、画像クラスを定義する必要があるみたいです。


しかし毎回、シーンに呼び出す度に画像を指定し直すだろうか? コレでは記述に無駄が多い。
やはりassetsがイメージを読み込んだ後に、new Playerで定義するのが良かろう。ということで、次にこのような形を試します。

assets.loadAll().then ⇒ new Playerを試す

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.screenCanvas);
    document.body.appendChild(timeCounter);
    game.start();

    const player = new Player(150, 200); //此処を追加した
});

assets.loadAll().then((a) => { となってる箇所、これは画像を全て読み込んだ後に動作させる関数を描く所。ここにnew Playerでplayerオブジェクトを定義してみます。}); // メインシーンの記述はそのまま。これでplayerはちゃんと読み込めるかな???

playerが読み込めない

結果。。playerが見つかりません。。。ここで{}の仕様が障害になっている。{}の中だけでしか定義した変数が機能を果たせないんだ。 非同期処理の{}め〜〜〜〜!!!! 。。。。さてどうするか。。。(o _ o。)

グローバルオブジェクトを先に定義する

そんなときに思いついたのが、グローバルオブジェクトの概念です。
const global = {}; //シーン間を跨がって活躍するActor達を格納する場所。

これこれ。先に、オブジェクトを格納する箱を作ってしまえばいい。 new で定義したオブジェクトを各シーン{}を飛び越えて使い回すのなら、定義できるようになったタイミングでglobal{}に登録保管しておいて、使うときにglobal{}から読みこめばいいという考えです!

ではglobal{}への追加の仕方は??
こと先生の記事だ! オブジェクトについての解説を読み返すよ。

JavaScriptの基礎その3:配列とオブジェクト


プロパティには括弧を使った「[‘キー’]」の他にも、ピリオド記法でアクセスすることができます:

'use strict';

const point = {};


point.x = 2;
point.y = 10;

console.log(point.x);
console.log(point.y);

先ほどの古都先生の記事より引用です。なるほど。
これを、プレイヤーオブジェクトの登録に置き換えると...

playerをglobalオブジェクトに追加する

const global = {}; //シーン間を跨がって活躍するActor達を格納する場所。

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.screenCanvas);
    document.body.appendChild(timeCounter);
    game.start();

    global.player = new Player(150, 200); //globalオブジェクトに、player: new Player();のプロパティを追加。global.playerでアクセス可能に!
});

こうです! 後はメインシーンに追加するプレイヤーの定義をglobal.playerに変更すれば...

メインシーンにglobal.playerとして追加

class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const npc = new NPC(150, 100);
        this.add(npc);
        this.add(global.player); //ここを追加

        const line2 = new LineActor(20, 150,   240, 40);
        this.add(line2);
        const line3 = new LineActor(350, 70,   0, 220,   true);
        this.add(line3);
    }
}

画像が読み込めた!
⇒ ゲームが起動できるかデモを見る



できました! 無事に、playerオブジェクトをグローバル化できました。 これでglobal.playerの記述から、何処からでもプレイヤーの各要素にアクセスできるようになりました。

Canvasサイズをグローバル領域に定義しておく

他にも、特定の値をゲーム全体的に使い回す(読み込む)必要のあるものは、グローバル化しておきます。
screenCanvasWidthとか、screenCanvasHeightとかは、scroller要素との調整にも必要と思うのでグローバルに定義する。

engine.js ⇒ 最初に画面サイズを定義

'use strict';

const screenCanvasWidth = 512;
const screenCanvasHeight = 384;

engine.jsの最初にcanvasサイズを定義しておきます。合わせて各記述も調整。

engine.js ⇒ gameクラスの修正

class Game {
    constructor(title, maxFps) { //widthとheightを無くす
        this.title = title;
        this.width = screenCanvasWidth; //ここを修正
        this.height = screenCanvasHeight; //ここを修正
        this.maxFps = maxFps;
        this.currentFps = 0;

        this.screenCanvas = document.createElement('canvas');
        this.screenCanvas.width = screenCanvasWidth; //ここを修正
        this.screenCanvas.height = screenCanvasHeight; //ここを修正
//〜続く

danmaku.js ⇒ RolePlayingGame extends Gameクラスの修正

class RolePlayingGame extends Game {
    constructor() {
        super('RPG製作', 60); //widthとheightを無くす
        const titleScene = new TitleScene(this.screenCanvas);
        this.changeScene(titleScene);
    }
}

これで問題なくゲームが動作しました。スクリーンサイズの定義もOKです!
下準備の仕上げに、Sceneクラスに使われる背景も設定しておこうと思います。

Sceneクラスをカスタマイズする

本格的なシーンの修正に取り掛かる前に、背景サイズの要素を付け加えておこう。 各シーン毎に、移動範囲を決められるようマップサイズを定義するところです。

constructor()内にwidth, height,を付け足し、this.width = width; this.height = height;でアクセスできるようにします。 このwidthとheightを、各シーンの背景サイズとして扱いたいと思います。

class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, backgroundColor, renderingTarget) { //ここを修正
        super();

        this.name = name;
        this.width = width; //ここを追加
        this.height = height; //ここを追加
        this.backgroundColor = backgroundColor;
        this.actors = [];
        this.renderingTarget = renderingTarget;

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3); //ここを修正
        this._detector = new CollisionDetector();

        this._destroyedActors = [];
    }
//以降続く

TitleSceneの修正

class TitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', screenCanvasWidth, screenCanvasHeight, 'black', renderingTarget);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.add(title);
    }
//以降続く
例えば、拡張したTitleSceneクラスでは背景サイズをそのまま、画面サイズ幅、高さで設定します。
super()の中でwidthはscreenCanvasWidth、heightはscreenCanvasHeightという感じ。
        super('タイトル', screenCanvasWidth, screenCanvasHeight, 'black', renderingTarget);

MainSceneの修正

class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 800, 600, 'black', renderingTarget);
        const npc = new NPC(150, 100);
        this.add(npc);
        this.add(global.player);
//以降続く
次のMainSceneクラスでは背景サイズを試しに、幅800、高さ600で設定しようかな。
super()の中でwidthは800、heightは600という感じに。
        super('メイン', 800, 600, 'black', renderingTarget);

予め背景SpriteActorをクラス定義しておいて
class BG0 extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('bg0'), new Rectangle(0, 0, 800, 600));
        const hitArea = new RectangleCollider(-1, -1, 0, 0);
        super(x, y, sprite, hitArea, ['bg']);
    }
}
背景アクターを作ったら、背景画像もAssetsに登録して
assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.addImage('bg0', 'backimage0.png'); //ここを追加

やっとシーンに背景を追加!
class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', screenCanvasWidth, screenCanvasHeight, 'black', renderingTarget);
        const bg1 = new BG1(0, 0);
        this.add(bg1);
        const npc = new NPC(150, 100);
        this.add(npc);
        this.add(global.player);

        const line2 = new LineActor(20, 150,   240, 40);
        this.add(line2);
        const line3 = new LineActor(350, 70,   0, 220,   true);
        this.add(line3);
    }
}

(デモ)
背景画像
上手く背景が表示されました。

注意点として、背景アクターは他より先に追加しておくことでしょうか。 canvas上で画像は順に上書きされちゃうので。

プレイヤー側からscroller要素を動かす

これで背景スクロールの下準備が整いました。scroller要素をつくって、描画関連に追記して、playerをグローバル化して、シーンに背景サイズと画像を設定した。
後はプレイヤーの位置に応じて、scrollerを移動させるだけです。。。Playerクラスの中でやってみます。

playerクラス ⇒ update(gameInfo, input) {}の一部修正

    if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に応じた移動距離を計算
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this.speed;
            this._velocityX += this.speed * Math.cos(this._dirGo);
            this._velocityY += -this.speed * Math.sin(this._dirGo);
        }

        //移動を反映させる
        this.x += this._velocityX;
        this.y += this._velocityY;

        const boundWidth = gameInfo.screenRectangle.width;
        const boundHeight = gameInfo.screenRectangle.height;
        const bound = new RectangleCollider(0, 0, boundWidth, boundHeight);
        
        if(this.isOutOfBounds(bound)) {
            this.x -= this._velocityX;
            this.y -= this._velocityY;
        }

        scroller.x = - this.hitArea.cx + screenCanvasWidth/2; //背景スクロールのx座標
        scroller.y = - this.hitArea.cy + screenCanvasHeight/2; //背景スクロールのy座標


        //スペースキーでアクション発生
        if(input.getKeyDown(' ')) {
            const actorX = this.x + rect.width * Math.cos(this._dir);
            const actorY = this.y - rect.height * Math.sin(this._dir);
            this.spawnActor( new PlayerAction(actorX, actorY, this._dir) );
        }
    }//if(isActive)

ここで追加したのは2行のみです。
        scroller.x = - this.hitArea.cx + screenCanvasWidth/2; //背景スクロールのx座標
        scroller.y = - this.hitArea.cy + screenCanvasHeight/2; //背景スクロールのy座標

プレイヤーの中心座標分だけスクローラーをマイナスさせて、いったん左上にプレイヤーを置くイメージから、画面幅の半分まで右に、画面高さの半分まで下に。
これでプレイヤーが画面中央に表示されるようなスクローラーの位置になりました。


デモで確認してみる


無事にスクロールができてます。おめでとう。 後は、playerのバウンス判定が従来のゲーム画面幅になってるから、そこを各シーンの背景サイズに変更できればいいかな。 シーン側で、update(gameInfo)にシーンの背景サイズを渡せるか試してみます。

Sceneクラス ⇒ update(gameInfo, input)内の修正

    update(gameInfo, input) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        gameInfo.screenRectangle = new Rectangle(0, 0, this.width, this.height ); // ここで各アクターに渡すスクリーンサイズを更新する
        this._updateAll(gameInfo, input);//Actorたちの動きを更新する

        this._qTree.clear();
        this.actors.forEach((a) => {
          this._qTree.addActor(a);
        });
        this._hitTest();//当たり判定を処理する
        this._disposeDestroyedActors();//死んだ役者リスト
        this._clearScreen();//シーンの初期化、描画の前に一度画面全体をクリアする
        this._renderAll();//再描画
    }
追加した1行はこちらです。
        gameInfo.screenRectangle = new Rectangle(0, 0, this.width, this.height ); // ここで各アクターに渡すスクリーンサイズを更新する


デモで確認してみる


うまくいきました。。。

解説というか、そもそもgameInfoの変数って何??っていうのをengine.jsを見ながら追っていくと、Gameクラスのループ内で使われているのを発見します。

Gameクラス ⇒ _loop()内にて

    _loop(timestamp) {
const start = performance.now();
        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);
const end = performance.now();
const timeStr = (end - start).toPrecision(4);
timeCounter.innerText = `${timeStr}ms`;
        requestAnimationFrame(this._loop.bind(this));
    }

new GameInformationされた箇所のscreenRectangleで、ゲーム画面サイズを矩形として記してます。
この画面サイズの情報が、アップデート関数を通して各シーンや各アクターに渡されているようです。
        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);

GameInformationの画面サイズ情報は、そもそも渡される必要がなくなった。逆に各アクターにはシーンの背景サイズのほうが必要なので、シーンのアップデート関数内でgameInfoのサイズを書き換えたという感じでしょうか。。。

説明が冗長すぎてしまいますね。もう少し簡略表記できそう。。ページの最後に調製した分を記載しておこうと思います。

プレイヤーの画面端バウンス判定について

さて、プレイヤーのバウンス判定ですが、ちょっと変えようかなと考えてみたり。

プレイヤーキャラ
⇒ 移動するライン描画


当たり判定を持つオブジェクトに画面端まで追いやられた場合、ハマって動けなくなる。
今後も、強制移動系のトラップを設置する場合、画面のバウンス判定は別の描き方がいいかな?って


ちょとplayerクラス側での記述を、調整してみようと思います。

playerクラス ⇒ update(gameInfo, input) {}内を一部修正


    if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に移動させる
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this.speed;
            this.x += this.speed * Math.cos(this._dirGo);
            this.y += -this.speed * Math.sin(this._dirGo);
        }

        if(this.x < 0) { this.x = 0; } //ここから4行プレイヤーが枠外にはみ出さないよう調整
        if(this.y < 0) { this.y = 0; }
        if(this.x > gameInfo.screenRectangle.width - rect.width) { this.x = gameInfo.screenRectangle.width - rect.width; }
        if(this.y > gameInfo.screenRectangle.height - rect.height) { this.y = gameInfo.screenRectangle.height - rect.height; }

        //背景のスクローラーを動かす
        scroller.x = - this.hitArea.cx + screenCanvasWidth/2; //背景スクロールのx座標
        scroller.y = - this.hitArea.cy + screenCanvasHeight/2; //背景スクロールのy座標


        //スペースキーでアクション発生
        if(input.getKeyDown(' ')) {
            const actorX = this.x + rect.width * Math.cos(this._dir);
            const actorY = this.y - rect.height * Math.sin(this._dir);
            this.spawnActor( new PlayerAction(actorX, actorY, this._dir) );
        }
    }//if(isActive)
    }

追加したのはこの4行です。
        if(this.x < 0) { this.x = 0; } //ここから4行プレイヤーが枠外にはみ出さないよう調整
        if(this.y < 0) { this.y = 0; }
        if(this.x > gameInfo.screenRectangle.width - rect.width) { this.x = gameInfo.screenRectangle.width - rect.width; }
        if(this.y > gameInfo.screenRectangle.height - rect.height) { this.y = gameInfo.screenRectangle.height - rect.height; }
4隅のそれぞれで判定して、枠外に出る場合はその一歩手前の座標を代入する感じです。
これで、枠外にはみ出て操作不能に陥ることは早々なかろうと思います。

scrollerも枠外にはみ出さないよう調整する

同様に、scrollerもシーンの枠外にはみ出さないよう調整します。
これは... シーン側で調整するのが良いだろうか。。

Sceneクラス ⇒ _updateAll()内に追記


    _updateAll(gameInfo, input) {//Actorたちの動きを更新する
        this.actors.forEach((actor) => actor.update(gameInfo, input));
        if(scroller.x > 0) { scroller.x = 0; } //ここから4行、背景スクローラーが枠外にはみ出さないよう調整
        if(scroller.y > 0) { scroller.y = 0; }
        if(scroller.x < screenCanvasWidth - this.width) { scroller.x = screenCanvasWidth - this.width; }
        if(scroller.y < screenCanvasHeight - this.height) { scroller.y = screenCanvasHeight - this.height; }
    }

アクター達のアップデート関数をそれぞれ実行した後に(プレイヤー側で一度スクローラーの位置が定められる)スクローラーが枠外にはみ出さないよう調整する記述です。プレイヤーに追加した4行と同じ感覚で追記しました。プレイヤー側で全部スクローラーの調整を任せても良かったのだけど、コードが冗長になりすぎる恐れがあったので、Sceneクラスに画面端のスクロール調整を任せました。

背景スクロール
⇒ 背景スクロールのデモを見る



これで背景スクロールのできあがりです!!

注意点:グローバルオブジェクトの扱いについて

(2019.02.27)

最後に古都さんにコードの確認をして頂きました。大分いい感じのようです。
ただ一点、グローバルオブジェクトについての注意点と、その改善策を教えてもらったので追記します。
いつもサポートありがとうございます!(' '*)


このぐらいの規模ならグローバルに色々置くのが一番早くて確実です。
それでも、ここからさらに本格的に作っていこうとするとできるだけScene内に閉じ込めていくのが正しいかな、とも思います。

グローバルに持たせるとだんだん散らかってくるので……
「アクセスできる範囲はできるだけ狭く」がプログラムお掃除の鉄則だったりします。

たとえばscrollerは各Sceneに持たせて、renderメソッドをrender(target, scroller)とかにしてしまえば、 Scene内にscroller閉じ込められます。

そろそろ結構本格的になってきて、実力が試される感じになってきています……!
ゆっくりでいいので頑張ってみてください。



グローバルでの定義は、極力無くす方向がよろしい。なるほど。スクローラーが枠外に独立して存在するよりも、確かにシーンの内部に入れ込んでスクロールの流れを追っていくほうがコードを追いやすい側面が有ります。おそらく後から見なおして追記修正したり、メンテナンス加えたりというのを考えると、この描き方が望ましいかもしれない。

よし、scrollerはシーンの内部に入れられるなら入れよう!
最後に、そちらの調整をして終わりにしたいと思います。

Sceneクラスにscroller要素を入れこむ

class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, backgroundColor, renderingTarget) {
        super();

        this.name = name;
        this.width = width;
        this.height = height;
        this.backgroundColor = backgroundColor;
        this.actors = [];
        this._destroyedActors = [];
        this.renderingTarget = renderingTarget;

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3);
        this._detector = new CollisionDetector();
    }


//〜〜〜〜


    update(gameInfo, input) {
        gameInfo.sceneName = this.name; // 各アクターに渡すシーンの名前を定義する
        gameInfo.sceneWidth = this.width; // 各アクターに渡すシーンの幅を定義する
        gameInfo.sceneHeight = this.height; // 各アクターに渡すシーンの高さを定義する
        gameInfo.scroller = this.scroller; //プレイヤーにスクロール情報を渡して操作してもらう。

        this._updateAll(gameInfo, input);//Actorたちの動きを更新する

        this._qTree.clear();
        this.actors.forEach((a) => {
          this._qTree.addActor(a);
        });
        this._hitTest();//当たり判定を処理する
        this._disposeDestroyedActors();//死んだ役者リスト
        this._clearScreen();//シーンの初期化、描画の前に一度画面全体をクリアする
        this._renderAll();//再描画
    }

    _updateAll(gameInfo, input) {//Actorたちの動きを更新する
        this.actors.forEach((actor) => actor.update(gameInfo, input));

        this.scroller = gameInfo.scroller; //アップデート後のスクロール情報をシーンに読み込む

        if(this.scroller.x > 0) { this.scroller.x = 0; } //左右スクロール限界を設定
        else if(this.scroller.x < screenCanvasWidth - this.width) { this.scroller.x = screenCanvasWidth - this.width; }
        if(this.scroller.y > 0) { this.scroller.y = 0; } //上下スクロール限界を設定
        else if(this.scroller.y < screenCanvasHeight - this.height) { this.scroller.y = screenCanvasHeight - this.height; }
    }

//〜〜〜

    _renderAll() {//updateメソッドで呼び出される _renderAll()で、描画します。
        this.actors.forEach(
            (obj) => obj.render(this.renderingTarget, this.scroller)
        );
    }

追記:アップデート関数内で、gameInfo.sceneWidth、gameInfo.sceneHeight、gameInfo.scrollerをアクターに渡しています。 こうすると、アクター側でシーンの各情報にアクセスできるようになります。

Player側のupdate()内でスクローラーを動かす

    update(gameInfo, input) {
        //なんか色々やる
        //移動反映後

        //背景スクローラーの調整
        gameInfo.scroller.x = - this.hitArea.cx + screenCanvasWidth/2; //背景スクロールのx座標
        gameInfo.scroller.y = - this.hitArea.cy + screenCanvasHeight/2; //背景スクロールのy座標
    }
gameInfoを通じてスクローラーの情報を受け取り、プレイヤー側で調整。

各アクターのrender()メソッドを修正

render(target, scroller) {
    //描画のコード
    }

以上でうまくscrollerを実装できました。
【目次】
  1. JavaScriptでゲーム作り「1:基礎編」
  2. JavaScriptでゲーム作り「2:プレイヤーの歩かせ方」
  3. JavaScriptでゲーム作り「3:プレイヤーの向きに応じてアクションを起こす」
  4. JavaScriptでゲーム作り「4:NPCと会話する」
  5. JavaScriptでゲーム作り「5:当たり判定を最適化する」
  6. JavaScriptでゲーム作り「6:線分の当たり判定を実装する」
  7. JavaScriptでゲーム作り「7:フィールドの背景スクロール」
  8. JavaScriptでゲーム作り「8:プログラムの最適化、高速化、コード整理」
  9. JavaScriptでゲーム作り「9:タッチ・マウスイベント入力」
  10. JavaScriptでゲーム作り「10:法線ベクトルと衝突時のバウンス判定」
  11. JavaScriptでゲーム作り「11:多角形を使った魔法陣を実装する」
  12. JavaScriptでゲーム作り「12:キャッシュの扱い方と計算処理の高速化」
  13. JavaScriptでゲーム作り「13:メッセージウィンドウを実装する」
  14. JavaScriptでゲーム作り「14:イベントの仕組みを理解する」
  15. JavaScriptでゲーム作り「15:メッセージテキスト表示の機能追加」
  16. JavaScriptでゲーム作り「16:会話時の立ち絵&名前表示」

すぺしゃるさんくす

https://sbfl.net/
古都さん
JavaScriptで作る弾幕STGの基礎(フレームワーク)を使わせていただいてます。感謝!


プレイヤーキャラ
ぴぽやさんもありがとう、キャラチップをお借りしています。




今回の調整分リスト

順に載せていきます。

engine.js

  • グローバルに画面サイズを定義
  • SpriteActorクラスの修正分
  • Sceneクラスにscroller要素を作成
  • GameInformationクラスの修正分
  • Gameクラスの修正分

danmaku.js

  • Playerクラスの修正分
  • PlayerActionクラスの修正分
  • LineActorクラスの修正分
  • 背景アクターの追加分
  • MainSceneの記述から最後までの修正分

背景画像

背景
30秒で描きあげました(' '*)

engine.js ⇒ グローバルに画面サイズを定義

const screenCanvasWidth = 512;
const screenCanvasHeight = 384;

engine.js ⇒ SpriteActorクラスの修正分

class SpriteActor extends Actor {//画像を当てはめた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, scroller) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        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 + scroller.x, this.y + scroller.y , // アクターの描画位置を、スクローラーの座標分ずらして調整
            rect.width, rect.height);
    }
}

engine.js ⇒ Sceneクラスの修正分

class Scene extends EventDispatcher {//Actorをまとめあげる舞台の役割、シーンの定義
    constructor(name, width, height, backgroundColor, renderingTarget) {
        super();

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

        // 背景スクローラー用のオブジェクト。アクターの描画位置を調整するのに使用。
        // scroller.xやscroller.yの値はプレイヤー側で操作。アクターのアップデート後、各シーンで画面端の調整する
        this.scroller =  { x: 0, y: 0 };

        this._qTree = new LinearQuadTreeSpace(this.width, this.height, 3);
        this._detector = new CollisionDetector();

        this._destroyedActors = [];
    }

    add(actor) {//Actorたちを保持する(追加・削除)
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));//spawnactorイベントが発生した場合はシーンにActorを追加
        actor.addEventListener('destroy', (e) => this._addDestroyedActor(e.target));//destroyイベントが発生した場合はそのActorを削除
    }

    remove(actor) {
        const index = this.actors.indexOf(actor);
        this.actors.splice(index, 1);
    }
    
    changeScene(newScene) {//Sceneを変更します。ですがScene自身がSceneを変更することはできないので、Actorのときと同じようにイベントだけ発生させて、実際の処理は他のクラスに任せます
        const event = new GameEvent(newScene);
        this.dispatchEvent('changescene', event);
    }

    update(gameInfo, input) {
        gameInfo.sceneName = this.name; // 各アクターに渡すシーンの名前を定義する
        gameInfo.sceneWidth = this.width; // 各アクターに渡すシーンの幅を定義する
        gameInfo.sceneHeight = this.height; // 各アクターに渡すシーンの高さを定義する
        gameInfo.scroller = this.scroller; //プレイヤーにスクロール情報を渡して操作してもらう。

        this._updateAll(gameInfo, input);//Actorたちの動きを更新する

        this._qTree.clear();
        this.actors.forEach((a) => {
          this._qTree.addActor(a);
        });
        this._hitTest();//当たり判定を処理する
        this._disposeDestroyedActors();//死んだ役者リスト
        this._clearScreen();//シーンの初期化、描画の前に一度画面全体をクリアする
        this._renderAll();//再描画
    }

    _updateAll(gameInfo, input) {//Actorたちの動きを更新する
        this.actors.forEach((actor) => actor.update(gameInfo, input));

        this.scroller = gameInfo.scroller; //アップデート後のスクロール情報をシーンに読み込む

        if(this.scroller.x > 0) { this.scroller.x = 0; } //左右スクロール限界を設定
        else if(this.scroller.x < screenCanvasWidth - this.width) { this.scroller.x = screenCanvasWidth - this.width; }
        if(this.scroller.y > 0) { this.scroller.y = 0; } //上下スクロール限界を設定
        else if(this.scroller.y < screenCanvasHeight - this.height) { this.scroller.y = screenCanvasHeight - this.height; }
    }

    _clearScreen() {//シーンの初期化、描画の前に一度画面全体をクリアする
        const context = this.renderingTarget.getContext('2d');
        context.fillStyle = this.backgroundColor;
        context.fillRect(0, 0, screenCanvasWidth, screenCanvasHeight);
    }

    _renderAll() {//updateメソッドで呼び出される _renderAll()で、描画します。
        this.actors.forEach(
            (obj) => obj.render(this.renderingTarget, this.scroller)
        );
    }

    _addDestroyedActor(actor) {//死んだ役者リストに追加
        this._destroyedActors.push(actor);
    }

    _disposeDestroyedActors() {//役者を消す
        this._destroyedActors.forEach((actor) => this.remove(actor));//死んだ役者を消す
        this._destroyedActors = [];//消した死んだ役者リストを空にする
    }

    _hitTest(currentIndex = 0, objList = []) {
      const currentCell = this._qTree.data[currentIndex];
      this._hitTestInCell(currentCell, objList);
      let hasChildren = false;
      for(let i = 0; i < 4; i++) {
        const nextIndex = currentIndex * 4 + 1 + i;
        const hasChildCell = (nextIndex < this._qTree.data.length) && (this._qTree.data[nextIndex] !== null);
        hasChildren = hasChildren || hasChildCell;
        if(hasChildCell) {
          objList.push(...currentCell);
          this._hitTest(nextIndex, objList);
        }
      }

      if(hasChildren) {
        const popNum = currentCell.length;
        for(let i = 0; i < popNum; i++) { objList.pop(); }
      }
    }

    _hitTestInCell(cell, objList) {
      const length = cell.length;
      const cellColliderCahce = new Array(length); 
      if(length > 0) { cellColliderCahce[0] = cell[0].hitArea; }

      for(let i=0; i < length - 1; i++) {
        const obj1 = cell[i];
        const collider1  = cellColliderCahce[i]; 
        for(let j=i+1; j < length; j++) {
          const obj2 = cell[j];
          let collider2;
          if(i === 0) {
            collider2 = obj2.hitArea;
            cellColliderCahce[j] = collider2;
          } else {
            collider2 = cellColliderCahce[j];
          }
          const hit = this._detector.detectCollision(collider1, collider2);

          if(hit) {
            obj1.dispatchEvent('hit', new GameEvent(obj2));
            obj2.dispatchEvent('hit', new GameEvent(obj1));
          }
        }
      }

      const objLength = objList.length;
      const cellLength = cell.length;
      for(let i=0; i

engine.js ⇒ GameInformationクラスの修正分

class GameInformation {//ActorやSceneのupdateに渡すゲーム情報クラスを作りましょう。
    constructor(title, maxFps, currentFps) {
        this.title = title;
        this.maxFps = maxFps;
        this.currentFps = currentFps;
    }
}
追記:screenRectangle要素を無くしました。

engine.js ⇒ Gameクラスの修正分

class Game {//いよいよ総まとめです!Gameクラスを作りましょう。Gameクラスはメインループを持ちます
    constructor(title, maxFps) {
        this.title = title;
        this.maxFps = maxFps;
        this.currentFps = 0;

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

        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 start = performance.now();
        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 gameInfo = new GameInformation(this.title, this.maxFps, this.currentFps);
        const input = this._inputReceiver.getInput();
        this.currentScene.update(gameInfo, input);
const end = performance.now();
const timeStr = (end - start).toPrecision(4);
timeCounter.innerText = `${timeStr}ms`;
        requestAnimationFrame(this._loop.bind(this));
    }
}
追記:new GameInformation()内のscreenRectangle要素を無くしました。

danmaku.js ⇒ Playerクラスの修正分

class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 96, 32, 32));
        const hitArea = new RectangleCollider(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['player']);
        
        this.speed = 2;
        this.walkCount = 0;
        this._dir = 90 * Math.PI/180; //プレイヤーの向き...角度(ラジアン)の単位。。角度°にπ/180をかけた値
        this._dirGo = 90 * Math.PI/180; //進む方角...初期は上向き
        this.isActive = true;

        this._interval = 5;
        this._timeCount = 0;

        this.addEventListener('hit', (e) => {
            if(!e.target.hasTag('spirit') && !e.target.hasTag('element') && !e.target.hasTag('playerAction') || !e.target.hitArea.type=='line') {
                const dx = e.target.hitArea.cx - this.hitArea.cx; //正の値ならプレイヤーが左側
                const dy = e.target.hitArea.cy - this.hitArea.cy; //正の値ならプレイヤーが上側
                if( dx > 0 && Math.abs(dx) > this.hitArea.width/2 ) { this.x -= this.speed; }
                if( dx < 0 && Math.abs(dx) > this.hitArea.width/2 ) { this.x += this.speed; }
                if( dy > 0 && Math.abs(dy) > this.hitArea.height/2 ) { this.y -= this.speed; }
                if( dy < 0 && Math.abs(dy) > this.hitArea.height/2 ) { this.y += this.speed; }
            }
            if(e.target.hitArea.type=='line') {
                const lineAB = e.target.hitArea; //ラインの壁
                const delta = lineAB.crossAB(this.hitArea.cx, this.hitArea.cy); // 自分の中心点とラインとの位置関係を調べる。
                if(delta > 0 || e.target.isFall) { //ラインに対する自分の中心点が正の位置にあるなら、ベクトルを正の値でとる
                    this.x += Math.SQRT2 * this.speed * lineAB.boundVect.x;
                    this.y += Math.SQRT2 * this.speed * lineAB.boundVect.y;
                }
                else if(delta < 0) { //ラインに対する自分の中心点が負の位置にあるなら、ベクトルを負の値でとる
                    this.x -= Math.SQRT2 * this.speed * lineAB.boundVect.x;
                    this.y -= Math.SQRT2 * this.speed * lineAB.boundVect.y;
                }
            }
        });
    }
    
    update(gameInfo, input) {
        const rect = this.sprite.rectangle;

        //矢印キーを押しただけの時に、プレーヤーの向きを変える。
        if(!input.getKey(' ')) {
            if(input.getKey('ArrowUp')) { rect.y = 96; this._dir = 90 * Math.PI/180;} //上
            if(input.getKey('ArrowRight')) { rect.y = 64; this._dir = 0 * Math.PI/180;} //右
            if(input.getKey('ArrowDown')) { rect.y = 0; this._dir = -90 * Math.PI/180;} //下
            if(input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 180 * Math.PI/180; } //左
            if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 45 * Math.PI/180;} //右上
            if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = -45 * Math.PI/180;} //右下
            if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = -135 * Math.PI/180;} //左下
            if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 135 * Math.PI/180;} //左上
        }

        //進む方角の設定
        if(input.getKey('ArrowUp')) { this._dirGo = 90 * Math.PI/180;} //上
        if(input.getKey('ArrowRight')) { this._dirGo = 0 * Math.PI/180;} //右
        if(input.getKey('ArrowDown')) { this._dirGo = -90 * Math.PI/180;} //下
        if(input.getKey('ArrowLeft')) { this._dirGo = 180 * Math.PI/180; } //左
        if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this._dirGo = 45 * Math.PI/180;} //右上
        if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this._dirGo = -45 * Math.PI/180;} //右下
        if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this._dirGo = -135 * Math.PI/180;} //左下
        if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this._dirGo = 135 * Math.PI/180;} //左上

        //歩行カウントが一定以上に達した時、歩くモーションを変化
        if(this.walkCount > 0) { rect.x = 64; }
        if(this.walkCount > 16) { rect.x = 32; }
        if(this.walkCount > 32) { rect.x = 0; }
        if(this.walkCount > 48) { rect.x = 32; this.walkCount = -15; }
        
        //立ち止まった時に直立姿勢
        if(input.getKeyUp('ArrowUp') || input.getKeyUp('ArrowDown') || input.getKeyUp('ArrowRight') || input.getKeyUp('ArrowLeft')) {rect.x = 32; this.walkCount = 0;}

    if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に移動させる
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this.speed;
            this.x += this.speed * Math.cos(this._dirGo);
            this.y += -this.speed * Math.sin(this._dirGo);
        }

        //ここから4行プレイヤーが枠外にはみ出さないよう調整
        if(this.x < 0) { this.x = 0; }
        if(this.y < 0) { this.y = 0; }
        if(this.x > gameInfo.sceneWidth - rect.width) { this.x = gameInfo.sceneWidth - rect.width; }
        if(this.y > gameInfo.sceneHeight - rect.height) { this.y = gameInfo.sceneHeight - rect.height; }

        //背景のスクローラーを動かす
        gameInfo.scroller.x = - this.hitArea.cx + screenCanvasWidth/2; //背景スクロールのx座標
        gameInfo.scroller.y = - this.hitArea.cy + screenCanvasHeight/2; //背景スクロールのy座標


        //スペースキーでアクション発生
        if(input.getKeyDown(' ')) {
            const actorX = this.x + rect.width * Math.cos(this._dir);
            const actorY = this.y - rect.height * Math.sin(this._dir);
            this.spawnActor( new PlayerAction(actorX, actorY, this._dir) );
        }
    }//if(isActive)
    }
}
いったん、this._velocity要素を無くしてます。

danmaku.js ⇒ PlayerActionクラスの修正分

class PlayerAction extends Actor {
    constructor(x, y, dir) {
        const hitArea = new CircleCollider(16, 16, 16);
        super(x, y, hitArea, ['playerAction']);
        this._dir = dir;
    }

    render(target, scroller) {//オーバーライドしたrenderメソッドで、アクション範囲を可視化してみる
        const context = target.getContext('2d');
        const rect = this.hitArea;
        context.beginPath();
        context.fillStyle = "rgba(255,255,255,0.7)"; //半透明の白で塗りつぶす
        context.arc(rect.x + scroller.x, rect.y + scroller.y, rect.radius, 0, Math.PI*2, false);
        context.fill();
    }

    update(gameInfo, input) {
        this.destroy();
    }
}

danmaku.js ⇒ LineActorクラスの修正分


class LineActor extends Actor {
    constructor(x, y, x2, y2, isFall=false) {
        const hitArea = new LineCollider(x, y, x2, y2);
        super(x, y, hitArea, ['object']);
        this.x2 = x2;
        this.y2 = y2;
        this.isFall = isFall;
        this._color = 'rgba(255, 255, 255, 0.9)';
        this.addEventListener('hit', (e) => this._color = 'rgba(255, 0, 0, 0.9)' );
    }

    get x() { return this._x; } set x(value) { this._x = value; this.hitArea.x = value; }//x座標に値を代入する関数をActorから上書き
    get y() { return this._y; } set y(value) { this._y = value; this.hitArea.y = value; }//y座標に値を代入する関数をActorから上書き
    get x2() { return this._x2; } set x2(value) { this._x2 = value; this.hitArea.x2 = value; }//x2座標に値を代入するときhitArea.x2も上書き
    get y2() { return this._y2; } set y2(value) { this._y2 = value; this.hitArea.y2 = value; }//y2座標に値を代入するときhitArea.y2も上書き

    update(gameInfo, input) {
        this._color = 'rgba(255, 255, 255, 0.9)';
    }
    render(target, scroller) {
        const context = target.getContext('2d');
        context.strokeStyle = this._color;
        context.beginPath(); //描画位置の初期化
        context.moveTo(this.x + scroller.x, this.y + scroller.y); //ラインの始点座標
        context.lineTo(this.x2 + scroller.x, this.y2 + scroller.y); //ラインの終点座標
        context.stroke(); //線を引く
    }
}

danmaku.js ⇒ 背景アクターの追加分

class BG0 extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('bg0'), new Rectangle(0, 0, 800, 600));
        const hitArea = new RectangleCollider(-1, -1, 0, 0);
        super(x, y, sprite, hitArea, ['bg']);
    }
}

danmaku.js ⇒ MainSceneの記述から最後までの修正分

class MainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン',  800, 600, 'black', renderingTarget);
        const bg0 = new BG0(0, 0);
        this.add(bg0);
        const npc = new NPC(150, 100);
        this.add(npc);
        this.add(global.player);

        const line2 = new LineActor(20, 150,   240, 40);
        this.add(line2);
        const line3 = new LineActor(350, 70,   0, 220,   true);
        this.add(line3);
    }
}

class TitleScene extends Scene {
    constructor(renderingTarget) {
        super('タイトル', screenCanvasWidth, screenCanvasHeight, 'black', renderingTarget);
        const title = new TextLabel(100, 200, 'RPG製作',"white", 25);
        this.add(title);
    }

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

class RolePlayingGame extends Game {
    constructor() {
        super('RPG製作', 60);
        const titleScene = new TitleScene(this.screenCanvas);
        this.changeScene(titleScene);
    }
}


const timeCounter = document.createElement('div');
const global = {}; //シーン間を跨がって活躍するActor達を格納する場所。

assets.addImage('sprite', 'sprite.png');
assets.addImage('player', 'chara_player.png');
assets.addImage('npc1', 'chara_npc1.png');
assets.addImage('bg0', 'backimage0.png');
assets.loadAll().then((a) => {
    const game = new RolePlayingGame();
    document.body.appendChild(game.screenCanvas);
    document.body.appendChild(timeCounter);
    game.start();

    global.player = new Player(150, 200); //globalオブジェクトに、player: new Player();のプロパティを追加。global.playerでアクセス可能に!
});
追加修正分は以上です。