ゲームエンジンやライブラリを使わずに、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;
}
まとめ
さて、デモで実現したキャラクターの動きとアニメーションの連動方法について説明してきました。
ポイントは、
・キャラクターに状態を持たせること
・各状態ごとにアニメーションの情報を持たせること
です。
ここで示した実装方法は一例にしか過ぎません。とりあえずは参考にしてみてください。
この仕組みを使えば、ある程度複雑なゲームの作成に対応できます。マリオブラザーズのようなアクションゲームもこの延長線上で作れます。
また、倉庫番のようなシンプルなゲームも、ここに示したアニメーションの仕組みを導入すれば、より豊かな表現にできます。
気に入った人は、色々いじって遊んでみてください。
コメント