【JavaScript】ゲームロジック確認用の超軽量ライブラリーができそう

アイキャッチの GIF アニメは、イージング関数(Tween)を使った移動(上)と等速移動(下)の比較テスト。

AI による動画説明

AI による音声解説

坂道で3速から4速に入れるのは狂気の沙汰だけどね!
例え話として分かりやすいですね。

なにこれ?

さくっとゲーム的な挙動を確認するための js ライブラリです。

Phaser 高度なブラウザゲームを開発できる高機能ゲームライブラリ 有料、イントーラーによる導入
Kontra.js 中規模なゲーム開発ライブラリ 無料、パッケージによる導入
gamejs(本ライブラリ) 超軽量なゲームロジック検証用ライブラリ 無料、OSS、game.jsをコピーするだけ
コンセプト

ゲーム用ロジックの検証を最小の手間で行えることを目的としています。
超軽量なライブラリで、クラスひとつあたりのコードもシンプルで軽量なので、検証時にゲーム開発用ライブラリのブラックボックス部分の影響を疑う必要がありません。
そういったノイズを極限まで減らし、検証時の影響範囲を狭め、クリアな環境による検証を高速に行うことが可能になります。

ゲームエンジンやライブラリを使う方が良いのでは?

ゲームで使うシンプルなロジックを検証するために、Visual Studio やゲームエンジンを起動するのは純粋にめんどくさいです。
それらのブラックボックス(余計なお世話機能)が純粋なロジックの検証において邪魔になることが多々あります。
メモ帳とブラウザの超シンプルな環境だけが唯一必要なのに、それに加えてエンジンやライブラリや API の機能について学習しなければならないのはコスパ悪いです。
MCP などを使ってそれらの環境での検証用コードを AI に生成させると、そのコードの正当性を検証するところから始めなければならなくなります。

作った経緯

メモ帳とブラウザだけで、スプライトを使ったゲーム用オブジェクトの動きを確認したり、ロジックが正しく動作するかを確認したり、ゲームを作るのが目的ではなく、ゲームに使うロジックを最小の手間で検証するための環境が欲しかったからです。
既存のライブラリやエンジンを使うと、それらのブラックボックス部分の影響をいちいち疑わなければならず、ノイズになります。

コードは AI に生成させればいいのでは?

AI が対応していない部分や AI が生成したものをさっと検証する目的でも使えます。
このライブラリを作った目的は AI の成果物検証だけではありません。
自分で思いついたロジックや、ネットで見たロジック、ソフトウェア工学の論文をさくっと検証することも想定しています。

汎用的なよく使う処理は AI を使えば良いのですが、新規開拓の分野だったり、ネット上に情報がないもの(開発用の環境が一般公開されていないものなど)は人間の力が必要です。
AI の成果物を最終確認するのは人間なので、AI が生成したコードのロジックがよく分からない、AI に問いただしても要領を得ないとき、AI に修正してもらうにしても、どのような方向で修正してもらうべきかが見えないときに自分で検証する環境が必要になります。

game.js は高機能ライブラリなどにありがちな、意図しないイベント発火や、見えない部分の tick 処理がありません。
更新が必要な場合は、定期的な更新を行うクラスを使うか、手動で呼び出す必要があります。
要するに自分で意図して処理を追加する必要があるため、意図しない呼び出しを防ぐことができ、追跡が容易になります。

何故 JavaScript なのか?

Windows ならメモ帳とブラウザは標準で搭載しているので、JavaScript を使うのが一番楽そうでした。
別言語に変換するのは AI がやってくれます。

どのような人が対象になるのか?

AI でチェック済の成果物を最終チェックするゲームエンジニアで、プログラム言語なんてなんでもいいよという人。
game.js でゲームを作るのが目的ではなく、ゲームに使う機能やロジックの検証だけしたい人。

使い方
  1. HTML ファイルを作成(文字コードは UTF-8)。
  2. game.js(後述) を同じフォルダに置く。
  3. HTML ファイルに後述のコードをコピペ。
  4. HTML ファイルをブラウザで開く。


下のサンプルコードを実行するとこんな感じのものが表示される。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>game.jsテスト</title>
</head>
<body>
  <canvas id="gameCanvas" width="800" height="300" style="border:1px solid black;"></canvas>
  <script src='game.js'></script>
  <script>
class Game extends GameSystemBase {
  constructor() {
    super('gameCanvas');

    // スプライトアニメを表示しているフレーム番号[0, n)
    this.#currentFrame = 0;

    // スプライトアニメの更新が早すぎる場合にかけるウェイト
    this.#frameWeight = 10;

    // アンチエイリアスを無効化
    this._getContext().imageSmoothingEnabled = false;

    // スプライト画像生成
    this.#sprite = new Image();

    // スプライトアニメーションのデータ設定
    this.#animes = [
      { x:0*32, y:0, width:31, height:16 },
      { x:1*32, y:0, width:31, height:16 }
    ];
  }
  load(onloadCallback) {
    // スプライト画像を読み込み
    this.#sprite.src = 'https://dokuro.moe/wp-content/uploads/2025/08/dokuro_sprite.png';
    // 読み込みが終わったら onloadCallback を呼び出す
    this.#sprite.onload = onloadCallback();
  }
  update(deltaTime) {
    super.update(deltaTime);
    if (this._getFrameCounter() % this.#frameWeight == 0) {
      // アニメーションのフレームを更新
      this.#currentFrame = (this.#currentFrame + 1) % this.#animes.length;
    }
  }
  draw() {
    super.draw();
    const anime = this.#animes[this.#currentFrame];
    this._getContext().drawImage(
      this.#sprite,
      anime.x, anime.y,
      anime.width, anime.height,
      180, 130, // canvas 上の表示位置
      anime.width * 2, anime.height * 2 // 縦横2倍に拡大して表示
    );
  }
  #animes;
  #sprite;
  #currentFrame;
  #frameWeight;
}

// 1秒間の更新回数
const FPS = 30;

// ゲームシステムを生成
const game = new Game();

// バーチャルなディスプレイ(画面)を生成
// 生成時点では画面の更新は行われない
const display = new VirtualDisplay(FPS, game);

// ゲームに必要なデータをロードする
// ロードが完了したときに display.start() を呼び出す
game.load(() => { display.start(); });

  </script>
</body>
</html>
game.js

ダウンロード rev.12(26/09/30)
ダウンロード rev.10(26/09/29)

// rev.9

/*
 * fps に指定したタイミングで game.update() と game.draw() を処理するクラス
 * fps は秒間の更新回数で、30 を指定した場合は FPS 30 で処理するようになる。
 * 定期的な更新が不要なら、このクラスは使わず game だけを使うことで、
 * 高速な繰り返しテストが可能。
 */
class VirtualDisplay {
  constructor(fps, game) {
    this.#fps           = fps;
    this.#frameInterval = 1000 / fps;
    this.#game          = game;
    this.#lastTime      = 0;
    this.#isRunning     = false;
  }
  start() {
    this.#isRunning = true;
    this.#lastTime  = performance.now();

    const loop = (currentTime) => {
      if (!this.#isRunning) { return; }

      const elapsed = currentTime - this.#lastTime;
      if (elapsed >= this.#frameInterval) {
        this.#lastTime = currentTime - (elapsed % this.#frameInterval);

        // 極端なラグ(ブラウザのタブ切替等)で大きな deltaTime が入った場合の跳ね防止ガード
        const safeDeltaTime = Math.min(elapsed / 1000, 0.1);

        this.#game.update(safeDeltaTime);
        this.#game.draw();
      }
      requestAnimationFrame(loop);
    };

    requestAnimationFrame(loop);
  }
  stop() {
    this.#isRunning = false;
  }
  #fps;
  #frameInterval;
  #game;
  #lastTime;
  #isRunning;
}

/*
 * ゲーム全体の処理を行うクラスのベースクラス
 * このベースクラス単体で使うことは想定していない。
 * このベースクラスを継承して、独自のゲームシステムクラスを使う必要がある。
 *
 * load() メソッドは継承した子クラス側でオーバーライドしなければならない。
 *
 * 更新処理と切り分けているため、定期的な更新が不要な場合は、VirtualDisplay は使わず
 * このクラスを継承した子クラスだけを使えば良い。
 * そうすることで、例えば、キー入力をしたときだけ update() を呼び出して、
 * 内部パラメータをコンソールに出力したり、100フレーム分の挙動を一気に回して
 * パラメータが意図した通りに変化しているかを確認する…といった検証に使えます。
 */
class GameSystemBase {
  constructor(canvasName) {
    if (new.target == GameSystemBase) {
      throw new Error("GameSystemBase は抽象クラスのため、直接インスタンス化することはできません。");
    }
    this.#canvas       = document.getElementById(canvasName);
    this.#context      = this.#canvas.getContext('2d');
    this.#frameCounter = 0;
  }
  // ゲームに必要なデータを読み込む(このメソッドを直接呼び出すことはせず、必ず子クラスで処理を実装する事)
  load(onloadCallback) {
    throw new Error("load() は子クラスで実装する必要があります");
  }
  // 画面の更新がリクエストされたときに行うロジック処理
  update(deltaTime) {
    this.#frameCounter++;
  }
  // 画面の更新がリクエストされたときに行う描画処理
  draw() {
    this.#context.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
  }
  _getCanvas() { return this.#canvas; }
  _getContext() { return this.#context; }
  _getFrameCounter() { return this.#frameCounter; }
  #canvas;
  #context;
  #frameCounter;
}

// イージング関数の定義
const Easing = {
    // Ease In (アクセル: 次数で加速具合を調整)
    easeInQuad: (t) => t * t,
    // Ease Out (ブレーキ: 到達前に減速)
    easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
    // Ease In-Out (アクセル + ブレーキ)
    easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
};

/*
 * イージング関数を使った移動制御クラス
 */
class EasedMover {
  constructor(startX, startY) {
    this.#startX = startX;
    this.#startY = startY;
    this.#targetX = startX;
    this.#targetY = startY;
    this.#currentX = startX;
    this.#currentY = startY;
    this.#duration = 1.0; // 移動にかける時間(秒)
    this.#elapsedTime = 0; // 経過時間(秒)
    this.#isMoving = false;
  }
  // 移動目標をセットして開始
  moveTo(targetX, targetY, duration = 1.0) {
    this.#startX = this.#currentX;
    this.#startY = this.#currentY;
    this.#targetX = targetX;
    this.#targetY = targetY;
    this.#duration = duration;
    this.#elapsedTime = 0;
    this.#isMoving = true;
  }
  // deltaTime (秒単位) を受け取って位置を更新
  update(deltaTime) {
    if (!this.#isMoving) { return; }

    // 経過時間を更新し、進行割合 t (0.0 ~ 1.0) を算出
    this.#elapsedTime += deltaTime;
    let t = Math.min(this.#elapsedTime / this.#duration, 1.0);

    // イージング関数を通した補間値を計算 (Ease In-Out でアクセル&ブレーキ)
    const easedT = Easing.easeInOutQuad(t);

    // 線形補間(Lerp)で現在位置を算出
    this.#currentX = this.#startX + (this.#targetX - this.#startX) * easedT;
    this.#currentY = this.#startY + (this.#targetY - this.#startY) * easedT;

    // 到達完了判定
    if (t >= 1.0) {
        this.#isMoving = false;
    }
  }
  isFinished() { return !this.#isMoving; }
  getX() { return this.#currentX; }
  getY() { return this.#currentY; }
  #startX;
  #startY;
  #targetX;
  #targetY;
  #currentX;
  #currentY;
  #duration;
  #elapsedTime;
  #isMoving;
}

/*
 * 指定秒数で指定区間を等速移動させるクラス
 */
class LineMover {
  constructor(startX, startY) {
    this.#startX = startX;
    this.#startY = startY;
    this.#targetX = startX;
    this.#targetY = startY;
    this.#currentX = startX;
    this.#currentY = startY;
    this.#duration = 1.0; // 移動にかける時間(秒)
    this.#elapsedTime = 0; // 経過時間(秒)
    this.#isMoving = false;
  }
  moveTo(targetX, targetY, duration = 1.0) {
    this.#startX = this.#currentX;
    this.#startY = this.#currentY;
    this.#targetX = targetX;
    this.#targetY = targetY;
    this.#duration = duration;
    this.#elapsedTime = 0;
    this.#isMoving = true;
  }
  update(deltaTime) {
    if (!this.#isMoving) { return; }

    // 経過時間を更新し、進行割合 t (0.0 ~ 1.0) を算出
    this.#elapsedTime += deltaTime;
    let t = Math.min(this.#elapsedTime / this.#duration, 1.0);

    // 線形補間(Lerp)で現在位置を算出
    this.#currentX = this.#startX + (this.#targetX - this.#startX) * t;
    this.#currentY = this.#startY + (this.#targetY - this.#startY) * t;

    // 到達完了判定
    if (t >= 1.0) {
        this.#isMoving = false;
    }
  }
  isFinished() { return !this.#isMoving; }
  getX() { return this.#currentX; }
  getY() { return this.#currentY; }
  #startX;
  #startY;
  #targetX;
  #targetY;
  #currentX;
  #currentY;
  #duration;
  #elapsedTime;
  #isMoving;
}

/*
 * ステートマシン(遷移条件とブレンド制御なし)
 * 必要最小限の機能に絞っているのは、意図しない遷移、複雑な条件による遷移を
 * クラス内部で行うのを省き、ロジックの検証や追跡を容易にするためです。
 */
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 = '';
}

/*
MIT License

Copyright (c) 2026 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.
*/

MIT ライセンスです。

game.js にあるもの

現状はこれくらいですが、たぶん、増えます。

今後どうするのか?

ある程度ボリュームが増えたら GitHub か GitLab で公開するかも?

GameSystemBase と canvas が依存関係にあるので、canvas がなくても動作するよう修正予定です。
ロジックの検証ではグラフィックの描画が不要な状況が多々あるためです。

また、EasedMover と LineMover がほぼ同じコードになっているので、移動計算の部分を分離させ、ひとつのクラスに集約させる、または、game.js から削除して、代わりに tween.js を使うようにする予定です。
tween.js は game.js だと使いにくいのでやめました。Mover クラスに統一。26/09/30

このライブラリは、AI への質問とディープリサーチの両方を使っても見つからないから作った…くらいのノリで作り始めたものです。
コンセプトに沿って機能拡張するつもりですが、今後も軽いノリで機能を追加したり削除します。

もし都合が悪い方がおられましたら、本ライブラリは MIT ライセンスなので、game.js をベースとした自分独自の検証用ライブラリを育てていただくことができます。
この程度なら誰でも思いつくものなので、本来ライセンスなんて要らないと思っていますが、面倒ごとを避けるためのものとご理解いただければと思います。

役に立ったもの

Gemini 3.6 Flash
Claude Haiku 4.5
NotebookLM による本稿のレビュー
Microsoft Clipchamp(GIFアニメ出力)
Snipping Tool(動画撮影)
EpicGames Lore(js コードのバージョン管理)

コメントを残す

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