JavaScriptでゲーム作り「8:プログラムの最適化、コード整理」

これまでの各コードを調整する

(2019.2.22執筆)

背景スクロール、一から無事に描けたことで、今後のゲームづくりに応用できそうなことが大幅に増えました。今回は新しいステップに入る前に、これまでの総集編として、コード周りの最適化(?)を行いたいと思います。

手を加える部分

  • 矩形と円同士の当たり判定高速化
  • 割り算や平方根による計算をできるだけ避ける
  • 基本の(Sprite)Actorクラスを整理
  • 背景のカスタムスクロール及び、コードの記述順序について

順番に見ていきます。

矩形と円同士の当たり判定高速化

いきなりですが矩形と円の当たり判定式・・・1つの追加コードで、劇的に改善されてしまった今日この頃です。 2つのシーンの矩形アクター50個、円アクター50個の当たり判定時間を、まず見くらべてみてください。

矩形と円の当たり判定
⇒ 矩形と円の当たり判定デモ1を見る



矩形と円の当たり判定改善例
⇒ 矩形と円の当たり判定デモ2を見る



なんと、約半分くらいの時間短縮になっています。
いったい何をやったのか。

矩形と円の当たり判定式


  deRectCircle(rect, circle) { //矩形と円の衝突判定式
    const leftRC = rect.left - circle.radius; //x軸左の境界(矩形の左端から、円の半径分さらに左のx座標)
    const topRC = rect.top - circle.radius; //y軸トップの境界(矩形のトップから、円の半径分さらに上のy座標)
    const widthRC = rect.width + circle.radius*2; //矩形の幅と円の直径を足した値
    const heightRC = rect.height + circle.radius*2; //矩形の高さと円の直径を足した値

// ↓ここで、false判定をとると早い!
    const bigRect = new Rectangle (leftRC, topRC, widthRC, heightRC);
    if ( !this.deRectPoint(bigRect, circle.x, circle.y) ) { return false; } //大きな矩形と円の中心点で判定。当たってないならfalse。判定高速化のため追記
// ↑ここまで

    const wideRect = new Rectangle (leftRC, rect.top, widthRC, rect.height); //円の半径分、横幅を増やした矩形
    const heightRect = new Rectangle (rect.left, topRC, rect.width, heightRC); //円の半径分、縦幅を増やした矩形
    if ( this.deRectPoint(wideRect, circle.x, circle.y) || this.deRectPoint(heightRect, circle.x, circle.y) ) {return true;} //それぞれの矩形と円の中心点で判定

    if (this.deCirclePoint( circle, rect.left, rect.top) || this.deCirclePoint( circle, rect.right, rect.top) ||
        this.deCirclePoint( circle, rect.left, rect.bottom) || this.deCirclePoint( circle, rect.right, rect.bottom) )  
       {return true;} //矩形の四隅の点と、円との衝突判定

    else {return false;} //3つとも判定が無ければ当たってない。
  }

注目部分はこちらです。
// ↓ここで、false判定をとると早い!
    const bigRect = new Rectangle (leftRC, topRC, widthRC, heightRC);
    if ( !this.deRectPoint(bigRect, circle.x, circle.y) ) { return false; } //大きな矩形と円の中心点で判定。当たってないならfalse。判定高速化のため追記

bigRect...矩形の左右幅と上下幅をそれぞれ円の半径分だけ拡張して、拡張した「矩形」と円の中心「点」で衝突判定します。
この一手でfalseがでるなら、そもそも当たらないんだ。以下2〜6つくらい続いてる判定式は必要なくなる。

ただ当たり判定をとるだけなら無くてもいけるのですが、まさかの改善効果です。


if()の条件分岐とreturnでの終了、起こる可能性の高い順に(あるいは処理の手間が少ない順に)考えて記述すると、100回の判定でここまでのパフォーマンスの差が出ることになってしまいました。
これはあまりに考えさせられます、。。ちなみにphina.jsさんからヒントを頂きました。ありがとうございます。

計算式の順番、条件分岐の順番を考えて、全体的なコードの最適化を測ると良さそうです。

割り算や平方根の計算をできるだけ避ける

後日追記(2018.04.05)...曲線の当たり判定を吟味中に、興味深い記事を発見しました。

割り算を避ける(高速化プログラミング)
高速根号計算 (fast sqrt algorithm)

曰く、割り算の演算処理は、掛け算の倍以上の時間がかかる。
それから√(平方根)を求める演算処理は、単純な2乗計算よりもかなり時間かかる。

平方根を求める計算式Sampleを見ると、とても長い

// 正解値 : 1.414213562373095…

   X1 = 2
  X2 = (X1 + X1 / X1) / 2 = (2.0000 + 2 / 2.0000) / 2 = 1.5000
  X3 = (X2 + X1 / X2) / 2 = (1.5000 + 2 / 1.5000) / 2 ≒ 1.4167
  X4 = (X3 + X1 / X3) / 2 = (1.4167 + 2 / 1.4167) / 2 ≒ 1.4142
  X5 = (X4 + X1 / X4) / 2 = (1.4142 + 2 / 1.4142) / 2 ≒ 1.4142
   
  /*u_long sqrt2(u_long f)
  {
    u_long s = f,t;
    if(x == 0) return 0;
    do
    {
      t = s;
      s = (t + f / t) >> 1;
    }while(s < t);
    return t;
  }*/

このときの改善例がいくつか見つけられました。

割り算は、小数点の掛け算に置き換えられる

//例えば、2で割る計算式というのは、0.5を掛ける計算に置き換わる。
this.width/2 ⇒ this.width*0.5;

//4で割るときも、このように置き換わる。
value / 4 ⇒ value * 0.25;

//8で割るときも、このように置き換わる。
value / 8 ⇒ value * 0.125;

//応用
value / x ⇒ value * (1 / x)

まず割り算の箇所を、掛け算で済む所は小数を用いた掛け算で済ませられます。

ただし小数点以下の数値は、0.1など2進数で循環小数となる値がほとんどになる。
プログラムは数値を2進数で判別しているため、小数計算は僅かな誤差が生まれることもある?
1/2、1/4、1/8、とかだけに変換を絞るか、厳密な計算結果が必要無いときに推奨。

(参考)
1より小さい数を含む二進数表現
浮動小数点数について本気出して考えてみた

平方根を用いた比較は、両辺を2乗した値同士で比較できる

// 原点(0, 0)を中心とする円と点(x, y)の当たり判定...
//半径 >= √(x*x + y*y)とするより
//半径×半径 >= x*x + y*y としたほうがやや高速化する。

√(平方根)を用いた比較演算(例えば円の当たり判定で半径と最短距離を比較するとき等)は、左辺と右辺を2乗した形で、それぞれを比較すると負担が少ない(不等号の向きは変わらないので)といったところです。
この例は円と線分の当たり判定の項でも活用しています。

Math.sqrt(2)はMath.SQRT2の定数で置き換わる

//Mathメソッドには、√2となる定数が定義されている。(値をそのまま呼び出すので早い)
Math.sqrt(2) ⇒ Math.SQRT2

Math.sqrt(1/2)はMath.SQRT1_2の定数で置き換わる

//Mathメソッドには、1/√2となる定数が定義されている。(値をそのまま呼び出すので早い)
Math.sqrt(1/2) ⇒ Math.SQRT1_2

という感じで、コード全体を見直していくとプログラムの速度が若干改善されそうです。

Actorクラスを見直す

こんな感じで、登場キャラクターの根幹となるアクタークラスから見なおしてみます。
ここから先は、個人的なゲームの作成備忘録なので、読み飛ばして大丈夫です。

Actorクラスのコード


class Actor extends EventDispatcher {//EventDispatcherイベント発生関数を持つActorクラスの定義
    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;
    }

    hasTag(tagName) { return this.tags.includes(tagName); } //タグは当たり判定などのときに使います

    //他のActorを発生させるときに使用
    spawnActor(actor) { this.dispatchEvent('spawnactor', new GameEvent(actor)); }

    //自身を解放(シーンから消去)する、しかし関数で自身を消すことはできないので、イベントだけ発生させてより上位のクラスに実際の処理を任せる
    release() { this.dispatchEvent('release', new GameEvent(this)); }
    
    get x() { return this._x; }//x座標を読み込む関数
    set x(value) {//x座標にvalueを代入する関数
        this._x = value;
        this.hitArea.x = value + this._hitAreaOffsetX;
    }
    get y() { return this._y; }//y座標を読み込む関数
    set y(value) {//y座標にvalueを代入する関数
        this._y = value;
        this.hitArea.y = value + this._hitAreaOffsetY;
    }
    update(gameInfo, input) {}//動く仕組みを作る
    render(target, scroller) {}//...線画処理
}

特に変わりはない。必要最低限で構成される基本のActorクラス。
(それと私の作るゲームのコンセプト上、destroyの文字列をreleaseに変換しております)

全てのActorクラスに必要になるであろう要素をぜーーんぶ入れ込んでも良かったんですが、他のActorでは全く必要のない要素を全てに羅列させるのもなぁ....たぶん、記述のムダを無くせる...というのが自分で描くことのメリット。ならば、ベースはシンプルが良い。Actorはここまでで良いのです。

BG_SpriteActorクラスの定義

class BG_SpriteActor extends Actor {//画像を当てはめただけの不動のActor
    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;//画像
    }
    render(target, scroller) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        const context = target.getContext('2d');
        const rect = this.sprite;
        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);
    }
}

背景オブジェクト用のBG_SpriteActorクラスを用意しました。もうシンプルイズベスト。
このBG_SpriteActorクラスはたぶん岩とか、植物とか、扉とか。動かない画像オブジェクト専用な感じ...

Spriteクラスの定義

class Sprite {//画像と、画像ファイルのどの部分を使うか?の定義)
    constructor(image, x, y, width, height) {
        this.image = image;
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
}

Sprite部分はnew Rectangleを無くして、この際ベタ書きにしました。 たぶんもう当たり判定のないRectangleは使わないので、全ての矩形は当たり判定に使うRectangle(従来のRectangleCollider)に統一してます。Colliderの各形状クラスもちょっと短くなりました。


話逸れますが。ただ背景画像を用意する場合は、シーン側で直に描いたほうが良さ気な気もしている。 時間帯による光の加減とか。シーン特有の効果を取り入れた描画とか。。レイヤーを意識した背景描画はシーン側が向いてる気がするし。ただ画像を設置するだけなら当たり判定も取る必要ないし。

SpriteActorクラスの定義


class SpriteActor extends Actor {//影描画を当てはめた基本のSpriteActor
    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;//画像
        this._dir = 270; //アクターの向き...deg表記で。
        this._velocityX = 0; //フレーム中のx軸の移動値
        this._velocityY = 0; //フレーム中のy軸の移動値
        this.isHit = 0; // hit中かどうか
        this.isStand = true; //立ち止まってる状態かどうか
        this.isDash = false; // ダッシュ中かどうか
        this.walkSpeed = 2; //歩くスピード
        this.walkCount = 0; // 移動距離のカウント

        this.addEventListener('hit', (e) => { //当たり判定発生時のバウンス判定
            if(!e.target.hasTag('playerAction') && !e.target.hasTag('event') && !e.target.hasTag('spirit') && !e.target.hasTag('element')) { //これらのタグ相手とはバウンス判定しない。
                const other = e.target.hitArea;
                let speed = this.speed; //反動ベクトルの大きさを定義する文字列

                if( this.isHit < 12 ) { this.isHit = 12; }
                if( other.type == 'rectangle') { //矩形とのバウンス判定
                    const dx = this.hitArea.cx - other.cx; //お互いの中心座標(x軸)で位置関係を把握。正の値ならこのアクターが右側
                    const dy = this.hitArea.cy - other.cy; //お互いの中心座標(y軸)で位置関係を把握。正の値ならこのアクターが下側
                    if (dx < 0) { //負の値ならこのアクターが左側
                        if ( this.hitArea.right - other.left < this.hitArea.width*0.5 ) { this._velocityX += -speed; }
                    }
                    else if (dx > 0) { //正の値ならこのアクターが右側
                        if ( -this.hitArea.left + other.right < this.hitArea.width*0.5 ) { this._velocityX += speed; }
                    }
                    if (dy < 0) { //負の値ならこのアクターが上側
                        if ( this.hitArea.bottom - other.top < this.hitArea.height*0.5 ) { this._velocityY += -speed; }
                    }
                    else if (dy > 0) { //正の値ならこのアクターが下側
                        if ( -this.hitArea.top + other.bottom < this.hitArea.height*0.5 ){ this._velocityY += speed; }
                    }
                    return;
                }
                if( other.type == 'circle') {  //円形とのバウンス判定。
                    const lineAB = new Line (other.cx, other.cy, this.hitArea.cx, this.hitArea.cy); //相手の中心点から、自分の中心点に向かう法線ベクトル
                    this._velocityX += speed * lineAB.dx / lineAB.length; //このアクターの速さに法線の単位ベクトルX軸をかけて反映
                    this._velocityY += speed * lineAB.dy / lineAB.length; //このアクターの速さに法線の単位ベクトルY軸をかけて反映
                    return;
                }
                if( other.type=='line') { //壁、線分とのバウンス判定
                    const lineAB = e.target.hitArea; //ライン壁を定義
                    const delta = lineAB.crossAB(this.hitArea.cx, this.hitArea.cy); // このアクターは、ラインのどちら側に居るか?
                    let boundVect; //法線ベクトル(衝突時の反動の方向)を定義する文字列。
                    if(e.target.isFall === true || delta > 0) { boundVect = lineAB.boundVect; } //deltaが正のとき、あるいは一方通行のとき、ラインの法線ベクトルを正でとる。
                    else { boundVect = lineAB.boundVect_; } //deltaが負のとき、ラインの法線ベクトルを負でとる。
                    this._velocityX += speed * boundVect.dx; //X軸の反動の大きさを反映
                    this._velocityY += speed * boundVect.dy; //Y軸の反動の大きさを反映
                    return;
                }
            }
        });
    }

    get speed() {
//        if(this.isStand) {return 0;} //立ち止まってる時の移動スピードは0
        if(this.isHit === 0) { return (this.walkSpeed + this.isDash * 4); } //移動スピードを取得。ダッシュ中はベース値に+4、
        else {return this.walkSpeed;} //Hit判定時は歩行スピード
        }
    get dir() { return this._dir; }
    set dir(value) { this._dir = value; } //後の拡張クラスで、sprite画像の向きを変えるときなどに上書きします
    get dirR() { return this._dir/180 * Math.PI; } //現在の向きをラジアンの角度で取得

    render(target, scroller) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        const context = target.getContext('2d');
        const rect = this.sprite; //キャラクターsprite画像

        context.beginPath(); //ここから影の描画
        context.fillStyle = "rgba(100,100,100,0.2)" 
        context.ellipse(this.hitArea.cx + scroller.x, this.hitArea.bottom-1 + scroller.y, 
            this.sprite.width*0.5, this.sprite.height*0.25, 0, 0, 2 * Math.PI);
        context.fill(); //影の描画ここまで

        context.drawImage(this.sprite.image, //ここからsprite画像の描画
            rect.x, rect.y,
            rect.width, rect.height,
            this.x + scroller.x, this.y + scroller.y , // アクターの描画位置を、スクローラーの座標分ずらして調整
            rect.width, rect.height); //sprite画像ここまで
    }

    update(gameInfo, input) {
        this.x += this._velocityX;
        this.y += this._velocityY;
        this.walkCount += Math.sqrt(this._velocityX*this._velocityX + this._velocityY*this._velocityY);

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

        this._velocityX = 0;
        this._velocityY = 0;
        if(this.isHit !== 0) {this.isHit -= 1;}
    }
}

長い...なんとなく今回のメインのような気がしないでもないSpriteActorの定義。

SpriteActorクラスの調整。基本となる人物、動物、石、宝箱、キーオブジェクトなどをシーンに追加するときに使うことを想定した、SpriteActorクラスを定義し直します。 ぶつかったときに移動したり、移動させたり、影を付けたり、などなど。オブジェクトとなる基本的な要素を仮に設定しておきました。

順にコードを追っていこうと思います(' '*)

SpriteActor constructor内の要素一覧


    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;//画像
        this._dir = -90; //アクターの向き...deg表記で。
        this._velocityX = 0; //フレーム中のx軸の移動値
        this._velocityY = 0; //フレーム中のy軸の移動値
        this.isHit = 0; // hit中かどうか
        this.isStand = true; //立ち止まってる状態かどうか
        this.isDash = false; // ダッシュ中かどうか
        this.walkSpeed = 2; //歩くスピード
        this.walkCount = 0; // 移動距離のカウント

//以下続く

画像を割り当てたアクターの基本クラスですが、人物ベースに考えて最低限の要素を追加してみました。
  • this.sprite ... 最初から定義されてます。画像にアクセスするための記述
  • this._dir = -90; ... アクターの向き。移動に使う時はラジアンに変換する必要がありますが、基本はdeg表記で十分。
  • this._velocityX ... フレーム毎のX軸の移動距離、をスタックしておくための要素。衝突判定の処理にも役立ちます。
  • this._velocityY ... Y軸の移動距離も同様。
  • this.isHit = 0; ... Hit中かどうかの判定。0でfalseとして評価されます。値が数字なのは後ほど
  • this.isStand = true; ... このアクターの移動スピードを取得するときに使います。trueならspeedは0です
  • this.isDash ... ダッシュ中かどうか。たまに勢いよく移動しまくることもあっていいでしょう。
  • this.walkSpeed = 2; ... アクターの歩くスピード。プレイヤーの歩行と合わせて。
  • this.walkCount ... 移動距離のカウント。一定値動かすことでイベントを発生させてもいいんじゃない?

わりと必要最低限かなって思う。それでも長いよね。
合わせて、get()関連の要素も確認しておきます。
    get speed() {
//        if(this.isStand) {return 0;} //立ち止まってる時の移動スピードは0
        if(this.isHit === 0) { return (this.walkSpeed + this.isDash * 4); } //移動スピードを取得。ダッシュ中はベース値に+4、
        else {return this.walkSpeed;} //Hit判定時は歩行スピード
        }

移動スピードの取得は、立ち止まってる時は0(後の衝突判定時に必要になるかも?)。
普段は歩行スピードの値。Hit中でない限り、ダッシュ中はさらに+4してます。
これにもif()とreturnの配置の仕方で、最適な描き方があるかもしれない。
今は、Hit中かダッシュ中かどうかでの分岐なので、そこまで考えなくていいと思いますが。。。


後はdir関連のメソッド(アクターの向き)。。。
    get dir() { return this._dir; }
    set dir(value) { this._dir = value; } //sprite画像の向きを変えるとき等に上書きします
    get dirR() { return this._dir/180 * Math.PI; } //現在の向きをラジアンの角度で取得

現在のアクターの向きを取得したり、向きが変わった際にsprite画像を切り替えたり、ラジアンに変換したりなど。ひとまずget()で設定しておきます。

ラジアン表記とかおそらく、縦横無尽に動きまわるのでない限り、例えば画像の「どちら向きか?」を差し替える程度ならベースはdeg表記でいいのかな?って。 *Math.PI/180(ラジアンに変換する関数)もコストかかるみたいなので...先に/180としたのは。計算が楽になりそうな気がしたので。

当たり判定時のアクターの移動について

まぁいいや、次にこのアクターの衝突判定時の関数について触れます。

JavaScriptでゲーム作り「10:法線ベクトルとバウンス処理」


当たり判定時のバウンスは重要な項目なので、今現在できる範囲での記事を新しく起こしました。上のリンク先を御覧ください。

なおバウンス判定で移動させるとき、座標(this.xやthis.y)に直接反映させるのではなく、一旦_VelocityX,Yでフレーム毎の移動値をスタックしておくことが、後の背景スクロールの調整に必要でした。 背景スクロールのカスタマイズはちょっとややこしかたので後回しで、次に描画(影をつける)に移ります。

アクター画像に影をつける

次に、render(target)のカスタマイズ、デフォルトで影を入れる設定にしました。
    render(target, scroller) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        const context = target.getContext('2d');
        const rect = this.sprite; //キャラクターsprite画像

        context.beginPath(); //ここから影の描画
        context.fillStyle = "rgba(100,100,100,0.2)" 
        context.ellipse(this.hitArea.cx + scroller.x, this.hitArea.bottom-1 + scroller.y, 
            this.sprite.width*0.5, this.sprite.height*0.25, 0, 0, 2 * Math.PI);
        context.fill(); //影の描画ここまで

        context.drawImage(this.sprite.image, //ここからsprite画像の描画
            rect.x, rect.y,
            rect.width, rect.height,
            this.x + scroller.x, this.y + scroller.y , // アクターの描画位置を、スクローラーの座標分ずらして調整
            rect.width, rect.height); //sprite画像ここまで
    }

追加した部分はここです。
        context.beginPath(); //ここから影の描画
        context.fillStyle = "rgba(100,100,100,0.2)" 
        context.ellipse(this.hitArea.cx + scroller.x, this.hitArea.bottom-1 + scroller.y, 
            this.sprite.width*0.5, this.sprite.height*0.25, 0, 0, 2 * Math.PI);
        context.fill(); //影の描画ここまで

render部分を追加すれば、1つの画像だけでなく、一緒に図形も添えられたり、2つ目の画像を追加できたりもするようです。 描画の順番、後から追加したほうが重なった時に上になります。

これで動くSpriteActorクラスには、一緒に影も描画されるようになりました。
残りの背景スクロールやupdate()は、次のPlayerクラス内にて解説。

Player extends SpriteActorクラスの解説

Playerクラス解説の主は、update()関数内の順序についてです。

class Player extends SpriteActor {
    constructor(x, y, dir=90, isActive = true) {
        const sprite = new Sprite(assets.get('player'), 32, 96, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['player']);
        this.isActive = isActive; //今、キー入力を受け付けるかどうか
        this.isScroll = true; //今、画面スクロールをさせるかどうか
        this.isScrollCustom = true; //カスタムスクロールか?
        this._dir = dir; //最初の向きを指定値で上書き、指定漏れの場合、初期値は90度にしている。
        this._dirGo = 0; //進む方角...移動方向に使うので、deg表記の角度°にπ/180をかけた値を用いる。初期値は0だけどすぐ変わるだろう。
        this.walkSpeed = 2;
        this._timeCount = 0;
    }

    get speed() {
        if(this.isHit === 0) { return (this.walkSpeed + this.isDash * 4); } //移動スピードを取得。ダッシュ中はベース値に+4、
        else {return this.walkSpeed;} //Hit判定時は歩くスピード
        }

    get dir() { return this._dir; }
    set dir(value) { this._dir = value; //プレイヤーの向きが変わった時、画像も差し替え
                     if (-45 <= value && value <= 45 || 315 <= value ) { this.sprite.y = this.sprite.height*2; return; } //画像右向き
                     else if (45 < value && value < 135) { this.sprite.y = this.sprite.height*3; return; } //画像上向き
                     else if (135 <= value && value <= 225 || value <= -135) { this.sprite.y = this.sprite.height; return; } //画像左向き
                     else if (-135 < value && value < -45 || 225 < value ) { this.sprite.y = 0; return; } //画像下向き
                   }

    bgScrollX(scroller) { //背景スクロールのX軸移動(基本はプレイヤーのupdate()内で使用)最後の調整はシーン側で行ってます
        let scrollPX
        if(!this.isScrollCustom) { scrollPX = - this.hitArea.cx + screenCanvasWidth*0.5; } //x座標のノーマルスクロール
        else{ scrollPX = -this.hitArea.cx + screenCanvasWidth*0.5 - 84 * Math.round(Math.cos(this.dirR)); }//カスタムスクローラー本来のx座標

        if ( this.isHit < 1 || Math.abs(this._velocityX) > 2) {
            scroller.x -= this._velocityX; //プレイヤーに合わせてx座標を移動(バウンス判定などで移動距離が短い場合、何もしない)
        }
        if (scroller.x + this.walkSpeed*1.25 < scrollPX) { scroller.x += this.walkSpeed*1.25; return; } //画面の縦横比でx軸移動の比を調整
        else if (scroller.x - this.walkSpeed*1.25 > scrollPX) { scroller.x -= this.walkSpeed*1.25; return; }
        else if (scroller.x + 1 < scrollPX) { scroller.x += 1; return; } //差がわずかの時は1だけ移動
        else if (scroller.x - 1 > scrollPX) { scroller.x -= 1; return; }
        else { scroller.x = scrollPX; return; }
    }
    bgScrollY(scroller) { //背景スクロールのY軸移動(基本はプレイヤーのupdate()内で使用)最後の調整はシーン側で行ってます
        let scrollPY
        if(!this.isScrollCustom) { scrollPY = - this.hitArea.cy + screenCanvasHeight*0.5; } //y座標のノーマルスクロール
        else{ scrollPY = -this.hitArea.cy + screenCanvasHeight*0.5 + 63 * Math.round(Math.sin(this.dirR)); }//カスタムスクローラー本来のy座標

        if ( this.isHit < 1 || Math.abs(this._velocityY) > 2 ) {
            scroller.y -= this._velocityY; //プレイヤーに合わせてy座標を移動(バウンス判定などで移動距離が短い場合、何もしない)
        }
        if (scroller.y + this.walkSpeed < scrollPY) { scroller.y += this.walkSpeed; return; } 
        else if (scroller.y - this.walkSpeed > scrollPY) { scroller.y -= this.walkSpeed; return; }
        else if (scroller.y + 1 < scrollPY) { scroller.y += 1; return; } //差がわずかの時は1だけ移動
        else if (scroller.y - 1 > scrollPY) { scroller.y -= 1; return; }
        else { scroller.y = scrollPY; return; }
    }

    update(gameInfo, input) {
        if(input.getKeyDown('Shift')) { this.isDash = !this.isDash; } //Shiftキーでダッシュ?歩行の切り替え

        //歩行カウントが一定以上に達した時、歩くモーションを変化
        if(this.walkCount % 128 > 0) { this.sprite.x = this.sprite.width; }
        if(this.walkCount % 128 > 32) { this.sprite.x = this.sprite.width*2; }
        if(this.walkCount % 128 > 64) { this.sprite.x = this.sprite.width; }
        if(this.walkCount % 128 > 96) { this.sprite.x = 0; }

        //立ち止まった時に直立姿勢
        if( !input.getKey('ArrowUp') && !input.getKey('ArrowDown') && !input.getKey('ArrowRight') && !input.getKey('ArrowLeft')) {
            this.sprite.x = this.sprite.width; this.isStand = true;
        }

        if(this.isActive) {//isActive = trueのときのみ、移動やアクションができる
            //矢印キーを押しただけの時に、プレーヤーの向きを変える。
            if(!input.getKey(' ')) {
                if(input.getKey('ArrowUp')) { this.dir = 90;} //上
                if(input.getKey('ArrowRight')) { this.dir = 0;} //右
                if(input.getKey('ArrowDown')) { this.dir = -90;} //下
                if(input.getKey('ArrowLeft')) { this.dir = 180; } //左
                if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this.dir = 45;} //右上
                if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this.dir = -45;} //右下
                if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this.dir = -135;} //左下
                if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this.dir = 135;} //左上
            }
            //進む方角の設定
            if( input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
                this.isStand = false;
                if(input.getKey('ArrowUp')) { this._dirGo = Math.PI*0.5;} //上 90*Math.PI/180
                if(input.getKey('ArrowRight')) { this._dirGo = 0;} //右 0*Math.PI/180
                if(input.getKey('ArrowDown')) { this._dirGo = - Math.PI*0.5;} //下 -90*Math.PI/180
                if(input.getKey('ArrowLeft')) { this._dirGo = Math.PI; } //左 180*Math.PI/180
                if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this._dirGo = Math.PI*0.25;} //右上 45 * Math.PI/180
                if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this._dirGo = - Math.PI*0.25;} //右下 -45 * Math.PI/180
                if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this._dirGo = -3 * Math.PI*0.25;} //左下 -135 * Math.PI/180
                if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this._dirGo = 3 * Math.PI*0.25;} //左上 135 * Math.PI/180
            }
            //矢印キーを押してる間、進む方角に移動させる
            if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
                this._velocityX += this.speed * Math.cos(this._dirGo);
                this._velocityY += -this.speed * Math.sin(this._dirGo);
            }

            //スペースキーでアクション発生
            if(input.getKeyDown(' ')) {
                const targetX = this.hitArea.cx + this.sprite.width * Math.cos(this.dirR);
                const targetY = this.hitArea.cy - this.sprite.height * Math.sin(this.dirR);
                this.spawnActor( new PlayerAction(this.hitArea.cx, this.hitArea.cy, targetX, targetY, this._dir ) );
            }
        }//if(isActive)

        //座標移動
        this.x += this._velocityX;
        this.y += this._velocityY;

        //歩数カウント
        this.vector = new Line( 0, 0, this._velocityX, this._velocityY ); //現在フレームの移動ベクトルを格納。
        this.walkCount += this.vector.length;

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

        //背景スクローラーの調整
        if (this.isScroll) { this.bgScrollX(gameInfo.scroller); this.bgScrollY(gameInfo.scroller); }

        if (this.isHit !== 0) { this.isHit -= 1; }//アップデートの最後にHit判定カウントを1つリセット
        this._velocityX = 0;//アップデートの最後にx軸の移動距離をリセット
        this._velocityY = 0;//アップデートの最後にy軸の移動距離をリセット
    }
}

では、constructor()内から順に見ていきます。

Playerクラス constructor()内の記述


    constructor(x, y, dir=90, isActive = true) {
        const sprite = new Sprite(assets.get('player'), 32, 96, 32, 32);
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea, ['player']);
        this.isActive = isActive; //今、キー入力を受け付けるかどうか
        this.isScroll = true; //今、画面スクロールをさせるかどうか
        this.isScrollCustom = true; //カスタムスクロールか?
        this._dir = dir; //最初の向き、指定漏れの場合、初期値は90度にしている。
        this._dirGo = 0; //進む方角...移動方向に使うので、deg表記の角度°にπ/180をかけた値を用いる。初期値は0だけどすぐ変わるだろう。
        this.walkSpeed = 2; //初期スピードは2
        this._timeCount = 0; // アクションの連続使用を制限するためのカウント
    }

このPlayerクラスはSpriteActorクラスからの拡張なので、SpriteActorの各要素を受け継いでます。

SpriteActorクラスより継承

  • this.sprite ... 最初から定義されてます。画像にアクセスするための記述
  • this._dir = -90; ... アクターの向き。移動に使う時はラジアンに変換する必要がありますが、基本はdeg表記で十分。
  • this._velocityX ... フレーム毎のX軸の移動距離、をスタックしておくための要素。衝突判定の処理にも役立ちます。
  • this._velocityY ... Y軸の移動距離も同様。
  • this.isHit = 0; ... Hit中かどうかの判定。0でfalseとして評価されます。値が数字なのは後ほど
  • this.isStand = true; ... このアクターの移動スピードを取得するときに使います。trueならspeedは0です
  • this.isDash ... ダッシュ中かどうか。たまに勢いよく移動しまくることもあっていいでしょう。
  • this.walkSpeed = 2; ... アクターの歩くスピード。プレイヤーの歩行と合わせて。
  • this.walkCount ... 移動距離のカウント。一定値動かすことでイベントを発生させてもいいんじゃない?

さらに、衝突時のバウンス判定まで受け継いでる(記載省略)


加えてPlayerクラス独自の(或いは上書きする)要素が、このconstructor内で記述されます。
        this.isActive = isActive; //今、キー入力を受け付けるかどうか
        this.isScroll = true; //今、画面スクロールをさせるかどうか
        this.isScrollCustom = true; //カスタムスクロールか?
        this._dir = dir; //最初の向きを指定値で上書き、指定漏れの場合、初期値は90度にしている。
        this._dirGo = 0; //進む方角...移動方向に使うので、deg表記の角度°にπ/180をかけた値を用いる。初期値は0だけどすぐ変わるだろう。
        this.walkSpeed = 2; //初期スピードは2
        this._timeCount = 0; // アクションの連続使用を制限するためのカウント

次にget/set dir()で、方向転換時に自動で画像を差し替えられる記述について。

方向転換時、自動で画像の向きを変える...get/set

    get dir() { return this._dir; }
    set dir(value) { this._dir = value; //プレイヤーの向きが変わった時、画像も差し替え
                     if (-45 <= value && value <= 45 || 315 <= value ) { this.sprite.y = this.sprite.height*2; return; } //画像右向き
                     else if (45 < value && value < 135) { this.sprite.y = this.sprite.height*3; return; } //画像上向き
                     else if (135 <= value && value <= 225 || value <= -135) { this.sprite.y = this.sprite.height; return; } //画像左向き
                     else if (-135 < value && value < -45 || 225 < value ) { this.sprite.y = 0; return; } //画像下向き
                   }

set関数を使うと、その要素に値が代入された時に、付随して何をするか?という命令を書けます。 今回プレイヤーの向きに応じて、対応する向きの画像が自動で差し替わるようなコードを取り入れてみました。 なかなかいい感じに動きます。以降向きを変えた時、一緒に画像まで指定しなおさなくてよくなる。

↓set dir()命令をおこすとき、指定時にthis.dir=90など、これまで使ってた_dir要素の_を抜かす。
            //矢印キーを押しただけの時に、プレーヤーの向きを変える。
            if(!input.getKey(' ')) {
                if(input.getKey('ArrowUp')) { this.dir = 90;} //上
                if(input.getKey('ArrowRight')) { this.dir = 0;} //右
                if(input.getKey('ArrowDown')) { this.dir = -90;} //下
                if(input.getKey('ArrowLeft')) { this.dir = 180; } //左
                if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this.dir = 45;} //右上
                if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this.dir = -45;} //右下
                if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this.dir = -135;} //左下
                if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this.dir = 135;} //左上
            }

次は、プレイヤーのupdate()内の処理の流れについて。

Player ⇒ update(gameInfo, input)の全体の流れ

毎フレーム毎に、各アクターのアップデートがどう行われるか流れを把握しなければ、狙った通りに動作しません。 hit判定時のバウンス、プレイヤーの移動、背景スクロールの処理。それらの動きが全て一貫して行われなければ、バグります。

特に、スクロール。。。お前。やばい。
そのための_velocityX、Y。
       this._velocityX = 0; //フレーム中のx軸の移動値
       this._velocityY = 0; //フレーム中のy軸の移動理

正直、これイラんくない?って思ってた前回ですが、ものっすごく重要であることに、3つ目の背景スクロールが加わったことで気付きました。

どう必要かというと、
  • 衝突判定(バウンス)
  • プレイヤー移動(this.speed)
  • スクロール(どんだけ動かすの??)

衝突時と移動時に、直にx,yの座標を動かして終わっちゃうと、どれだけ移動したか、スクロールをどれだけ移動させるかが解んなくなってしまうのです。
んで、その移動値をスタックさせるために_velocity...という要素に値を格納しておく。イメージとしてはこんな感じになります。

update()の構成

update(gameInfo, input) {

    //衝突時のバウンス判定(this._velocityX...this._velocityY)を受け取った状態でupdateスタート
    //スタックの中身を仮にこんな感じでイメージします。何処にもぶつかってないなら0だけど。
    //this._velocityX = x;
    //this._velocityY = y;

    //その後、キー入力を受け取って、矢印キーに応じて移動させる値を_velocityに加算
    if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
        this._velocityX += this.speed * Math.cos(this._dirGo);
        this._velocityY += -this.speed * Math.sin(this._dirGo);
    }

    //実際に移動させる
    this.x += this._velocityX;
    this.y += this._velocityY;

    //現在フレームの移動ベクトルを格納。
    this.vector = new Line( 0, 0, this._velocityX, this._velocityY ); //現在フレームの移動ベクトルを格納。
    //歩数カウント
    this.walkCount += this.vector.length;

    //枠外に出る場合、その手前側の座標とする。移動はキャンセル扱いなので、移動値を0として計算。
    if(this.x < 0) { this.x = 0; this._velocityX = 0;}
    if(this.y < 0) { this.y = 0; this._velocityY = 0;}
    if(this.x > gameInfo.sceneWidth - rect.width) { this.x = gameInfo.sceneWidth - rect.width; this._velocityX = 0;}
    if(this.y > gameInfo.sceneHeight - rect.height) { this.y = gameInfo.sceneHeight - rect.height; this._velocityY = 0;}

    //プレイヤーの移動値に応じて、スクローラーを調整
    if (this.isScroll) { this.bgScrollX(gameInfo.scroller); this.bgScrollY(gameInfo.scroller); }

    //最後に、各値をリセット
    if (this.isHit !== 0) { this.isHit -= 1; }//アップデートの最後にHit判定カウントを1つリセット
    this._velocityX = 0;//アップデートの最後にx軸の移動距離をリセット
    this._velocityY = 0;//アップデートの最後にy軸の移動距離をリセット
}

update全体の流れは、だいたいこんな感じです。
this._velocityXとthis._velocityYをアップデートの最後にリセットするのが肝。

というのも、全体のシーンを取りまとめてるアップデートの流れとして...

Sceneクラスのアップデート内

    update(gameInfo, input) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。
        this._updateAll(gameInfo, input);//Actorたちの動きを更新する
        this._hitTest();//当たり判定を処理する
        this._disposeDestroyedActors();//死んだ役者リスト
        this._clearScreen();//シーンの初期化、描画の前に一度画面全体をクリアする
        this._renderAll();//再描画
    }

  1. アクター達のupdate関数が実行され、実際に移動。その後ベロシティの値がリセット
  2. 次に当たり判定。。。つまり、衝突時のバウンス判定が起こり、ベロシティに移動値がスタックされる
  3. 死んだアクターが消えて、画面が描画される。くりかえし、アクターのアップデート.....


と続きます。アップデートの最初にベロシティを初期化するとバウンス判定が消えちゃうので、この構成の場合は最後にリセット。これで上手く動きます。

関数の実行順番を考えないと、プログラムの構成全体を見渡せないと上手に処理を流せない。いい例を体感しました。
自前のコードを把握しやすい点は、こういう時に活かされるかもしれない。

では、最後に背景のカスタムスクロールについて。

背景スクローラーとスクロール関数の記述

今回、背景スクローラーにカスタムスクロール要素が加わりました。
const scroller = { x: 0, y: 0, isCustom: true };

で、Playerクラスのメソッドに追加したカスタムスクロールの記述。
    bgScrollX(scroller) { //背景スクロールのX軸移動(基本はプレイヤーのupdate()内で使用)最後の調整はシーン側で行ってます
        let scrollPX
        if(!this.isScrollCustom) { scrollPX = - this.hitArea.cx + screenCanvasWidth*0.5; } //x座標のノーマルスクロール
        else{ scrollPX = -this.hitArea.cx + screenCanvasWidth*0.5 - 84 * Math.round(Math.cos(this.dirR)); }//カスタムスクローラー本来のx座標

        if ( this.isHit < 1 || Math.abs(this._velocityX) > 2) {
            scroller.x -= this._velocityX; //プレイヤーに合わせてx座標を移動(バウンス判定などで移動距離が短い場合、何もしない)
        }
        if (scroller.x + this.walkSpeed*1.25 < scrollPX) { scroller.x += this.walkSpeed*1.25; return; } //画面の縦横比でx軸移動の比を調整
        else if (scroller.x - this.walkSpeed*1.25 > scrollPX) { scroller.x -= this.walkSpeed*1.25; return; }
        else if (scroller.x + 1 < scrollPX) { scroller.x += 1; return; } //差がわずかの時は1だけ移動
        else if (scroller.x - 1 > scrollPX) { scroller.x -= 1; return; }
        else { scroller.x = scrollPX; return; }
    }

bgScrollY()もありますが、だいたい同じなのでX軸の方だけ記載(SpriteActorの所に全文あります)
これでどういう感じにスクロールするかというと...(だいぶ調製したので動きもスムーズなはず)

背景スクロール
⇒ カスタムスクロールのデモを見る
(Shiftキーでダッシュor歩行の切り替え、NPCちゃんを運べるように)


前回のスクロールと比べ、だいぶリアルな感じになってる気がします。よりプレイヤー目線に近い。
前回の方は、プレイヤーを上から見下ろす第三者の視点。これもこれでシーンを視聴する側として重宝するので、こちらをノーマルスクロールとして使い分けられる設定にしました。

とりあえず従来のスクロールは置いておいて、カスタムスクロールについて。
改めてメソッドを見てみます。全体の構成はこんな感じです。

カスタムスクロールの設計概念

  1. プレイヤーの向きに応じたスクローラーの到達点(座標)を定義
  2. プレイヤーの移動に合わせて、スクローラーも一緒に移動させる
  3. 現在のスクローラーの位置がずれている場合、ちょっとずつ到達点へ持っていく
  4. スクローラーが既に到達点にあるなら何もしない


おおまかに、こんな感じで、骨組みを作るところから。

1.スクローラーの到達点(座標)を定義

const scrollPX = -this.hitArea.cx + screenCanvasWidth*0.5 - 84 * Math.cos(this.dirR);

これはX軸のスクローラーの目標地点を定義したもの。
前回のスクロールより、まず画面幅の中央(-this.hitArea.cx + screenCanvasWidth/2)をベースに考える。

そこから向きに応じて... Math.cos(this.dirR)は右向きの時は「+1」...左向きの時は「-1」の値をとるから、
これで右を向いた時は-84×(+1)だけ...つまりX軸を中央左に84pxスライドした地点が定義される。
これで左を向いた時は-84×(-1)だけ...つまりX軸を中央右に84pxスライドした地点が定義される。

この定義が、スクローラーX軸の目標地点です。

2.プレイヤーの移動に合わせて、スクローラーも一緒に移動させる

    if ( this.isHit < 1 || Math.abs(this._velocityX) > 2) {
        scroller.x -= this._velocityX; //プレイヤーに合わせてx座標を移動(バウンス判定などで移動距離が短い場合、何もしない)
    }
調整前に、スクローラーをプレイヤーが移動した分だけ平行移動させます。
バウンス判定で移動距離が相殺される場合、移動が不安定になるので、この操作はスキップ。あまりガチガチにし過ぎると、バウンスするたびに画面が震えて見づらくなる...大変。スキップすれば未だ安全。

3.現在のスクロールがずれている場合、ちょっとずつ到達点へ持っていく

    if (scroller.x + this.walkSpeed*1.25 < scrollPX) { scroller.x += this.walkSpeed*1.25; return; } //画面の縦横比でx軸移動の比を調整
    else if (scroller.x - this.walkSpeed*1.25 > scrollPX) { scroller.x -= this.walkSpeed*1.25; return; }
    else if (scroller.x + 1 < scrollPX) { scroller.x += 1; return; } //差がわずかの時は1だけ移動
    else if (scroller.x - 1 > scrollPX) { scroller.x -= 1; return; }
    else { scroller.x = scrollPX; return; }
そこから、ちょっとずつ右にずらすのか?左にずらすのか? を、最後のif()で分けてる感じ。
あまり厳密に判定させると、スクロールが振動してよろしくないので、多少の誤差は放置。

方向転換のスクロールスピードは、 this.walkSpeed...歩行をベースにしてます。あまり早すぎると目が追いつかないから歩行くらいでいいかなって。
それと、判定の振れ幅が大きいと到達点で画面が震えて見づらくなる恐れもあります...大変。回避策として到達点付近は1ずつスライドさせてます。

y軸のスクローラーの定義に関して

const scrollPY = -this.hitArea.cy + screenCanvasHeight*0.5 + 63 * Math.sin(this.dirR);
一方でこちら、Y軸のスクローラーの目標地点を定義したもの。
前回のスクロールより、まず画面高さ中央(-this.hitArea.cy + screenCanvasHeight/2)をベースに考える。

そこから向きに応じて... Math.sin(this.dirR)は上向きの時は「+1」...下向きの時は「-1」の値をとるから、
これで上を向いた時は+63×(+1)だけ...つまりY軸を中央下に63pxスライドした地点が定義される。
これで下を向いた時は+63×(-1)だけ...つまりY軸を中央上に63pxスライドした地点が定義される。

後の計算は、だいたいScrollX()と同じです。

スクローラーの調整を終え、一通りのアクターの見直しはOKといったところでしょうか。。。
ここで定義したScrollX()、ScrollY()の関数を、それぞれプレイヤーのアップデート内で起動させて実装完了です。

スクロール実装のあとがき

背景スクロール、鬼門でした。ただ背景をスクロールさせるだけなら、問題ないのです。 しかし、カスタムスクロールのようにちょっと複雑なことしようとすると、様々な関連要素がでてきて大変なことになる。
今回の場合
  • プレイヤーの向き
  • バウンス判定時の挙動
  • 立ってる時か歩いてる時かダッシュ中か
  • 数値を反映させるタイミング
  • ifとreturnの使いドコロ

一個ずつ整理することになったのも、スクロールの調整が必要だったからなのでした。 バウンス判定をきちんと形状分けして、移動はVellocityにまとめて数値を反映させて、Hit判定時は減速させて何とかスクロールに無理のないよう対応。という感じ。

基本は従来のシンプルなスクロールのほうが、拡張しやすくは在るんかな〜とか。思ったりもする。


後書き忘れてたけど、スクローラーを移動させた後に、シーン側で画面端の最終調整をする記述を残したりもしてます。

シーンでのスクローラー画面端の調整

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

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

        if(scroller.x > 0) { scroller.x = 0; } //左右スクロール限界を設定
        else if(scroller.x < screenCanvasWidth - this.width) { scroller.x = screenCanvasWidth - this.width; }
        if(scroller.y > 0) { scroller.y = 0; } //上下スクロール限界を設定
        else if(scroller.y < screenCanvasHeight - this.height) { scroller.y = screenCanvasHeight - this.height; }
    }
アクター内でスクローラーを動かす式が複雑になってきたので、仕上げの画面調整はシーン側に役割を切り分けたほうがやりやすかったです。 なぜかアクター側で全部やろうとすると、ブレが起こったし...


あ、最後にシーンクラスの修正分を載せておきます。
(私の作るゲームのコンセプト上、destroyの文字列をreleaseに変換してたりするので...)

Sceneクラスの修正分


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

        this.name = name;
        this.width = width;
        this.height = height;
        this.sceneColor = sceneColor;
        this.actors = [];
        this._releasedActors = [];
        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();
    }

    add(actor) {//Actorたちを保持する(追加・削除)
        this.actors.push(actor);
        actor.addEventListener('spawnactor', (e) => this.add(e.target));//spawnactorイベントが発生した場合はシーンにActorを追加
        actor.addEventListener('release', (e) => this._releasedActors.push(e.target));//releaseイベントはそのActorを降板リストに追加
    }
    
    changeScene(newScene) {//Sceneを変更します。ですがScene自身がSceneを変更することはできないので、Actorのときと同じようにイベントだけ発生させて、実際の処理は他のクラスに任せます
        const event = new GameEvent(newScene);
        this.dispatchEvent('changescene', event);
    }

    update(gameInfo, input) {//updateメソッドではシーンに登録されているActor全てを更新し、当たり判定を行い、描画します。描画の前に一度画面全体をクリアするのを忘れないで下さい。
        gameInfo.sceneName = this.name; // 各アクターに渡すシーンの名前を定義する
        gameInfo.sceneColor = this.sceneColor; //シーンの特徴カラーを渡す
        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._disposeReleasedActors();//シーンから降ろす役者リスト
        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 = "black";
        context.fillRect(0, 0, screenCanvasWidth, screenCanvasHeight);
    }

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

    _disposeReleasedActors() {//役者を解放する
        this._releasedActors.forEach((actor) => this.remove(actor));//降板する役者をシーンから除外
        this._releasedActors = [];//降板する役者リストを空にする
    }
    remove(actor) {//Actorを消す関数
        const index = this.actors.indexOf(actor);
        this.actors.splice(index, 1);
    }


    _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

今回、見直しだから差分ファイル残さなくていいかな?って思って、ぶっ通しでやっちゃいました。

これもこれで、なかなか。見直しだから分かってる範囲だから問題少なかったけど。 やはり新しいことする場合、差分ファイル取っておいたほうが安心だし、思い切って色々試せるな!というのもありました。 これからゲーム作る人に向けて、参考になるかな?って思って描いてきた記事が、自分の開発記録になってる。 だから、色々試してダメでも元通りにしやすくて、思い切って前に進めてるのかな?って気もします。

これにて、背景スクロールおよび、アクター関連の記述は一度切り上げ。 長々とありがとうございました。


以降挑戦してみたいことは
  • スマホ、タッチ、マウス対応
  • シーンの切り替え
  • シーンのイベント挿入
  • 会話ウィンドウの選択肢
  • メニュー画面の実装
  • サウンド関連
  • セーブ関連

主にシーンとウィンドウ関連の処理になるでしょうか。 引き続き出来そうなところからやっていきたいと思います。
【目次】
  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でゲーム作り「10EX:2次曲線の当たり判定とバウンス処理」

すぺしゃるさんくす

https://sbfl.net/
古都さん
いつも勉強になってます。たぶん古都さんのフレームワークがなければ何もできなかった。


プレイヤーキャラ
ぴぽやさん、PlayerとNPCの素材を使わせて頂きました。ありがとうです!