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さんからヒントを頂きました。ありがとうございます。

記述する順番、処理する順番を考えて、全体的なコードの最適化を測る。
こんな感じで、Actor関連のクラスも見なおしていこうと思います。

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); } //タグは当たり判定などのときに使います
    get speed() {  return 0; } //現在の移動スピードを取得

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

    //自身を破壊する、しかし関数で自身を消すことはできないので、イベントだけ発生させてより上位のクラスに実際の処理を任せる
    destroy() { this.dispatchEvent('destroy', 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) {}//...線画処理
}
特に変わりはない。必要最低限の、本当に最低限のもの。
    get speed() {  return 0; } //現在の移動スピードを取得

手を加えたのも、移動スピードのgetメソッドを追加しただけ。。
というのも、hit判定時にアクターの総当りになるのですが、相手のスピードに応じた衝突時の処理も必要になるかな?と思って。基本0で記述を加えてます。

ホントは、全てのActorクラスに必要になるであろう要素をぜーーんぶ入れ込んでも良かったんですが、全く必要のない要素をnewするたびに羅列させるのもなぁ....たぶん、記述のムダを無くせる...というのが自分で描くことのメリット。ならば、ベースはシンプルが良い。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 = -90; //アクターの向き...deg表記で。
        this._velocityX = 0; //フレーム中のx軸の移動値
        this._velocityY = 0; //フレーム中のy軸の移動理
        this.isHit = 0; // hit中かどうか
        this.isStand = true; //動いていないかどうか
        this.isDash = false; // ダッシュ中かどうか
        this.speedWalk = 2; //歩くスピード
        this.walkCount = 0; // 移動距離のカウント

        this.addEventListener('hit', (e) => { //当たり判定発生時のバウンド判定
            const speed = this.speed + e.target.speed;
            const other = e.target.hitArea;
            if(!e.target.hasTag('playerAction') && !e.target.hasTag('event') && !e.target.hasTag('spirit') && !e.target.hasTag('element')) {
                if( this.isHit < 12 ) { this.isHit = 12; }
                if( other.type == 'rectangle') {
                    const dx = other.cx - this.hitArea.cx; //正の値ならこのアクターが左側
                    const dy = other.cy - this.hitArea.cy; //正の値ならこのアクターが上側
                    if (dx > 0 && this.hitArea.right - other.left < this.hitArea.width/2 ) {this._velocityX -= speed;}
                    if (dx < 0 && -this.hitArea.left + other.right < this.hitArea.width/2) {this._velocityX += speed;}
                    if (dy > 0 && this.hitArea.bottom - other.top < this.hitArea.height/2) {this._velocityY -= speed;}
                    if (dy < 0 && -this.hitArea.top + other.bottom < this.hitArea.height/2){this._velocityY += speed;}
                }
                if( other.type == 'circle') {  //円形とのバウンド判定。
                    const dx = this.hitArea.cx - other.cx; //相手中心点のx座標から、自分の中心点のx座標に向かってx軸バウンド
                    const dy = this.hitArea.cy - other.cy; //相手中心点のy座標から、自分の中心点のx座標に向かってy軸バウンド
                    const distance = Line.distance(dx, dy); //2点間の距離
                    this._velocityX += speed * dx / distance;
                    this._velocityY += speed * dy / distance;
                }
                if( other.type=='line') { //壁、線分とのバウンド判定
                    const lineAB = e.target.hitArea; //ライン壁を定義
                    const lineAP = new Line (lineAB.x, lineAB.y, this.hitArea.cx, this.hitArea.cy); //ラインの始点Aから自分の中心位置Pまでの線分
                    const innerAX = lineAB.innerP(lineAP) / (lineAB.length); //線分上の始点Aから、衝突点Xまでの距離を測る=AXの長さ
                    const pointX = {x:lineAB.x + lineAB.dx * innerAX/lineAB.length, y:lineAB.y + lineAB.dy * innerAX/lineAB.length}; //衝突点Xを求める。始点Aから線分ABの単位ベクトルとAXの長さ(innerAX)を掛けあわせた分だけ、xとyを移動した座標。
                    const lineXP = new Line (pointX.x, pointX.y, this.hitArea.cx, this.hitArea.cy); //衝突点Xから中心Pまでのベクトルを引く。(この矢印の向きが、押し戻す方向となる)
                    if(e.target.isFall === false) { //ラインが一方通行かどうかで、ベクトルを絶対値で取るかどうか分ける
                        this._velocityX = (this.speedWalk+e.target.speed) * lineXP.dx/lineXP.length;
                        this._velocityY = (this.speedWalk+e.target.speed) * lineXP.dy/lineXP.length;
                    } else {
                        this._velocityX = Math.abs((this.speedWalk+e.target.speed) * lineXP.dx/lineXP.length);
                        this._velocityY = Math.abs((this.speedWalk+e.target.speed) * lineXP.dy/lineXP.length);
                    }
                }
            }
        });
    }

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

    bgScrollX(scroller) { //背景スクロールのX軸移動(基本はプレイヤーのupdate()内で使用)最後の調整はシーン側で行ってます
        if(!scroller.isCustom) { scroller.x = - this.hitArea.cx + screenCanvasWidth/2; return;} //x座標のノーマルスクロール
        else{
            const scrollPX = -this.hitArea.cx + screenCanvasWidth/2 - 84 * Math.cos(this.dirR); //カスタムスクローラー本来のx座標
            if ( this.isHit < 1 || Math.abs(this._velocityX) > 2) {
                scroller.x -= this._velocityX; //プレイヤーに合わせてx座標を移動(バウンド判定などで移動距離が短い場合、何もしない)
            }
            if (scroller.x + this.speedWalk*4/3 < scrollPX) { scroller.x += this.speedWalk*4/3; return; } //画面の縦横比でx軸移動の比を調整
            else if (scroller.x - this.speedWalk*4/3 > scrollPX) { scroller.x -= this.speedWalk*4/3; 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()内で使用)最後の調整はシーン側で行ってます
        if(!scroller.isCustom) { scroller.y = - this.hitArea.cy + screenCanvasHeight/2; return;} //y座標のノーマルスクロール
        else{
            const scrollPY = -this.hitArea.cy + screenCanvasHeight/2 + 63 * Math.sin(this.dirR); //カスタムスクローラー本来のy座標
            if ( this.isHit < 1 || Math.abs(this._velocityY) > 2 ) {
                scroller.y -= this._velocityY; //プレイヤーに合わせてy座標を移動(バウンド判定などで移動距離が短い場合、何もしない)
            }
            if (scroller.y + this.speedWalk < scrollPY) { scroller.y += this.speedWalk; return; } 
            else if (scroller.y - this.speedWalk > scrollPY) { scroller.y -= this.speedWalk; 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; }
        }
    }

    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/2, this.sprite.height/5, 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.speedWalk = 2; //歩くスピード
        this.walkCount = 0; // 移動距離のカウント

//以下続く

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

わりと必要最低限かなって思う。それでも長いよね。
合わせて、get()関連の要素も確認しておきます。
    get speed() {
        if (this.isStand) { return 0; } //直立姿勢の時は0
        else if(this.isHit === 0) { return (this.speedWalk + this.isDash * 4); } //移動スピードを取得。ダッシュ中はベース値に+4、
        else {return this.speedWalk;} //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 * Math.PI/180; } //現在の向きをラジアンの角度で取得

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

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


まぁいいや、次にconstructor()内でthis.addEventListenerで登録した、このアクターの衝突判定時の関数について触れます。

Hit判定時のバウンド(反動ベクトル)を求める式について


        this.addEventListener('hit', (e) => { //当たり判定発生時のバウンド判定
            const speed = this.speed + e.target.speed;
            const other = e.target.hitArea;
            if(!e.target.hasTag('playerAction') && !e.target.hasTag('event') && !e.target.hasTag('spirit') && !e.target.hasTag('element')) {
                if( this.isHit < 12 ) { this.isHit = 12; }
                if( other.type == 'rectangle') {
                    const dx = other.cx - this.hitArea.cx; //お互いの中心座標(x軸)で位置関係を把握。正の値ならこのアクターが左側
                    const dy = other.cy - this.hitArea.cy; //お互いの中心座標(y軸)で位置関係を把握。正の値ならこのアクターが上側
                    if (dx > 0 && this.hitArea.right - other.left < this.hitArea.width/2 ) {this._velocityX -= speed;}
                    if (dx < 0 && -this.hitArea.left + other.right < this.hitArea.width/2) {this._velocityX += speed;}
                    if (dy > 0 && this.hitArea.bottom - other.top < this.hitArea.height/2) {this._velocityY -= speed;}
                    if (dy < 0 && -this.hitArea.top + other.bottom < this.hitArea.height/2){this._velocityY += speed;}
                }
                if( other.type == 'circle') {  //円形とのバウンド判定。
                    const dx = this.hitArea.cx - other.cx; //相手中心点のx座標から、自分の中心点のx座標に向かってx軸バウンド
                    const dy = this.hitArea.cy - other.cy; //相手中心点のy座標から、自分の中心点のx座標に向かってy軸バウンド
                    const distance = Line.distance(dx, dy); //2点間の距離
                    this._velocityX += speed * dx / distance;
                    this._velocityY += speed * dy / distance;
                }
                if( other.type=='line') { //壁、線分とのバウンド判定
                    const lineAB = e.target.hitArea; //ライン壁を定義
                    const lineAP = new Line (lineAB.x, lineAB.y, this.hitArea.cx, this.hitArea.cy); //ラインの始点Aから自分の中心位置Pまでの線分
                    const innerAX = lineAB.innerP(lineAP) / (lineAB.length); //線分上の始点Aから、衝突点Xまでの距離を測る=AXの長さ
                    const pointX = {x:lineAB.x + lineAB.dx * innerAX/lineAB.length, y:lineAB.y + lineAB.dy * innerAX/lineAB.length}; //衝突点Xを求める。始点Aから線分ABの単位ベクトルとAXの長さ(innerAX)を掛けあわせた分だけ、xとyを移動した座標。
                    const lineXP = new Line (pointX.x, pointX.y, this.hitArea.cx, this.hitArea.cy); //衝突点Xから中心Pまでのベクトルを引く。(この矢印の向きが、押し戻す方向となる)
                    if(e.target.isFall === false) { //ラインが一方通行かどうかで、ベクトルを絶対値で取るかどうか分ける
                        this._velocityX = (this.speedWalk+e.target.speed) * lineXP.dx/lineXP.length;
                        this._velocityY = (this.speedWalk+e.target.speed) * lineXP.dy/lineXP.length;
                    } else {
                        this._velocityX = Math.abs((this.speedWalk+e.target.speed) * lineXP.dx/lineXP.length);
                        this._velocityY = Math.abs((this.speedWalk+e.target.speed) * lineXP.dy/lineXP.length);
                    }
                }
            }
        });

。。。このHit時のバウンド判定(どちら側にどれだけ移動させるか)が鬼門ですね(' '*);;;
ぶつかった時の反動ベクトルを計算するだけなのですが。。。相手アクターの形状とか条件で振り分ける必要があるので。 めちゃくちゃ長くなったのは、いろんな条件を加味して振り分けすぎたからなのか、私の腕がド素人だからか。

全体の手順を大まかに記します

  • ぶつかった時のお互いのスピードを確認(もしかしたら"ベクトル同士の差"で判定スべきか??)
  • すり抜けていいアクターのタグを確認して、当てはまったら何もしない(ただのイベント判定とか)
  • それ以外ならぶつかるのでisHitに加算(このアクターを減速させるため)
  • 各形状に振り分け。反動ベクトルの式をそれぞれ記し、バウンド。
    • 相手が矩形の場合
    • 相手が円形の場合
    • 相手が線分(壁)の場合

では、形状の式を1つずつ確認していきたいと思います。

相手が矩形の場合のバウンド判定

                if( other.type == 'rectangle') {
                    const dx = other.cx - this.hitArea.cx; //お互いの中心座標(x軸)で位置関係を把握。正の値ならこのアクターが左側
                    const dy = other.cy - this.hitArea.cy; //お互いの中心座標(y軸)で位置関係を把握。正の値ならこのアクターが上側
                    if (dx > 0 && this.hitArea.right - other.left < this.hitArea.width/2 ) {this._velocityX -= speed;}
                    if (dx < 0 && -this.hitArea.left + other.right < this.hitArea.width/2) {this._velocityX += speed;}
                    if (dy > 0 && this.hitArea.bottom - other.top < this.hitArea.height/2) {this._velocityY -= speed;}
                    if (dy < 0 && -this.hitArea.top + other.bottom < this.hitArea.height/2){this._velocityY += speed;}
                }

相手が矩形の場合、最初にこの2行でお互いの位置関係を確認できるようにします。
    const dx = other.cx - this.hitArea.cx; //お互いの中心座標(x軸)で位置関係を把握。正の値ならこのアクターが左側
    const dy = other.cy - this.hitArea.cy; //お互いの中心座標(y軸)で位置関係を把握。正の値ならこのアクターが上側

dx > 0 なら、このアクターが左。相手が右。
dx < 0 なら、このアクターが右。相手が左。

dyの方も同じ感覚で、上下左右の位置関係が何となく分かります。
んで、もうhit判定はtrueで帰ってきてる。どれかの辺が接してるのは確実。

    if (dx > 0 && this.hitArea.right - other.left < this.hitArea.width/2 ) {this._velocityX -= speed;}
    if (dx < 0 && -this.hitArea.left + other.right < this.hitArea.width/2) {this._velocityX += speed;}

まずX軸の判定ですが、(dx > 0?)お互いの位置関係によって式が変わっている感じです。図にすると分かりやすいけど。イメージで。 if()内の条件分岐の意味する所は、横の重なり。重なりが大きい場合は位相がずれてる(横の衝突じゃない)と判断して何もしません。

小さい場合に、そこが接点であると判定してバウンドさせます。
バウンドさせるかどうかの重なり加減は好みです。私は(this.hitArea.width/2)このアクター幅の半分なら、という条件でバウンドさせるようにしました。


    if (dy > 0 && this.hitArea.bottom - other.top < this.hitArea.height/2) {this._velocityY -= speed;}
    if (dy < 0 && -this.hitArea.top + other.bottom < this.hitArea.height/2){this._velocityY += speed;}

こちらは縦の重なりを見て、大きければ位相がずれてる(縦の衝突じゃない)と判断。
小さい時に、バウンド判定させます。加減はthis.hitArea.height/2(このアクターの高さ半分)です。

相手が円形の場合のバウンド判定

    if( other.type == 'circle') {  //円形とのバウンド判定。
        const dx = this.hitArea.cx - other.cx; //相手中心点のx座標から、自分の中心点のx座標に向かってx軸バウンド
        const dy = this.hitArea.cy - other.cy; //相手中心点のy座標から、自分の中心点のx座標に向かってy軸バウンド
        const distance = Line.distance(dx, dy); //2点間の距離
        this._velocityX += speed * dx / distance;
        this._velocityY += speed * dy / distance;
    }

相手が円の場合は簡単です。相手の中心点と、自分の中心点との位置関係から、どの方向にバウンドさせるか?のベクトルが分かります。 あとは、単位ベクトル(求めたベクトルをその距離で割る)にスピードを掛けた分だけx軸の移動とy軸の移動をさせればいいです。

相手が線分(壁)の場合のバウンド判定

    if( other.type=='line') { //壁、線分とのバウンド判定
        const lineAB = e.target.hitArea; //ライン壁を定義
        const lineAP = new Line (lineAB.x, lineAB.y, this.hitArea.cx, this.hitArea.cy); //ラインの始点Aから自分の中心位置Pまでの線分
        const innerAX = lineAB.innerP(lineAP) / (lineAB.length); //線分上の始点Aから、衝突点Xまでの距離を測る=AXの長さ
        const pointX = {x:lineAB.x + lineAB.dx * innerAX/lineAB.length, y:lineAB.y + lineAB.dy * innerAX/lineAB.length}; //衝突点Xを求める。始点Aから線分ABの単位ベクトルとAXの長さ(innerAX)を掛けあわせた分だけ、xとyを移動した座標。
        const lineXP = new Line (pointX.x, pointX.y, this.hitArea.cx, this.hitArea.cy); //衝突点Xから中心Pまでのベクトルを引く。(この矢印の向きが、押し戻す方向となる)
        if(e.target.isFall === false) { //ラインが一方通行かどうかで、ベクトルを絶対値で取るかどうか分ける
            this._velocityX = (this.speedWalk + e.target.speed) * lineXP.dx/lineXP.length;
            this._velocityY = (this.speedWalk + e.target.speed) * lineXP.dy/lineXP.length;
        } else {
            this._velocityX = Math.abs((this.speedWalk + e.target.speed) * lineXP.dx/lineXP.length);
            this._velocityY = Math.abs((this.speedWalk + e.target.speed) * lineXP.dy/lineXP.length);
        }
    }

この式の解説は、線分の衝突判定のページに載せてます。 自分を円に見立てて、ラインと衝突した時にどちら向きにバウンドさせるか?のベクトルを求める式です。

詳しく ⇒ JavaScriptでゲーム作り「6:線分の当たり判定を実装する」


やや他の形状と異なるのは、this._velocityX、this._velocityYの値をそのまま上書きしてること。上書きするので、Line形状は一番最後の記述にしてます。 他に衝突中のバウンド判定が在っても、壁の判定を優先させるためです。のめり込みを防ぐ。

また、Hit判定時にスピードを減速させる(初期値にする)記述にしてるので、バウンド判定を0より大きい値で入れられる、ちょっと狡いことしてます。後でひびくかな〜。これ。。。
            this._velocityX = (this.speedWalk + e.target.speed) * lineXP.dx/lineXP.length;
            this._velocityY = (this.speedWalk + e.target.speed) * lineXP.dy/lineXP.length;

なおバウンド判定で移動させるとき、座標(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/2, this.sprite.height/5, 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/2, this.sprite.height/5, 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.speedWalk = 2;
        this._timeCount = 0;
    }

    get speed() {
        if (this.isStand) { return 0; } //直立姿勢の時は0
        else if(this.isHit === 0) { return (this.speedWalk + this.isDash * 4); } //移動スピードを取得。ダッシュ中はベース値に+4、
        else {return this.speedWalk;} //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; } //画像下向き
                   }

    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/2;} //上 90*Math.PI/180
                if(input.getKey('ArrowRight')) { this._dirGo = 0;} //右 0*Math.PI/180
                if(input.getKey('ArrowDown')) { this._dirGo = - Math.PI/2;} //下 -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/4;} //右上 45 * Math.PI/180
                if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this._dirGo = - Math.PI/4;} //右下 -45 * Math.PI/180
                if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this._dirGo = -3 * Math.PI/4;} //左下 -135 * Math.PI/180
                if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this._dirGo = 3 * Math.PI/4;} //左上 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;

        //ここから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;}

        //歩数カウント
        this.walkCount += Math.sqrt(this._velocityX*this._velocityX + this._velocityY*this._velocityY);

        //背景スクローラーの調整
        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.speedWalk = 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.isDash ... ダッシュ中かどうか。たまに勢いよく移動しまくることもあっていいでしょう。
  • this.speedWalk = 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.speedWalk = 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;

    //枠外に出る場合、その手前側の座標とする。移動はキャンセル扱いなので、移動値を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 };

で、SpriteActor内のメソッドに追加したカスタムスクロールの記述。(実際に動かすのはプレイヤー)
bgScrollX() { 
    const scrollPX = -this.hitArea.cx + screenCanvasWidth/2 - 84 * Math.cos(this.dirR); //カスタムスクローラー本来のx座標
    if ( this.isHit < 1 || Math.abs(this._velocityX) > 2) {
        scroller.x -= this._velocityX; //プレイヤーに合わせてx座標を移動(バウンド判定などで移動距離が短い場合、何もしない)
    }
    if (scroller.x + this.speedWalk*4/3 < scrollPX) { scroller.x += this.speedWalk*4/3; return; } //画面の縦横比でx軸移動の比を調整
    else if (scroller.x - this.speedWalk*4/3 > scrollPX) { scroller.x -= this.speedWalk*4/3; 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/2 - 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.speedWalk*4/3 < scrollPX) { scroller.x += this.speedWalk*4/3; return; } //画面の縦横比でx軸移動の比を調整
    else if (scroller.x - this.speedWalk*4/3 > scrollPX) { scroller.x -= this.speedWalk*4/3; 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.speedWalk...歩行をベースにしてます。あまり早すぎると目が追いつかないから歩行くらいでいいかなって。
それと、判定の振れ幅が大きいと到達点で画面が震えて見づらくなる恐れもあります...大変。回避策として到達点付近は1ずつスライドさせてます。

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

const scrollPY = -this.hitArea.cy + screenCanvasHeight/2 + 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; }
    }
アクター内でスクローラーを動かす式が複雑になってきたので、最後の画面調整はシーン側に役割を切り分けたほうがやりやすかったです。 なぜかアクター側で全部やろうとすると、ブレが起こったし...


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

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

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


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

主にシーンとウィンドウ関連の処理になるでしょうか。 引き続き出来そうなところからやっていきたいと思います。
【目次】
  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:タッチ・マウスイベント入力」

すぺしゃるさんくす

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


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