2:プレイヤーを歩かせる

古都さん作シューティングゲームを元にして

(2019.1.11執筆)

前回に触れた、Javascriptお役立ちコラムを執筆されてる古都さんは、blogで独自にシューティングゲームのJavaScriptを公開されてます。

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


操作キャラが矢印キーで動いてるのは何となく体感。
古都さんのデモページで確認


これらのソースコードは膨大なJSライブラリと違い、かなりシンプルな構成です。
シンプル=仕組みを理解しやすい、応用しやすい、というメリットが有ります。

初心者の私がプログラミングを体感する上で、シンプルさ以上に重要な要素はない。つまり、解説を見ながら一つ一つ仕組みを読解していけば、少しずつ自分なりのアレンジを加えていけるのではないか?と、考えました。後にコードの改変許可を頂けたので、実践過程を記しておきます。古都さんありがとうです!


JavaScriptで弾幕シューティングゲームをフルスクラッチで作ってみよう、その1
上記のソースコードを元に、プレイヤーが歩くまでの動作を組み込みます。


プレイヤーキャラ
プレイヤーの画像はぴぽやさんの素材から、この子を使うことにします。。 この記事は備忘録です。既存のコードをどのように取り入れて、応用するかな? を、この文章で追体験してもらえると記事を残した甲斐があります。

自機のソースコードの仕組みを理解する

初期のゲームファイルは、engine.js(仕組みの中核部分)とdanmaku.js(シーンやキャラの設定部分)の2つで成り立っているようです。 ひとまずdanmaku.jsに記述されてる自機(class Fighterで定義)の部分を見ていきましょう。

danmaku.js > Fighterクラス


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


さっそく意味が分かりませんね(' '*);; ここで諦めます。終了....(o _ o。)


...いや、さっきのページに解説載ってるやん。見るよ、見るぞ。
JavaScriptで弾幕シューティングゲームをフルスクラッチで作ってみよう、その1


うん、よく分かんないね。

どうやら自機の記述そのものを理解するには、この内部に書かれてる「Sprite」やら「Rectangle」やら「SpriteActor」が何なのか? 記載されてるのがゲームエンジンの中核部分「engine.js」、そう「engine.js」の中身を追っていく必要があるようです。

engine.jsを開いて、上記の記述を抜粋します。

engine.js > Rectangle


class Rectangle {
    constructor(x, y, width, height) {//現在位置(x座標、y座標)と、大きさ(幅、高さ)を持つオブジェクトの定義
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    hitTest(other) {
        const horizontal = (other.x < this.x + this.width) &&
            (this.x < other.x + other.width);//他オブジェクトとのx軸接触判定
        const vertical = (other.y < this.y + this.height) &&
            (this.y < other.y + other.height);//他オブジェクトとのy軸接触判定
        return (horizontal && vertical);//x軸&&y軸両方重なるか接触判定する・・・trueかfalseの値で使う
    }
}


矩形(くけい)は重要な要素です。描画や当たり判定など様々なところで使用します。座標と幅・高さを持つだけのシンプルな(sbfl.netより)
Rectangle...こういった存在のようです。

色んな判定や、範囲、位置情報に用いるのでしょう。Rectangle要素はゲーム中で実体を持つ、ほぼ全てに用いられているようです。

engine.js > Sprite


class Sprite {//Rectangleに画像を当てはめる。画像名と、画像のどの部分を使うか?の定義)
    constructor(image, rectangle) {
        this.image = image;
        this.rectangle = rectangle;
    }

	get _rectangle(){return this.rectangle;}
	set _rectangle(newRectangle){this.rectangle = newRectangle;}
}


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

Spriteは、先ほどのRectangle要素に、画像を当てはめるための記述のようです。自機の画像を変更する場合は、Sprite要素を弄るとよいことが分かります。

engine.js > SpriteActor


class SpriteActor extends Actor {//画像を当てはめたActor
    constructor(x, y, sprite, hitArea, tags=[]) {
        super(x, y, hitArea, tags);
        this.sprite = sprite;//画像
        this.width = sprite.rectangle.width;//画像の幅
        this.height = sprite.rectangle.height;//画像の高さ
    }

    render(target) {//オーバーライドしたrenderメソッドでは、canvasのdrawImageメソッドを使って自身を描画
        const context = target.getContext('2d');
        const rect = this.sprite.rectangle;
        context.drawImage(this.sprite.image,
            rect.x, rect.y,
            rect.width, rect.height,
            this.x, this.y,
            rect.width, rect.height);
    }
    
    isOutOfBounds(boundRect) {//isOutOfBoundsメソッドはRectangleオブジェクトの外であるかどうかを判定
        const actorLeft = this.x;
        const actorRight = this.x + this.width;
        const actorTop = this.y;
        const actorBottom = this.y + this.height;

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

        return (horizontal || vertical);
    }
}


SpriteActorは色々すっ飛ばしたので分かりません(o _ o。)
しかし此処までの段階で、一つ前のSpriteを弄ればプレイヤー画像を変更できることが分かりました。

画像ファイルの当てはめ方を理解する

では、danmaku.jsに戻って、自機(Fighter)のコードを再確認します。

danmaku.js > Fighterクラス


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


画像の差し替えにSpriteを弄る...つまりこのFighterクラスの3段目に注目ですね。
        const sprite = new Sprite(assets.get('sprite'), new Rectangle(0, 0, 16, 16));
↑ これこれ。


この部分を試しに以下のように変更するよ。
        const sprite = new Sprite(assets.get('player'), new Rectangle(0, 0, 32, 32));

プレイヤーキャラ
用意したキャラチップは"32×32"で1人前なので、領域分はnew Rectangle(0, 0, 32, 32)にします。 ...で、肝心の「chara_player.png」はきちんと読み込めるのか?


未だ、読み込めるわけないんですよね〜。(' '*) どうやって新しい画像を読み込ませるか? それには初期の画像「sprite.png」がどのように扱われているかを追っていく必要があります。 .jsファイルの中身を再度確認してみましょう。。。

ここで「sprite.png」の文字列を検索!


。。。するとdanmaku.jsの最後の方に、それらしき記述を発見できます。

danmaku.js > sprite.png


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


何やら「assets」という文字列でsprite.pngを読み込ませているようですね。
ということは、新しい画像の文字列とURLをコードを追加して...

assets.addImage('player', 'chara_player.png');

このようにまとめれば...


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行追加しただけ。 如何にプログラミングが難しいか、思い起こされそうな気します...先長い。

こっから移動方向に向きを変えたり、キーを押してる間に歩く動作を適応させたり課題は残ります...

キャラクターの向きを歩く方向にチェンジする

        const sprite = new Sprite(assets.get('player'), new Rectangle(0, 0, 32, 32));
先ほど手を加えた上のコードでは、正面片足立ちが表示されました。

プレイヤーキャラ

12個あるうち、一番左上の画像がデモで表示されてましたね。再確認。
じゃあRectangle(0, 0, 32, 32)の部分を、試しにRectangle(32, 0, 32, 32)にしてみます。
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 0, 32, 32));
プレイヤーキャラ
今度は、正面で真っ直ぐ立つようになりました。



では次にRectangle(32, 32, 32, 32)を試してみましょう。
        const sprite = new Sprite(assets.get('player'), new Rectangle(32, 32, 32, 32));
プレイヤーキャラこれは左向きか。

先ほどのpngファイルと照らしあわせてみると、Sprite(〜)内にあるnew Rectangle(x, y, width, height)の意味がだんだん掴めてきます。

xを、この画像の場合は32ずつ加算すると、右のマスの画像に。
yを、この画像の場合は32ずつ加算すると、下のマスの画像に変化する、ということ。


1マスのキャラの大きさが32×32ピクセルの単位width=32、height=32...だったので。 つまり画像ファイルの左上から、右xピクセル、下yピクセルを起点に、幅=width、高さ=heightの範囲を切り取って画像を呼び出す!のが、Sprite(〜)内のnew Rectangle(x, y, width, height)の意味だと理解できたのでした。



ここまで分かってしまえば、後はnew Rectangle(x, y, width, height)の数値を加減すれば向きを変えられる!という発想になります。
ひとまず直立姿勢のRectangle(x=32, y, 32, 32)を起点としましょう。

↓下向き時は、y=0 ...つまりnew Rectangle(32, 0, 32, 32)
←左向き時は、y=32 ...つまりnew Rectangle(32, 32, 32, 32)
→右向き時は、y=64 ...つまりnew Rectangle(32, 64, 32, 32)
↑上向き時は、y=96 ...つまりnew Rectangle(32, 96, 32, 32)

となるように、自機のコード(キー入力部分)を改変してみましょう。

danmaku.js > Fighterクラス修正前


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


これを、このように書き換える。

danmaku.js > Fighterクラス修正後


class Fighter 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;
    }
    
    update(gameInfo, input) {
        if(input.getKey('ArrowUp')) { this.y -= this.speed; this.sprite.rectangle.y = 96;}
        if(input.getKey('ArrowDown')) { this.y += this.speed; this.sprite.rectangle.y = 0;}
        if(input.getKey('ArrowRight')) { this.x += this.speed; this.sprite.rectangle.y = 64;}
        if(input.getKey('ArrowLeft')) { this.x -= this.speed; this.sprite.rectangle.y = 32;}
    }
}

デモを開いてみましょう


ちゃんと進む方向に合わせて向きを変えています!!!素晴らしい。
ここまでの追加分は4行...理解は果てしなく大変なのに、作業する時は一瞬だよ。
実際には、めちゃくちゃ試行錯誤してるので、もっと掛かってますが...

ここで判ったのは、何かの要素の値を入れ替えたい場合、this.sprite.rectangle.y = 〜といったように、.(ピリオド)で要素を辿って「値」を代入できるということですね。


最後に、キーを押しっぱなしの間は、sprite.rectangle()内のxが±32する記述です!これでちゃんと歩く動作になるよ。実現できればね。

歩く動作を組み込むsprite.rectangle(x)を切り替える

さて、では歩く動作の原理を考えてみましょう。 何らかの矢印キー↑↓←→を押している間、「sprite.rectangle.x」の値がこのように変わります。

32 ⇒ 64 ⇒ 32 ⇒ 0 ⇒ 32 ⇒ 64 ⇒ 32 ⇒ 0 ⇒ 以下繰り返し。。。
(32 ⇒ 0 ⇒ 32 ⇒ 64 ⇒ 32 ⇒ 0 ⇒ 32 ⇒ 64 ⇒ 以下繰り返し。。でもイイ)


一定の距離を進んだら、値を切り替えるようにするかな。
その一定距離を測るための要素を一つ追加してみます。。。Fighterクラス内に、他の書き方に添って。

Fighterクラス内に追加

        this.speed = 2;
        this.walkCount = 0;

最初からあるthis.speed = 2の下に、this.walkCount = 0;という要素を加えてみました。
歩いてる間は、this.speed =2 で順次移動している。
このことを利用して、矢印キー↑↓←→を押している間は歩行距離が順次加算されていくコードを描きます。

Fighterクラス内の update(gameInfo, input) 内に追加

if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft'))
 {this.walkCount += this.speed;}

this.walkCount += this.speed;という計算式にした。if()でいずれかの矢印キーが押されてる間に適応。
あとは、this.walkCountが一定値を超える度に、歩行モーションを切り替えるようにします。 条件式ifを用いると良いのかな??? 色々試行錯誤する。

同じくupdate(gameInfo, input) 内に追加

        if(this.walkCount > 0) { this.sprite.rectangle.x = 64; }
        if(this.walkCount > 16) { this.sprite.rectangle.x = 32; }
        if(this.walkCount > 32) { this.sprite.rectangle.x = 0; }
        if(this.walkCount > 48) { this.sprite.rectangle.x = 32; this.walkCount = -15; }
この計算式を考えるのにだいたい6時間くらい掛かった。まだまだ要領をつかめん...
今回16刻みでやってますが、12刻みでも20刻みでも、値を加減して歩くモーションスピードを調整します。


移動キーを放した際に、直立姿勢になるコードも加えますか。バランスとる仕上げ。
        if(input.getKeyUp('ArrowUp') || input.getKeyUp('ArrowDown') || input.getKeyUp('ArrowRight') || input.getKeyUp('ArrowLeft')) 
{this.sprite.rectangle.x = 32; this.walkCount = 0;}

ふぅうう、作業時間ばかり経過している(o _ o。)
ようやく完成形が見えてきました。

danmaku.js > Fighterクラス歩く完成形


class Fighter 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.walkCount = 0;
    }
    
    update(gameInfo, input) {
        if(input.getKey('ArrowUp')) { this.y -= this.speed; this.sprite.rectangle.y = 96;}
        if(input.getKey('ArrowDown')) { this.y += this.speed; this.sprite.rectangle.y = 0;}
        if(input.getKey('ArrowRight')) { this.x += this.speed; this.sprite.rectangle.y = 64;}
        if(input.getKey('ArrowLeft')) { this.x -= this.speed; this.sprite.rectangle.y = 32;}
        
        if(input.getKey('ArrowUp') || input.getKey('ArrowDown') || input.getKey('ArrowRight') || input.getKey('ArrowLeft')) {this.walkCount += this.speed;}

        if(this.walkCount > 0) { this.sprite.rectangle.x = 64; }
        if(this.walkCount > 16) { this.sprite.rectangle.x = 32; }
        if(this.walkCount > 32) { this.sprite.rectangle.x = 0; }
        if(this.walkCount > 48) { this.sprite.rectangle.x = 32; this.walkCount = -15; }
        
        if(input.getKeyUp('ArrowUp') || input.getKeyUp('ArrowDown') || input.getKeyUp('ArrowRight') || input.getKeyUp('ArrowLeft')) {this.sprite.rectangle.x = 32; this.walkCount = 0;}
    }
}
プレイヤーキャラ
⇒ 完成形のデモを開く



。。。ここまで丸2日費やした。 労力とリターンが見合ってるかどうかは判らんけど、とりあえず歩く動作が完成!
おめでとー、やったね(' '*)



こんなふうにして、無事にゲーム作りの第一歩を迎えることができました。
ご成長ありがとうございました。javascriptとかプログラム初心者ではあるものの、これまでHTML+CSSの経験もあるからどうかのう、誰かの励みになれば◎。



つづいては...NPCとの会話イベントだろうか...???
ぼちぼちやっていくか(o _ o。)


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

すぺしゃるさんくす

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


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




尚、ここまでの差分ファイルは、プレイヤー画像chara_player.pngの追加と、danmaku.jsの修正のみです。

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


'use strict';

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

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

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

        if(this.walkCount > 0) { this.sprite.rectangle.x = 64; }
        if(this.walkCount > 16) { this.sprite.rectangle.x = 32; }
        if(this.walkCount > 32) { this.sprite.rectangle.x = 0; }
        if(this.walkCount > 48) { this.sprite.rectangle.x = 32; this.walkCount = -15; }
        
        if(input.getKeyUp('ArrowUp') || input.getKeyUp('ArrowDown') || input.getKeyUp('ArrowRight') || input.getKeyUp('ArrowLeft')) {this.sprite.rectangle.x = 32; this.walkCount = 0;}
    }
}

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

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

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

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

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