ゲームプログラミング入門:キャラクターの動きとアニメーションを連動させる

ゲーム

ゲームエンジンやライブラリを使わずに、HTMLとJavaScriptだけでゲームを作ります。

以下のような人のためにあります。

ゲームを作ってみたいけど、何をどうしていいか分からない。
単純なゲームを作りたいだけなのに、UnityとかUnreal Engineは覚えることが多すぎる。

ある程度プログラミングの知識がある人を対象にしています。

この記事で作るもの

ドット絵のキャラクターが状態によってアニメーションを切り替えながら動きます。
左右キーを押してみてください。

先に読んでおく記事

ドット絵のキャラクターをアニメーションさせる方法は、下記記事に書きましたので、先に読んでおくことをお勧めします。

ソースコード

index.html, index.js を同じフォルダーに置いてください。さらに、ドット絵パターンが描かれたspritesheet.png を用意し、同じフォルダーにおく必要があります。index.htmlをブラウザからファイル読み込みすれば実行できます。

このデモのspritesheet.png

<html>
    <center>
        <canvas id="canvasBg" width="80" height="32" style="border:1px solid #000000; background-color: #000; display:none;"></canvas>
        <canvas id="gameCanvas" width="640" height="256" style="border:1px solid #000000; background-color: #000;"></canvas>
    <script type="text/javascript" src="index.js"></script>
</html>
// background canvas
const canvas_bg = document.getElementById('canvasBg');
const context_bg = canvas_bg.getContext('2d');

// display canvas
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;

const State = {
    STANDING: 0,
    MOVE_LEFT: 1,
    MOVE_RIGHT: 2,
}

class Chara {
    constructor(x,y, anime_table, wait_time=0) {
        this.x = x;
        this.y = y;
        this.anime_count = 0;
        this.anime_index = 0;
        this.move_count = 0;
        this.state = State.STANDING;
        this.flip = false;
        this.anime_table = anime_table;
        this.wait_time = wait_time;
        this.wait_count = this.wait_time;
        this.next_action_input = State.STANDING;
    }

    update() {
        if (this.wait_count > 0) {
            this.wait_count--;
            return;
        }

        this.next_action();

        switch(this.state) {
        case State.STANDING:
            this.next_action();
            // do nothing
            break;
        case State.MOVE_RIGHT:
            this.count_move(1, 0);
            break;
        case State.MOVE_LEFT:
            this.count_move(-1, 0);
            break;
        }

        this.anime_update();
        this.wait_count = this.wait_time;
    }

    next_action() {
    }

    anime_update() {
        let frames = this.anime_table[this.state].frames;
        let frame_interval = this.anime_table[this.state].frame_interval;

        if (this.anime_count >= frame_interval) {
            this.anime_index = (this.anime_index+1) % frames.length;
            this.anime_count = 0;
        }
        this.sprite = frames[this.anime_index];
        this.anime_count++;
    }

    draw() {
        drawSprite(this.sprite, this.x, this.y, this.flip);
    }

    change_state(state) {
        if (state == this.state) {
            return;
        }
        this.state = state;
        this.anime_index = 0; 
        this.anime_count = 0;
        this.move_count = this.anime_table[this.state].move_count;
    }

    count_move(dx, dy) {
        this.move_count--;
        if (this.move_count < 0) {
            this.stop_move();
            return;
        }
        this.x += dx;
        this.y += dy;
    }

    stop_move() {
        this.change_state(State.STANDING);
    }

    move_right() {
        this.change_state(State.MOVE_RIGHT);
        this.flip = false;
    }

    move_left() {
        this.change_state(State.MOVE_LEFT);
        this.flip = true;
    }
}

class Monster extends Chara {
    setTarget(target) {
        this.target = target;
    }

    next_action() {
        if (this.x < this.target.x) {
            this.move_right();
        } else if (this.x > this.target.x) {
            this.move_left();
        } else {
            this.stop_move();
        }
    }
}

// 入力ハンドラー
document.addEventListener('keydown', keyDownHandler, false);

function keyDownHandler(event) {
    if (event.key === 'Left' || event.key === 'ArrowLeft') {
        player.move_left();
    } 

    if (event.key === 'Right' || event.key === 'ArrowRight') {
        player.move_right();
    }
}

function createBoy(x, y) {
    return new Chara(x,y,
        [
            {state_name: 'STANDING', move_count: 0, frames: [0,1], frame_interval: 60},
            {state_name: 'MOVE_LEFT', move_count: 9, frames: [2,3,4], frame_interval: 3},
            {state_name: 'MOVE_RIGHT', move_count: 9, frames: [2,3,4], frame_interval: 3},
        ]);
}

function createMonster(x, y) {
    return new Monster(x,y,
        [
            {state_name: 'STANDING', move_count: 8, frames: [8,10], frame_interval: 50},
            {state_name: 'MOVE_LEFT', move_count: 8, frames: [8,9,11], frame_interval: 2},
            {state_name: 'MOVE_RIGHT', move_count: 8, frames: [8,9,11], frame_interval: 2},
        ], 4);
}

function drawSprite(sprite_no, x, y, flip) {
    let sx = (sprite_no % 8) *8;
    let sy = Math.floor(sprite_no / 8)*8;
    if (flip) {
        context_bg.save();
        context_bg.scale(-1,1);
        context_bg.drawImage(spriteSheet, sx, sy, 8, 8, -x-8, y, 8, 8);
        context_bg.restore();
    } else {
        context_bg.drawImage(spriteSheet, sx, sy, 8, 8, x, y, 8, 8);
    }
}

function update() {
    // オリジナルサイズをバックグランドバッファに描画
    context_bg.clearRect(0, 0, canvas_bg.width, canvas_bg.height);
    for (o of chara_list) {
        o.update();
        o.draw();
    }

    // 表示用に拡大する
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.drawImage(canvas_bg, 0, 0, canvas_bg.width, canvas_bg.height, 0, 0, canvas.width, canvas.height);
}

// スプライトシートのロード
const spriteSheet = new Image();
spriteSheet.src = "./spritesheet.png";

let player = createBoy(5,10);
let monster = createMonster(40,20);
monster.setTarget(player);

let chara_list = [];
chara_list.push(player);
chara_list.push(monster);

setInterval(update, 10);


キャラクターの状態管理

ゲーム中に動き回るキャラクターは、いろんな状態があります。例えば、以下のようなものです。

  • 止まっている
  • 走っている
  • ジャンプしている

それぞれの状態に合った絵を使ってアニメーションをさせたいところです。それには、まずはキャラクターの状態を定義して管理する必要があります。

このデモの実装では、Charaクラスに state という変数を定義しています。

・・・
const State = {
    STANDING: 0,
    MOVE_LEFT: 1,
    MOVE_RIGHT: 2,
}
・・・
class Chara {
    constructor(x,y, anime_table, wait_time=0) {
       ・・・
        this.state = State.STANDING;
       ・・・
    }
・・・
}

状態は3つだけ定義しています。

  • STANDING: 立っている状態
  • MOVE_RIGHT: 右に動く
  • MOVE_LEFT: 左に動く

状態ごとのアニメーション情報

状態を定義した後は、それぞれの状態のアニメーションを設定します。

デモの実装としては、キャラクターのインスタンスを生成しているところに、状態ごとのアニーメーション状態の定義を渡しています。

function createBoy(x, y) {
    return new Chara(x,y,
        [
            {state_name: 'STANDING', move_count: 0, frames: [0,1], frame_interval: 60},
            {state_name: 'MOVE_LEFT', move_count: 9, frames: [2,3,4], frame_interval: 3},
            {state_name: 'MOVE_RIGHT', move_count: 9, frames: [2,3,4], frame_interval: 3},
        ]);
}

以下のプロパティが入ったオブジェクトの配列を渡して、これを Charaクラス内では anime_table として管理しています。

  • state_name: 状態名 (ソースコードを読む時の補助情報、またはデバッグ用)
  • move_count: 状態の動作フレーム数
  • frames: その状態で使用するスプライト番号のリスト
  • frame_interval: アニメーションの更新間隔 (更新待ちフレーム数)

上記を状態ごとに定義して、これを配列に入れていますが、並べる順番は State オブジェクトの定義と揃えている必要があります。下記の定義です。

const State = {
    STANDING: 0,
    MOVE_LEFT: 1,
    MOVE_RIGHT: 2,
}

アニメーション情報の中身

例えば、立っている状態(STANDING)のアニメーション情報は下記です。

{state_name: 'STANDING', move_count: 0, frames: [0,1], frame_interval: 60},

frames配列にある番号 0, 1はスプライト番号です。下記のスプライートシートの0と1の絵です。

frame_interval は 60 に設定されています。これは、60フレームに1回絵を切り替えるという意味です。60フレームに一回、framesの配列を指すインデックスを順に進めて、描画するスプライトを切り替えます。

1フレームは10ミリ秒ごとに更新されるので600ミリ秒に1回、絵が切り替わりことになります。

状態の切り替わりとアニメーション

も少し詳しく、状態の切り替わりとアニメーションについて説明します。

キャラが右に動いている状態 State.MOVE_RIGHT を例にします。以下がそのアニメーション情報です。

{state_name: 'MOVE_RIGHT', move_count: 9, frames: [2,3,4], frame_interval: 3},

このアニメーションの動きを図にすると、下記のようになります。

右キーが押されたときに、playerキャラの状態は State.MOVE_RIGHT に切り替わります。

切り替わった後に、move_count に設定された 9フレームを使って動きます。その間のアニメーションは frames: [2,3,4] のスプライトを切り替えて行います。切り替えは、frame_interval 設定に従って、3フレームごとの間隔で行います。

9フレーム分の時間が経ったら、State.STANDINGに自動遷移します。

スプライトの切り替えの制御は下記の関数で実現しています。

    anime_update() {
        let frames = this.anime_table[this.state].frames;
        let frame_interval = this.anime_table[this.state].frame_interval;

        if (this.anime_count >= frame_interval) {
            this.anime_index = (this.anime_index+1) % frames.length;
            this.anime_count = 0;
        }
        this.sprite = frames[this.anime_index];
        this.anime_count++;
    }

キャラクターの移動

State.MOVE_RIGHT時に、キャラはアニメーションしながら、右にも移動しています。これは、以下のコードで実現しています。

・・・
    update() {
        ・・・
        switch(this.state) {
       ・・・
       case State.MOVE_RIGHT:
            this.count_move(1, 0);
            break;
       ・・・
        }
       ・・・
    }
・・・
    count_move(dx, dy) {
        this.move_count--;
        if (this.move_count < 0) {
            this.stop_move();
            return;
        }
        this.x += dx;
        this.y += dy;
    }
・・・

update() は10ミリ秒ごとに呼ばれます。(アニメーションの1フレームと同じです)。

count_move()関数内で、キャラクターの座標移動をしています。これを this.move_count 分だけ繰り返します。

この実装では、dx = 1, dy = 0 で、右に1ドットずつ動きます。一回のMOVE_RIGHTで9ドット動きます。this.move_countはアニメーションテーブルの move_count の設定情報を使っています。

以下の関数の
 this.move_count = this.anime_table[this.state].move_count; 
の処理で、状態が切り替わったときに更新されます。

    change_state(state) {
        if (state == this.state) {
            return;
        }
        this.state = state;
        this.anime_index = 0; 
        this.anime_count = 0;
        this.move_count = this.anime_table[this.state].move_count;
    }

まとめ

さて、デモで実現したキャラクターの動きとアニメーションの連動方法について説明してきました。

ポイントは、
・キャラクターに状態を持たせること
・各状態ごとにアニメーションの情報を持たせること
です。

ここで示した実装方法は一例にしか過ぎません。とりあえずは参考にしてみてください。

この仕組みを使えば、ある程度複雑なゲームの作成に対応できます。マリオブラザーズのようなアクションゲームもこの延長線上で作れます。

また、倉庫番のようなシンプルなゲームも、ここに示したアニメーションの仕組みを導入すれば、より豊かな表現にできます。

気に入った人は、色々いじって遊んでみてください。


コメント

タイトルとURLをコピーしました