JavaScriptでゲーム作り「3:プレイヤーの向きに応じてアクションを起こす」

NPCと会話する記述に挑戦したい!

(2019.1.19執筆)

無事プレイヤーキャラを歩かせることができました、今回はNPCとの会話に挑戦する前提で、プレイヤーの動作を調整してみようと思います。
内容的にも、古都さんのシューティングゲームの記事その2が活用できそうです。

参考 ⇒ JavaScriptで弾幕シューティングゲームをフルスクラッチで作ってみよう、その2


この記事を置き換えると、プレーヤーの弾がNPCに働きかけるアクション ⇒ NPCがアクションに触れたとき、会話イベントを発生させる。という発想に応用できそうですね。

前準備として、この記事ではプレイヤーが目の前の人物(オブジェクト)にアクションを働きかける流れを描いていきたいと思います。

danmaku.js 全体 ⇒ その2のカスタマイズ差分を合わせた記述

'use strict';

class TextLabel extends Actor {
    constructor(x, y, text, size) {
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);        
        this.text = text;
        this.size = size;
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = 'white';
        context.fillText(this.text, this.x, this.y);
    }
}


class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 32, 32, 32));
        const hitArea = new Rectangle(8, 8, 2, 2);
        super(x, y, sprite, hitArea);
        
        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
    }
    
    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        const rect = this.sprite.rectangle;

        //矢印キーを押した時に、プレーヤーの向きを変える。
        if(input.getKey('ArrowUp')) { rect.y = 96; }
        if(input.getKey('ArrowDown')) { rect.y = 0; }
        if(input.getKey('ArrowRight')) { rect.y = 64; }
        if(input.getKey('ArrowLeft')) { rect.y = 32; }

        //キーを押した方向に移動させる
        if(input.getKey('ArrowUp')) { this._velocityY = -this._speed; }
        if(input.getKey('ArrowRight')) { this._velocityX = this._speed; }
        if(input.getKey('ArrowDown')) { this._velocityY = this._speed; }
        if(input.getKey('ArrowLeft')) { this._velocityX = -this._speed; }
        
        this.x += this._velocityX;
        this.y += this._velocityY;

        //歩行カウントをすすめる
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {this.walkCount += this._speed;}

        //歩行カウントが一定以上に達した時、歩くモーションを変化
        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;}

        const boundWidth = gameInfo.screenRectangle.width - this.width;
        const boundHeight = gameInfo.screenRectangle.height - this.height;
        const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
        
        if(this.isOutOfBounds(bound)) {
            this.x -= this._velocityX;
            this.y -= this._velocityY;
        }
        
        this._timeCount++;
        const isFireReady = this._timeCount > this._interval;
        if(isFireReady && input.getKey(' ')) {
            const bullet = new Bullet(this.x, this.y);
            this.spawnActor(bullet);
            this._timeCount = 0;
        }

    }
}

class Bullet extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
        const hitArea = new Rectangle(4, 0, 8, 16);
        super(x, y, sprite, hitArea, ['playerBullet']);

        this.speed = 6;

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('enemy')) { this.destroy(); } 
        });
    }

    update(gameInfo, input) {
        this.y -= this.speed;
        if(this.isOutOfBounds(gameInfo.screenRectangle)) {
            this.destroy();
        }
    }
}

class Enemy extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 0, 16, 16));
        const hitArea = new Rectangle(0, 0, 16, 16);
        super(x, y, sprite, hitArea, ['enemy']);

        this.maxHp = 50;
        this.currentHp = this.maxHp;

        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerBullet')) {
               this.currentHp--;
               this.dispatchEvent('changehp', new GameEvent(this));
           }
        });
    }

    update(gameInfo, input) {
        
        if(this.currentHp <= 0) {
            this.destroy();
        }
    }
}


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

        this._width = 200;
        this._height = 10;
        
        this._innerWidth = this._width;

        
        enemy.addEventListener('changehp', (e) => {
            const maxHp = e.target.maxHp;
            const hp = e.target.currentHp;
            this._innerWidth = this._width * (hp / maxHp);
        });
    }

    render(target) {
        const context = target.getContext('2d');
        context.strokeStyle = 'white';
        context.fillStyle = 'white';
        
        context.strokeRect(this.x, this.y, this._width, this._height);
        context.fillRect(this.x, this.y, this._innerWidth, this._height);
    }
}


class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const enemy = new Enemy(150, 100);
        const hpBar = new EnemyHpBar(50, 20, enemy);
        this.add(new Player(150, 200) );
        this.add(enemy);
        this.add(hpBar);
    }
}

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

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

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

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

ここまでのデモを開く

前回までの記述をベースに、古都さんのシューティングゲーム記事その2のコード(途中まで)を組み合わせた上記のプログラム。。を元に、調整を始めます。

なおFighterクラスは、今回よりPlayerクラスに命名変更し、 前回調製した同クラス内のthis.sprite.rectangleの記述は「const rect =」で表記を簡略化してます。


加えてdanmaku.jsの最後の方に記述されている、danmakugameクラスでは、画面サイズを512×384(ピクセル)に設定⇒super('弾幕STG', 512, 384, 60);で表記した箇所...など、動作に影響ない数ヵ所を、自分の分かりやすい形に修正しました。

なぜか画面の幅を512pxより大きくすると、例えば650px↑とかにすると動作がつまずくんですよねぇ。 なんでかな。2進数、での計算限度を越えるんやろうか??? まぁイイや、512×384画面サイズで調整していきましょう。PC表示に合わせて幅4:高3の比率です。

プレイヤーの向く方にアクションを起こす

デモを開いてアクションを起こしてみると、上方向にしか行きません。 プレイヤーの向きが変わっても、アクションを働きかける方向が変わらないのを、まず何とかしてみましょう

プレイヤーclass ⇒ dir「方向」の要素を付け加える


        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 0;


this._dir = 0; というのを、プレーヤーの向きを現す数字として組み込んでみました。 上下左右斜め8方向に対応させる設計で、歩行をカスタマイズしていきます。

    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        const rect = this.sprite.rectangle;

        //矢印キーを押した時に、プレーヤーの向きを変える。
        if(input.getKey('ArrowUp')) { rect.y = 96; this._dir = 0;} //上
        if(input.getKey('ArrowRight')) { rect.y = 64; this._dir = 2;} //右
        if(input.getKey('ArrowDown')) { rect.y = 0; this._dir = 4;} //下
        if(input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 6; } //左
        if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 1;} //右上
        if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 3;} //右下
        if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 5;} //左下
        if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 7;} //左上

        //矢印キーを押してる間、歩行カウントをすすめて、向きの方角に移動する距離を求める
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this._speed;
                const sqrt_spead = this._speed / Math.sqrt(2); //斜め方向は、xとyの移動距離を2の平方根で割る。
            switch (this._dir) {
                case 0 : this._velocityY = -this._speed; break; //上方向へ this._dir = 0;のとき
                case 1 : this._velocityY = -sqrt_spead; this._velocityX = sqrt_spead; break; //右上方向へ
                case 2 : this._velocityX = this._speed; break; //右方向へ this._dir = 2;のとき
                case 3 : this._velocityY = sqrt_spead; this._velocityX = sqrt_spead; break; //右下方向へ
                case 4 : this._velocityY = this._speed; break; //下方向へ this._dir = 4;のとき
                case 5 : this._velocityY = sqrt_spead; this._velocityX = -sqrt_spead; break; //左下方向へ
                case 6 : this._velocityX = -this._speed; break; //左方向へ this._dir = 6;のとき
                case 7 : this._velocityY = -sqrt_spead; this._velocityX = -sqrt_spead; break; //左上方向へ
                default: break;
            }
        }

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

このように、歩く挙動を調整しました。

矢印キーに合わせて「this._dir=〜(プレイヤーの向き)」を設定し、何らかの矢印キーが押されている間、向きの方角にthis._spead分だけ移動するという仕組みです。 switch(プレーヤーの向き)の記述によって、移動する方角を振り分けています。

このように、プレイヤーの向き(this._dir)の値を活用することで、向きに合わせた動作を組み込めるようになりました。

(それと話は逸れますが、斜め移動で若干スピードを早く感じてたのも、斜めのベクトル距離を平方根...スピードをMath.sqrt(2)で割ったら、一定スピードになってくれました)


↓ では続いて、アクションを発生させる方向も考えてみましょう。

プレイヤーclass ⇒ if(isFireReady && input.getKey(' '))


        this._timeCount++;
        const isFireReady = this._timeCount > this._interval;
        if(isFireReady && input.getKey(' ')) {
            const bullet = new Bullet(this.x, this.y);
            this.spawnActor(bullet);
            this._timeCount = 0;
        }

初期のプレーヤーアクションの記述、3段目の部分... const bullet = new Bullet(this.x, this.y);の部分に、this._dir要素も組み込めるよう追記。
            const bullet = new Bullet(this.x, this.y, this._dir);

一方で、class Bulletのクラス設定側でも、this._dir要素を受け取れるよう記述を加えます。

class Bullet extends SpriteActor


class Bullet extends SpriteActor {
    constructor(x, y, dir) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
        const hitArea = new Rectangle(4, 0, 8, 16);
        super(x, y, sprite, hitArea, ['playerBullet']);

        this._speed = 6;
        this._dir = dir;

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('enemy')) { this.destroy(); } 
        });
    }

    update(gameInfo, input) {
        this.y -= this.speed;
        if(this.isOutOfBounds(gameInfo.screenRectangle)) {
            this.destroy();
        }
    }
}

Bullet の constructor(x, y)要素に dirを追加 ⇒ constructor(x, y, dir) そしてdirを参照するための this._dir = dir; を追記しました。

後は、Bulletの進む方向が this._dir の値によって変化する(switchする)ようupdate部分を調整してみます。

class Bullet ⇒ update(gameInfo, input)


    update(gameInfo, input) {
        const sqrt_spead = this._speed / Math.sqrt(2);
        switch (this._dir) {
            case 0 : this.y -= this._speed; break; //上方向
            case 1 : this.y -= sqrt_spead; this.x += sqrt_spead break; //右上方向
            case 2 : this.x += this._speed; break; //右方向
            case 3 : this.y += sqrt_spead; this.x += sqrt_spead break; //右下方向
            case 4 : this.y += this._speed; break; //下方向
            case 5 : this.y += sqrt_spead; this.x -= sqrt_spead break; //左下方向
            case 6 : this.x -= this._speed; break; //左方向
            case 7 : this.y -= sqrt_spead; this.x -= sqrt_spead break; //左上方向
        }

        if(this.isOutOfBounds(gameInfo.screenRectangle)) {
            this.destroy();
        }
    }

ここまでのデモを開く


なんと、プレイヤーアクションが上下左右斜め8方向に反映されるようになりました。素晴らしい! ここまでは想定通りの動きです。

しかし斜め打ちで固定が難しいですね。
スペースキー(' ')を押してる間はプレイヤーの向きが変わらない設定にしますか。。


プレイヤーclass ⇒ update(gameInfo, input) 内の修正


        //矢印キーを押しただけの時(スペースが押されてない!時)に、プレーヤーの向きを変える。
        if(!input.getKey(' ')) {
            if(input.getKey('ArrowUp')) { rect.y = 96; this._dir = 0;} //上
            if(input.getKey('ArrowRight')) { rect.y = 64; this._dir = 2;} //右
            if(input.getKey('ArrowDown')) { rect.y = 0; this._dir = 4;} //下
            if(input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 6; } //左

            if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 1;} //右上
            if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 3;} //右下
            if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 5;} //左下
            if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 7;} //左上
        }

ここまでのデモを開く


よし向きが固定された。・・
そしてw(゚ロ゚)w 移動にバグが起こった。

しまった。。。
プレイヤー移動の記述に問題があるようです。矢印キーにかかわらず、向きの方向に進むのが固定されるからね。。 ....だんだん要素が増えてくるけど仕方ない。進む方向の要素も付け加えて修正します。。

プレイヤーclass ⇒ update(gameInfo, input) 内のさらなる修正


class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 96, 32, 32));
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea);

        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 0; //プレイヤーの向き
        this._dirGo = 0; //進む方角
    }
    
    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        const rect = this.sprite.rectangle;
        const sqrt_spead = this._speed / Math.sqrt(2); //斜め移動は、xとyの移動距離を2の平方根で割る。

        //矢印キーを押しただけの時に、プレーヤーの向きを変える。
        if(!input.getKey(' ')) {
            if(input.getKey('ArrowUp')) { rect.y = 96; this._dir = 0;} //上
            if(input.getKey('ArrowRight')) { rect.y = 64; this._dir = 2;} //右
            if(input.getKey('ArrowDown')) { rect.y = 0; this._dir = 4;} //下
            if(input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 6; } //左
            if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 1;} //右上
            if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { rect.y = 64; this._dir = 3;} //右下
            if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 5;} //左下
            if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { rect.y = 32; this._dir = 7;} //左上
        }

        //進む方角の設定
        if(input.getKey('ArrowUp')) { this._dirGo = 0;} //上
        if(input.getKey('ArrowRight')) { this._dirGo = 2;} //右
        if(input.getKey('ArrowDown')) { this._dirGo = 4;} //下
        if(input.getKey('ArrowLeft')) { this._dirGo = 6; } //左
        if(input.getKey('ArrowUp') && input.getKey('ArrowRight')) { this._dirGo = 1;} //右上
        if(input.getKey('ArrowDown') && input.getKey('ArrowRight')) { this._dirGo = 3;} //右下
        if(input.getKey('ArrowDown') && input.getKey('ArrowLeft')) { this._dirGo = 5;} //左下
        if(input.getKey('ArrowUp') && input.getKey('ArrowLeft')) { this._dirGo = 7;} //左上

        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に応じた距離を計算
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {
            this.walkCount += this._speed;
            switch (this._dirGo) {
                case 0 : this._velocityY = -this._speed; break; //上方向へ this._dir = 0;のとき
                case 1 : this._velocityY = -sqrt_spead; this._velocityX = sqrt_spead; break; //右上方向へ=1
                case 2 : this._velocityX = this._speed; break; //右方向へ this._dir = 2;のとき
                case 3 : this._velocityY = sqrt_spead; this._velocityX = sqrt_spead; break; //右下方向へ=3
                case 4 : this._velocityY = this._speed; break; //下方向へ this._dir = 4;のとき
                case 5 : this._velocityY = sqrt_spead; this._velocityX = -sqrt_spead; break; //左下方向へ=5
                case 6 : this._velocityX = -this._speed; break; //左方向へ this._dir = 6;のとき
                case 7 : this._velocityY = -sqrt_spead; this._velocityX = -sqrt_spead; break; //左上方向へ=7
                default: break;
            }
        }

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

ここまでのデモを開く


OK! プレイヤーの向きと進む方向を、それぞれ違う要素に独立させました。これで横歩きも後ろ歩きもできるようになったよ。すごいね! 将来的に(混乱状態?)とか導入するときも役に立ちそう?
ぶっ通しでコードと格闘、プログラムの勉強(switch文)など含めて丸2日。。。いったん休憩を入れましょう。

お茶でものみなされ。

その間、出来たやつで遊ぶもよし。わーい、弾がいっぱいでるー。
いんふぃにてぃ〜ばれっと弾なの〜〜〜〜♪ヽ(。◕ v ◕。)ノ~*:・'゚☆



。。。。


さて、攻撃弾が8方向に打てるようになっちゃいましたが、当初の目的を忘れてはなりません。ゴールはNPCとの会話。 必要なのはプレイヤーが目の前の人物に話しかけるアクションですよ。

ぼちぼち作り始めていきましょう。

目の前の人物にアクションを起こす新たな矩形を定義する

ではclass Bulletの記述を参考に、他者に働きかけるアクション...を発生させる矩形class PlayerActionを定義してみます。 class Playerの下に、新たに書き起こしてみましょう。

class PlayerAction extends Actorを作成


class PlayerAction extends Actor {
    constructor(x, y, dir) {
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, hitArea, ['playerBullet']);
    }

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

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

解説。

画像は必要ない。矩形だけあればいいので、SpriteActorではなくActorからの拡張クラスにします。
renderメソッドは、enjine.jsに記述されてあるSpriteActorの欄を参考に、描き足しました。アクション範囲を確認するためです。
判定は一瞬でいいので、次のupdate(gameInfo, input)で、自身を破棄します。

プレイヤーclass ⇒ if(isFireReady && input.getKey(' ')) の修正

続いてPlayerクラスの方は、スペースキーを押した時の動作(Bulletを発生させる部分)を、PlayerActionに置き換えます。


        this._timeCount++;
        const isFireReady = this._timeCount > this._interval;
        if(isFireReady && input.getKey(' ')) {
            const action = new PlayerAction(this.x, this.y, this._dir);
            this.spawnActor(action);
            this._timeCount = 0;
        }

ここまでのデモを開く


うん、いい感じですね。

これは単発で良さそうので、キーを押した時のみ⇒ input.getKeyDown(' ')のアクションにして、 あとはプレイヤーの向きによって、アクション範囲を考えたいところ。

...なんかthis._dirは新たなアクション要素に引き継がなくて良さそうですね。プレイヤー側で、向きに応じた挙動を調整してみましょうか。。

上向き時(this._dir=0)は、高さ分(32px)上にくるので、y座標が-32となる。
右向き時(this._dir=2)は、幅分(32px)右にくるので、x座標が+32となる。
下向き時(this._dir=4)は、高さ分(32px)下にくるので、y座標が+32。。。

というように、8方向をプログラムしていきましょう。

プレイヤーclass ⇒ const action = new PlayerAction(this.x, this.y);の修正


        if(input.getKeyDown(' ')) {
            switch(this._dir) {
                case 0 : this.spawnActor( new PlayerAction(this.x, this.y - rect.height) ); break; //上
                case 1 : this.spawnActor( new PlayerAction(this.x + rect.width / Math.sqrt(2), this.y - rect.height / Math.sqrt(2)) ); break; //右上
                case 2 : this.spawnActor( new PlayerAction(this.x + rect.width, this.y) ); break; //右
                case 3 : this.spawnActor( new PlayerAction(this.x + rect.width / Math.sqrt(2), this.y + rect.height / Math.sqrt(2)) ); break; //右下
                case 4 : this.spawnActor( new PlayerAction(this.x, this.y + rect.height) ); break; //下
                case 5 : this.spawnActor( new PlayerAction(this.x - rect.width / Math.sqrt(2), this.y + rect.height / Math.sqrt(2)) ); break; //左下
                case 6 : this.spawnActor( new PlayerAction(this.x - rect.width, this.y) ); break; //左
                case 7 : this.spawnActor( new PlayerAction(this.x - rect.width / Math.sqrt(2), this.y - rect.height / Math.sqrt(2)) ); break; //左上
            }
        }


あとは、PlayerActionクラスの、必要なかったdir要素を削除しておきます。 また、タグは[playerbullet](敵を攻撃するタグ)になってる箇所を[playerAction](アクションを起こすタグ)に変更しておきます。

class PlayerAction extends Actor {
    constructor(x, y) {
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, hitArea, ['playerAction']);
    }

ここまでのデモを開く


よし、想定通りの動きです! お見事。(タグを変更したから敵のHPは削れない...)
これでアクション動作が分かりやすい形で表現できました。


プレイヤー側の調整は終わりましたので、ようやくNPCキャラの作成に移れます。 かなり長くなったので、続きは次のページに。

補足と修正点 〜 三角関数でシンプルに表記

。。。後日、古都さんより改善案を頂きました。

dirに角度(ラジアンで!)を突っ込んでおいて、 Math.sin(dir)とかMath.cos(dir)とかで三角関数が使えます。Math.PIっていう定数もあるのでπはこれを使えます。 プログラム的にはこの方がシンプルになるとは思います。たぶん。
だそうです。なるほど(o _ o。)
角度のラジアン表記については、解説サイトを見て学べる。
http://yarinaosinosansu.nomaki.jp/radian/index.html


this._dir(方向)を現す値を、角度(ラジアン)の表記にすれば、8方向にActorを出す記述をもっとシンプルにできるようです。 仕上げに、三角関数での記載を実践してみましょう。

this._dir と this._dirGoの値をラジアン表記に


class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 96, 32, 32));
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea);
        
        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 90 * Math.PI/180; //プレイヤーの向き...角度(ラジアン)の単位。。角度°にπ/180をかけた値
        this._dirGo = 90 * Math.PI/180; //進む方角...初期は上向き
    }
    
    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        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;} //左上

角度に * Math.PI/180 を掛けると、ラジアン表記になります。
右向きを0°、上向きを90°、左を180°、下が270(−90)°の流れにした場合、それぞれの角度に* Math.PI/180を付記した値がラジアン表記です。


↓ このラジアン表記を用いて、x軸の移動距離をMath.cos(ラジアン値)、y軸の移動距離をMath.sin(ラジアン値)、といった三角関数で簡単に表現することができるようです。

x,y軸の移動距離をラジアン表記を用いた三角関数で表記


        //矢印キーを押してる間、歩行カウントをすすめて、進む方角に応じた移動距離を計算
        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);
        }

プレイヤーの移動表記、8方向の移動パターンで8行以上に及んだコードが、たった2行で済みました。 ラジアンを三角関数と合わせて使うと、一括で計算OK、x軸はプラス方向に、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) );
        }

        //zキーで射撃
        this._timeCount++;
        const isFireReady = this._timeCount > this._interval;
        if(isFireReady && input.getKey('z')) {
            const bullet = new Bullet(this.x, this.y, this._dir);
            this.spawnActor(bullet);
            this._timeCount = 0;
        }


class Bullet extends SpriteActor {
    constructor(x, y, dir) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
        const hitArea = new Rectangle(4, 0, 8, 16);
        super(x, y, sprite, hitArea, ['playerBullet']);

        this._speed = 6;
        this._dir = dir;

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('enemy')) { this.destroy(); } 
        });
    }

    update(gameInfo, input) {
        this.y -= this._speed * Math.sin(this._dir);
        this.x += this._speed * Math.cos(this._dir);

        if(this.isOutOfBounds(gameInfo.screenRectangle)) {
            this.destroy();
        }
    }
}

ここまでのデモを開く


プレイヤーアクションと、z射撃の両方、記載しています。 該当部分、ものすっごく行数の削減になってます。約1/4です。 しかも16方向とか、32方向とか、果ては360方向までこの記述で対応できそうです。すさまじいですね。

方向を参照する時は、三角関数とラジアン表記が適する。というのを学びました。
ことさん、ありがとうございます><


次回、NPCとの会話(メッセージ編)につづきますよ。
JavaScriptでゲーム作り「4:NPCと会話する」


【目次】
  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の基礎(フレームワーク)を使わせていただいてます。感謝!


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




尚、ここまでの差分ファイルは、danmaku.jsの修正のみです。

danmaku.js修正後のソースコード

'use strict';

class TextLabel extends Actor {
    constructor(x, y, text, size) {
        const hitArea = new Rectangle(0, 0, 0, 0);
        super(x, y, hitArea);        
        this.text = text;
        this.size = size;
    }

    render(target) {
        const context = target.getContext('2d');
        context.font = `${this.size}px sans-serif`;
        context.fillStyle = 'white';
        context.fillText(this.text, this.x, this.y);
    }
}


class Player extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 96, 32, 32));
        const hitArea = new Rectangle(6, 4, 20, 28);
        super(x, y, sprite, hitArea);
        
        this._speed = 2;
        this._velocityX = 0;
        this._velocityY = 0;
        this.walkCount = 0;
        this._interval = 5;
        this._timeCount = 0;
        this._dir = 90 * Math.PI/180; //プレイヤーの向き...角度(ラジアン)の単位。。角度°にπ/180をかけた値
        this._dirGo = 90 * Math.PI/180; //進む方角...初期は上向き
    }
    
    update(gameInfo, input) {
        this._velocityX = 0;
        this._velocityY = 0;
        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(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);
        }

        //歩行カウントが一定以上に達した時、歩くモーションを変化
        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;}
        
        //移動を反映させる
        this.x += this._velocityX;
        this.y += this._velocityY;

        const boundWidth = gameInfo.screenRectangle.width - this.width;
        const boundHeight = gameInfo.screenRectangle.height - this.height;
        const bound = new Rectangle(this.width, this.height, boundWidth, boundHeight);
        
        if(this.isOutOfBounds(bound)) {
            this.x -= this._velocityX;
            this.y -= this._velocityY;
        }
        
        //スペースキーでアクション発生
        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) );
        }

        //zキーで射撃
        this._timeCount++;
        const isFireReady = this._timeCount > this._interval;
        if(isFireReady && input.getKey('z')) {
            const bullet = new Bullet(this.x, this.y, this._dir);
            this.spawnActor(bullet);
            this._timeCount = 0;
        }
    }
}

class PlayerAction extends Actor {
    constructor(x, y) {
        const hitArea = new Rectangle(0, 0, 32, 32);
        super(x, y, hitArea, ['playerAction']);
    }

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

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

class Bullet extends SpriteActor {
    constructor(x, y, dir) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 16, 16, 16));
        const hitArea = new Rectangle(4, 0, 8, 16);
        super(x, y, sprite, hitArea, ['playerBullet']);

        this._speed = 6;
        this._dir = dir;

        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('enemy')) { this.destroy(); } 
        });
    }

    update(gameInfo, input) {
        this.y -= this._speed * Math.sin(this._dir);
        this.x += this._speed * Math.cos(this._dir);

        if(this.isOutOfBounds(gameInfo.screenRectangle)) {
            this.destroy();
        }
    }
}

class Enemy extends SpriteActor {
    constructor(x, y) {
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(16, 0, 16, 16));
        const hitArea = new Rectangle(0, 0, 16, 16);
        super(x, y, sprite, hitArea, ['enemy']);

        this.maxHp = 50;
        this.currentHp = this.maxHp;

        
        this.addEventListener('hit', (e) => {
           if(e.target.hasTag('playerBullet')) {
               this.currentHp--;
               this.dispatchEvent('changehp', new GameEvent(this));
           }
        });
    }

    update(gameInfo, input) {
        
        if(this.currentHp <= 0) {
            this.destroy();
        }
    }
}


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

        this._width = 200;
        this._height = 10;
        
        this._innerWidth = this._width;

        
        enemy.addEventListener('changehp', (e) => {
            const maxHp = e.target.maxHp;
            const hp = e.target.currentHp;
            this._innerWidth = this._width * (hp / maxHp);
        });
    }

    render(target) {
        const context = target.getContext('2d');
        context.strokeStyle = 'white';
        context.fillStyle = 'white';
        
        context.strokeRect(this.x, this.y, this._width, this._height);
        context.fillRect(this.x, this.y, this._innerWidth, this._height);
    }
}


class DanmakuStgMainScene extends Scene {
    constructor(renderingTarget) {
        super('メイン', 'black', renderingTarget);
        const enemy = new Enemy(150, 100);
        const hpBar = new EnemyHpBar(50, 20, enemy);
        this.add(new Player(150, 200) );
        this.add(enemy);
        this.add(hpBar);
    }
}

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

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

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

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