ゲームエンジンを使わずに、手軽に 2D ゲームを作る環境を調べているのですが、その過程で JavaScript を使った方法を調査したので得られた知見を共有したいと思います。
やり方としては、スタイルシートの要素を使う方法と、canvas を使う方法のふたつあります。
本稿ではスタイルシートの要素を使う方法について共有します。
- JavaScript でスプライトアトラスを使ってスプライトアニメする
- script.js
- style.css
- HTML
- スプライト画像
- JavaScript はちょっとした 2D ゲームのモック作成に使えるか?
これから共有するコードは、すべて AI に相談しながら構築したものです。
マイナー言語は無理ですが、メジャーなものは AI に聞いた方が早いです。
ブラウザを使うので、HTML, CSS, JavaScript の3つのソースファイルが必要です。
コードは以下になります。
MIT ライセンスとします。
/* * Copyright (c) 2025 dokuro.moe * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /////////////////////////////////////////////////////////////////////////////////////////////////// // スプライト情報 const sprite = { simone: document.querySelector('.sprite-animation-simone'), skull: document.querySelector('.sprite-animation-dokuro') }; /////////////////////////////////////////////////////////////////////////////////////////////////// // スプライトアニメのデータ // 待機アニメ const idleAnimes = [ { x:2*32, y:0, width:16, height:32, weight:5 }, { x:6*32, y:0, width:16, height:32, weight:5 } ]; // ページめくりアニメ const pageAnimes = [ { x:2*32, y:0, width:16, height:32, weight:2 }, { x:3*32, y:0, width:16, height:32, weight:2 }, { x:4*32, y:0, width:16, height:32, weight:2 }, { x:5*32, y:0, width:16, height:32, weight:2 } ]; // 魔法発動アニメ const invokeAnimes = [ { x:7*32, y:0, width:16, height:32, weight:5 }, { x:8*32, y:0, width:16, height:32, weight:10 } ]; // ドクロアニメ const skullAnimes = [ { x: 0, y:0, width:16, height:16, weight:3 }, { x:32, y:0, width:16, height:16, weight:3 } ]; /////////////////////////////////////////////////////////////////////////////////////////////////// // ドクロの表示位置制御用データ const skullData = { defaultX: 116, defaultY: 100, x: screen.width, y: 100, angle: 0 }; /////////////////////////////////////////////////////////////////////////////////////////////////// // ステートマシン(遷移条件とブレンド制御なし) class StateMachine { add(name, animes, loop) { this.#animes.push({ name:name, animes:animes, frame:0, weight:0, loop:loop }); } transition(name) { const animes = this.getAnimes(name); if (animes === undefined) { return; } this.#current = name; animes.frame = 0; animes.weight = 0; } getCurrentName() { return this.#current; } getCurrentAnime() { if (!this.#current) { return undefined; } const animes = this.getAnimes(this.#current); if (animes === undefined) { return undefined; } const last_frame = this.#getMaxFrame() - 1; animes.frame = Math.min(animes.frame, last_frame); const anime = animes.animes[animes.frame]; return anime; } updateFrame() { const animes = this.getAnimes(this.#current); if (animes == undefined) { return; } const anime = this.getCurrentAnime(); if (anime === undefined) { return; } const max_frame = this.#getMaxFrame(); const max_weight = this.#getMaxWeight(); animes.weight++; if (!animes.loop && this.isFinished()) { animes.weight = max_weight; return; } animes.weight %= max_weight; if (animes.weight > 0) { return; } if (animes.loop) { animes.frame = (animes.frame + 1) % max_frame; } else { animes.frame = Math.min(++animes.frame, max_frame); } } isFinished() { const animes = this.getAnimes(this.#current); if (animes === undefined) { return false; } const last_weight = this.#getMaxWeight() - 1; const last_frame = this.#getMaxFrame() - 1; return animes.weight >= last_weight && animes.frame >= last_frame; } getAnimes(name) { const animes = this.#animes.find(e => e.name === name); return animes; } #getMaxFrame() { const animes = this.getAnimes(this.#current); if (animes === undefined) { return -1; } return animes.animes.length; } #getMaxWeight() { const animes = this.getAnimes(this.#current); if (animes === undefined) { return -1; } const last_frame = this.#getMaxFrame() - 1; const frame = Math.min(animes.frame, last_frame); return animes.animes[frame].weight; } #animes = new Array(); #current = ''; } /////////////////////////////////////////////////////////////////////////////////////////////////// // ステートマシンのインスタンスと遷移データ設定 const sm = { simone: new StateMachine(), skull: new StateMachine() }; // シモーネ用 sm.simone.add('idle', idleAnimes, true); sm.simone.add('page', pageAnimes, false); sm.simone.add('invoke', invokeAnimes, false); sm.simone.transition('idle'); // ドクロ用 sm.skull.add('idle', skullAnimes, true); sm.skull.transition('idle'); /////////////////////////////////////////////////////////////////////////////////////////////////// // スプライトアニメ処理 // シモーネ用 function animateSprite() { const anime = sm.simone.getCurrentAnime(); if (anime === undefined) { return; } const x = -anime.x; const y = -anime.y; sprite.simone.style.backgroundPosition = `${x}px ${y}px`; sm.simone.updateFrame(); // アニメの遷移処理 const name = sm.simone.getCurrentName(); if (name === 'page' || name == 'invoke') { if (sm.simone.isFinished()) { if (name == 'invoke') { skullData.x = skullData.defaultX; skullData.y = skullData.defaultY; } sm.simone.transition('idle'); } } else { if (Math.random() <= 0.05) { sm.simone.transition('page'); } } } // ドクロ用 function animateSkullSprite() { const anime = sm.skull.getCurrentAnime(); if (anime === undefined) { return; } const x = -anime.x; const y = -anime.y; sprite.skull.style.backgroundPosition = `${x}px ${y}px`; sm.skull.updateFrame(); let left = skullData.x + 5; let top = skullData.y + Math.sin(skullData.angle) * 4; if (left > screen.width + 32) { left = screen.width + 32; } skullData.x = left; skullData.y = top; skullData.angle += 1; sprite.skull.style.left = `${left}px`; sprite.skull.style.top = `${top}px`; } // 魔法発動アニメに遷移させる function invokeMagic() { const name = sm.simone.getCurrentName(); if (name === 'invoke') { return; } sm.simone.transition('invoke'); } /////////////////////////////////////////////////////////////////////////////////////////////////// // 一定間隔でアニメーション関数を呼び出す setInterval(animateSprite, 200); setInterval(animateSkullSprite, 100); /////////////////////////////////////////////////////////////////////////////////////////////////// // イベント設定 document.addEventListener('click', (event) => { console.log('ボタンがクリックされました!'); invokeMagic(); }); document.addEventListener('touchstart', (event) => { console.log('画面がタップされました!'); invokeMagic(); });
StateMachine クラスがアニメーション周りのデータを管理しています。
ゲームエンジンやライブラリを使う場合も似たような仕組みになると思います。
スタイルシートの要素は冒頭で定義している sprite オブジェクトを使ってアクセスすることができます。
以下の流れで JavaScript で操作できるようになります。
1. HTML でクラスを宣言(DIV タグを使う)。
※ BODY タグ内ならどこで宣言しても良い。
2. そのクラスの見た目を CSS で設定する。
※設定を間違えると動かない。
※ 1 で宣言したクラス名が hoge なら、CSS では .hoge になる。
3. JavaScript でそのクラスにアクセスする。
※ 1 と 2 の設定に間違いがなければ JavaScript で制御できる。
※クラス名は CSS と同じ(先頭にピリオドを付ける)。
ライセンスはありません。使用は自己責任です。
.sprite-animation-simone { width: 16px; height: 32px; background-image: url('sprite_atlas.png'); background-repeat: no-repeat; background-position: 0 0; transform-origin: bottom left; transform: scale(2); position: fixed; left: 100px; top: 100px; } .sprite-animation-dokuro { width: 16px; height: 16px; background-image: url('dokuro_sprite.png'); background-repeat: no-repeat; background-position: 0 0; transform-origin: bottom left; transform: scale(1.5); position: fixed; left: -100px; top: 100px; }
ライセンスはありません。使用は自己責任です。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Sprite Anime Sample</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="sprite-animation-simone"></div> <div class="sprite-animation-dokuro"></div> <script src="script.js"></script> </body> </html>
画像は個人使用に限定します。
sprite_atlas.png
dokuro_sprite.png
ムリ。
単体ではきつい。
1. スプライトアニメの処理を作るのが面倒。
2. スタイルシート用意するのが面倒。
3. HTML 書くのが面倒。
4. VisualStudio で動作確認するまでが面倒。
5. ブラウザのウェブ開発ツール(Mozilla 用)でデバッガ使って動作確認できるけど、デバッグ中のコードを直接書き換えできないなど VS と比べて使い勝手悪い。
※ js の開発環境に詳しくないので、もっと手軽に扱う方法があるかも知れません。
1. Visual Studio で簡単に作成&動作確認&デバッグできる。
※メジャーでコード量が少なくなる言語が良い。
2. ソースファイルひとつ書くだけで動かせる。
3. 追加のライブラリが要らない(インストールめんどくさい)。
1. Python Arcade Library (Python)
2. MonoGame (C#)
JavaScript 用のシンプル 2D ゲームライブラリ kaboom.js 自体は良いと思います。
1. インストールとセットアップが重い。
2. 起動がいちいち重い。
3. 機能過剰で重い。
4. エディタをいじくりまわすのがめんどくさい。
5. マウス使うのめんどくさい(マウスが重い)。
6. 何をするのも重い。
7. エンジンバグが鬱陶しい&調査修正が重い。
8. エンジンの使い方覚えるのが重い。
※コード単体で済むならエンジンの知識は要らない。
※ 3D ゲームならありです。
※ 2D ゲームのモックじゃなくて、本格的な開発ならありです。