アイテム管理のデータ駆動化と音声ID自動生成【CFW P2P心理戦ゲーム開発記】 #17

今回は見た目の変化はほぼありませんが、今後の保守管理を劇的に楽にするため、内部構造を大きく改良しました。

これまではアイテムの処理を巨大な if-else 分岐で記述していました。しかし、この方式だと新規でアイテムを追加するたびにメインの関数に処理をベタ書きする必要があり、管理が非常に煩雑になってしまいます。そこで、アイテムの管理を「データ駆動型(Data-Driven)」に移行しました。アイテムの定義を外部ファイル(JSONなど)に書き出し、それを読み込んで処理するアーキテクチャです。

アイテムをデータ駆動型に改良し、ついでに音声もデータ駆動に改良

アイテムjsonを作る

アイテムの動作パラメータや表示テキストなどをJSONファイルに書き出し、ゲームのセットアップ時にこれを読み込むようにします。これにより、例えば将来的に多言語対応(英語化など)を行う際も、ここで定義するテキストを差し替えるだけで容易に実装可能になります。

{
  "001": { 
    "name": "% Mod", 
    "icon": "🧮", 
    "description": "山札のカードを今の数との剰余に変える",
    "effectType": "CALC_MODULO",
    "logTemplate": "🧮 $actor が [% Mod $val] 発動! 山札がすべて $val で割った余りになった!"
  },
 
...ほかのアイテムの定義

}

アイテムの実行ロジックを分離

もともとは以下のような力技の if-else 分岐でした。

// 元の実装(べた書き)
    if (action === "USE_ITEM") {
            const { itemIndex, itemId } = payload;
            state.inventory[player].splice(itemIndex, 1);
            const itemName = ITEM_MASTER[itemId].name; 

            if (itemName === "% Mod") {
                const m = state.board.currentCard;
                state.board.queue = state.board.queue.map(x => x % m);
                Core.updateAndSync(`🧮 ${actor} が [% Mod ${m}] 発動! 山札がすべて ${m} で割った余りになった!`);
            } else if (itemName === "~ Complement") {
                state.board.queue = state.board.queue.map(x => 11 - x);
                Core.updateAndSync(`🔄 ${actor} が [~ Complement] 発動! 山札の数字がすべて反転した!`);
            } else if (itemName === "Garbage Collection") {
                let combined = [...state.board.queue, ...state.board.voidPool];
                Core.shuffle(combined);
                state.board.voidPool = [combined.pop(), combined.pop(), combined.pop()];
                state.board.queue = combined;
                Core.updateAndSync(`♻️ ${actor} が [Garbage Collection] 発動! Voidと山札を再シャッフルした!`);
            } else if (itemName === "Void Scanner" || itemName === "Peek()") {
                Core.updateAndSync(`👁️ ${actor} が [${itemName}] を使用しました...`);
            } else if (itemName === "Try-Catch") {
                state.effects[player].tryCatch = true;
                Core.updateAndSync(`🛡️ ${actor} が [Try-Catch] 発動! 次のBustペナルティを無効化します。`);
            } else if (itemName === "Context Switch") {
                state.effects[opponentId].cannotFold = true;
                state.turn = opponentId; 
                Core.updateAndSync(`⚡ ${actor} が [Context Switch] 発動! ポットを残したまま手番を押し付けた!`);
            }
            return;
        }

この実装を改め、アイテムの効果(effectType)をキーとして、別ファイル(ItemEffectBase)にあらかじめ定義した関数を動的に呼び出す形に改良しました。

// 効果の中身は別ファイル(itemEffect.js)に定義 ===================
/**
 * アイテムの実行ロジックをまとめたハンドラ群
 * @param {Object} state - ゲームのグローバルステート
 * @param {string} player - 発動したプレイヤーID ("p1" or "p2")
 * @param {string} opponentId - 相手のプレイヤーID
 * @returns {Object} - { logVars: { val: 10 }, resetTimer: boolean } を返す
 */
export const ItemEffectsBase = {

    CALC_MODULO: (state, player, opponentId) => {
        const m = state.board.currentCard;
        state.board.queue = state.board.queue.map(x => x % m);
        // JSONの $val に代入するための値を返す
        return { logVars: { val: m }, resetTimer: false };
    },
... (アイテム追加分だけここに追記する)
// ============================================================

元の関数部分は、以下のように驚くほどシンプルになります。

// 元の関数部分では以下のようにシンプルになります

const effectName = itemData.effectType;
const handler = ItemEffects[effectName];

if (handler) {
    // itemEffect.js の関数を実行し、盤面の操作結果を受け取る
    const result = handler(state, player, opponentId);
    
    // ... 共通のテキスト処理などへ続く
}

ログテキストのパース

ログテキストをベタ書きからJSON定義へ移行するにあたり、テキスト内の特定の文字列(キー)を動的に置換(パース)する仕組みを作りました。 Pythonの f'used item : {item}'"used : {}".format(item) のように、JSON側に $actor$name と書いておけば、実行時に実際の変数へ置き換わるようにします。

// 【基本変数の置換】
// RegExpの 'g' オプションで、テキスト内の全ての該当箇所を置換する
logMsg = logMsg.replace(/\$actor/g, actorName);
logMsg = logMsg.replace(/\$name/g, itemData.name);

// 【カスタム変数の置換】
// effects.js 側から return { logVars: { val: 10 } } のように値が返ってきた場合、
// テキスト内の $val を 10 に置換する
if (result && result.logVars) {
    for (const [key, value] of Object.entries(result.logVars)) {
        logMsg = logMsg.replace(new RegExp(`\\$${key}`, 'g'), value);
    }
}

音声のID管理

まだ音声エンジン自体は作り込めていませんが、いずれ音声もデータ駆動式に移行する予定なので、あらかじめ基盤だけ作っておきます。

スタンドアロンのPCゲームなどであれば、ゲーム起動時に特定のフォルダ内(例:/sounds)を検索してファイルリストを自動生成することができます。しかし、今回のWebベースの環境ではセキュリティの観点から、クライアント側でサーバー内のフォルダを勝手に走査(検索)することは不可能です。

そこで、デプロイ(公開)前にローカル環境でNode.jsスクリプトを走らせ、公開用の音声ファイルリスト(レジストリ)を自動生成する方式を採用します。

構成のポイント: 公開される public フォルダ内は誰でも見れてしまうため、大元の管理フォルダは公開されない階層に配置し、ビルド時にパスを解決するのが安全です。

以下が、ローカルでファイルを検索し、Web上で読み込むための相対パスに変換して、JSONオブジェクト(レジストリ)として出力するセットアップスクリプトの例です。

// scripts/logic-highandlow/generateAudioRegistry.js の作成例
const fs = require('fs');
const path = require('path');

const audioDir = 'ローカルの音声フォルダパス'; // 例: ./assets/audio
const outputFile = '出力先のパス'; // 例: ./public/game001/audioRegistry.json

function getFiles(dirPath) {
    if (!fs.existsSync(dirPath)) return {};
    const files = fs.readdirSync(dirPath);
    const registry = {};

    files.forEach(file => {
        if (file.endsWith('.mp3') || file.endsWith('.wav') || file.endsWith('.ogg')) {
            const id = path.basename(file, path.extname(file));
            
            // Web上で読み込むときのパス (public/以降の相対パスに成形)
            const webPath = `assets/audio/${path.basename(dirPath)}/${file}`;
            registry[id] = webPath;
        }
    });
    return registry;
}

const registry = {
    bgm: getFiles(path.join(audioDir, 'bgm')),
    se: getFiles(path.join(audioDir, 'se'))
};

// JSONファイルとして書き出す
fs.writeFileSync(outputFile, JSON.stringify(registry, null, 2));

Wranglerなどの環境を使っている場合、これを package.json のscriptsに登録しておけば、デプロイやビルドのタイミングで自動的に走らせることができ、毎回手動で叩かなくて良くなります。

// package.json の scripts 部分に追記
{
  "scripts": {
    "dev": "...", 
    "build": "...",
    "gen:audio-lhl": "node scripts/logic-highandlow/generateAudioRegistry.js"
  }
}

まとめ

今回は、アイテム管理のデータ駆動化への移行と、Web環境における音声ファイルのID管理・リスト自動生成の仕組みを実装しました。これにより、今後の拡張やバランス調整が圧倒的に楽になります。

関連記事
【第9回】ゲームデザインとアイテム実装
ゲームルールを考える:ハイアンドローモックアップ【CFW P2P心理戦ゲーム開発記】 #9
【第13回】ゲームルールの策定v1
Logic High and Low v1ルール策定とゲーム本体実装【CFW P2P心理戦ゲーム開発記】 #13
【第14回】ゲームUIのコンセプト決定
コンセプトとゲームUIに着手【CFW P2P心理戦ゲーム開発記】 #14

次回予告

次回は、再びUIの改良に戻ります。
現状の画面構成では「単にシステムと対話している」という雰囲気が強く出てしまっています。しかし、本作はあくまでP2Pの「対戦ゲーム」です。
そこで次回は、画面の向こうに「対戦相手がいること」を直感的に感じられるよう、相手の操作パネル(仮想コンソール)を画面上に配置するなどのUIアップデートを行っていく予定です。

次回:UIの改良 対戦相手を表現したい #18
関連記事は、2026年6月29日に公開予定 (あと2時間)

ここまで読んでいただきありがとうございます。 では、次の記事で。 lumenHero