【JavaScript】スプライトアニメーション

ゲームエンジンを使わずに、手軽に 2D ゲームを作る環境を調べているのですが、その過程で JavaScript を使った方法を調査したので得られた知見を共有したいと思います。

本稿で扱う内容

やり方としては、スタイルシートの要素を使う方法と、canvas を使う方法のふたつあります。
本稿ではスタイルシートの要素を使う方法について共有します。

てっとり早く確認したい場合
PC でご覧の方なら、この Web ページの左上に 8-bit ゲームっぽいキャラ※が見えると思いますが、これは GIF アニメや PNG アニメを使っていません。
スマホやタブレットでご覧の方は、右上の ≡ をタップすることで表示されます。

※キャラ名は「シモーネ/シモーヌ/Simone」です。

このキャラは JavaScript でスプライトアトラスを使ってスプライトアニメしています。
このウェブページのソースを見れば、スタイルシートと JavaScript のコードも確認できます。

もくじ

JavaScript でスプライトアトラスを使ってスプライトアニメする

これから共有するコードは、すべて AI に相談しながら構築したものです。
マイナー言語は無理ですが、メジャーなものは AI に聞いた方が早いです。

補足と蛇足
よく混同している人がいるので、いちおう説明しますが、Java と JavaScript は全く違うものです。
node.js が登場するまでは、Web ブラウザで色んなことをするためのスクリプト言語でした。
node.js が登場してからは、サーバーサイドの処理もできるようになり、ユーザー数は全世界トップです。

私が以前 JavaScript をいじっていたのは node.js の登場よりずっと前で、まだマイナーでした。
インターネットも普及していなくて、スマホも存在しない時代です。
ここ 30 年くらいの動向を見て来た感じ、言語仕様はゆるいけど何でもできる言語が重宝されるようですね。
Rust はどうなるんだろう…。

ブラウザを使うので、HTML, CSS, JavaScript の3つのソースファイルが必要です。
コードは以下になります。

script.js

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();
});
MIT ライセンス全文
MIT License

Copyright (c) 2025 dokuro.moe

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

StateMachine クラスがアニメーション周りのデータを管理しています。
ゲームエンジンやライブラリを使う場合も似たような仕組みになると思います。

スタイルシートの要素は冒頭で定義している sprite オブジェクトを使ってアクセスすることができます。
以下の流れで JavaScript で操作できるようになります。

1. HTML でクラスを宣言(DIV タグを使う)。
 ※ BODY タグ内ならどこで宣言しても良い。
2. そのクラスの見た目を CSS で設定する。
 ※設定を間違えると動かない。
 ※ 1 で宣言したクラス名が hoge なら、CSS では .hoge になる。
3. JavaScript でそのクラスにアクセスする。
 ※ 1 と 2 の設定に間違いがなければ JavaScript で制御できる。
 ※クラス名は CSS と同じ(先頭にピリオドを付ける)。

style.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;
}

HTML

ライセンスはありません。使用は自己責任です。

<!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

JavaScript はちょっとした 2D ゲームのモック作成に使えるか?

ムリ。
単体ではきつい。

モックとプロトタイプの違い
モックはプロトタイプとは違います。

「こんなゲーム作ろうとしてます。」というのを、スポンサーや会社の偉い人など、ゲーム開発の実務や知見なんて、なーんも知らない人に見せるためのものです(現場上がりで最新技術に明るい人はレアです)。

モックは見た目の確認を目的としているので、見た目さえつくろえれば中身は問題になりません。
素早く手軽に作れることが最も重要です。
だいたい、数日から一週間くらいで作れと言われます。
その短期間で、映えるものを詰め込めるだけ詰め込みます。
見た目が良ければそれで良いので、動画で作ることもあります。

開発が始まるかどうかも分からない段階なので、モック製作で実際の開発環境を想定し、時間をかけて作るのは不適切です。

技術アピールをしたい場合は、プロトタイプを作ります。
プロトタイプは中身が重要ですが、実用レベルのものを作る必要はありません。
ただ、ゲームなので見た目も重要で、映えるものにする必要があります。

理由

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 自体は良いと思います。

2D ゲームのモック作成にゲームエンジンを使わない理由

1. インストールとセットアップが重い。
2. 起動がいちいち重い。
3. 機能過剰で重い。
4. エディタをいじくりまわすのがめんどくさい。
5. マウス使うのめんどくさい(マウスが重い)。
6. 何をするのも重い。
7. エンジンバグが鬱陶しい&調査修正が重い。
8. エンジンの使い方覚えるのが重い。
※コード単体で済むならエンジンの知識は要らない。

※ 3D ゲームならありです。
※ 2D ゲームのモックじゃなくて、本格的な開発ならありです。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です