【第13回】AI にゲームは作れる。でも「手触り」は作れない — LLM に欠けた game feel を Bevy 製大富豪で埋めた記録
「正しく動くのに手触りが死んでいる」——AI が書くゲームに欠けがちな game feel を、Rust/Bevy(WASM) 製の対戦大富豪で実装した記録。ヒットストップの時計設計、スクリーンシェイク、WASM 公開の落とし穴まで、LLM が黙っていた実測値と設計判断。
← 前の記事: 【第12回】ガス代ゼロで寄付できる募金箱——JPYCが3つある事件と、ウォレットに詐欺扱いされた話この記事は何か(AI でゲームを作る人へ)
AI にコードを書かせてゲームを作る——もう普通のことです。ルールも、盤面も、通信も、LLM は 書けます。でも実際に動かすと、多くが 「正しく動くのに、手触りが死んでいる」。カードを 出しても弾まない、決め手が刻まれない、当たっても効いた気がしない。
これは腕の問題ではなく、LLM に game feel(juice)が構造的に欠けているからです。正確に言うと:
- 知ってはいるが気にしない:訊けば「スクリーンシェイクを足せ」と答える。でも黙って 実装させると juice ゼロのものを出す。プロンプトで明示的に要求しない限り、手触りは生えない
- 暗黙知を持たない:「何ミリ秒止めるか」「どの時計に載せるか」「実ブラウザで動くか」 といった、テキストに落ちにくい実装の勘所は、LLM から自発的には出てこない
この記事は、その欠けている知識を Rust 製ゲームエンジン Bevy の対戦大富豪(ブラウザで 遊べる WASM ビルド)で実際に埋めた記録です。「入門」ではなく、LLM が黙っていた設計判断と、自分でハマった落とし穴を、 動くコードと実測値で残します。AI と組んでゲームを作る人が、 「ここは自分が知識を持ち込む番だ」と気づくための、具体的な地図として。
- 遊べるデモ: /games/grimoire-royale/
- 扱うもの: ヒットストップ / スクリーンシェイク / HPバーのフラッシュ、そして WASM でブラウザ公開するまで
- 扱わないもの: Bevy の網羅的な入門 → 入門編 へ(本記事は必要な最小限だけ §2 で触れる)
- シリーズ: 入門編 → 配信編 → 手触り編(本記事)
「juice」とは、操作に対してゲームが生き生きと応答する感触の総称。詳しくは末尾の参考リンク (Jonasson & Purho, Nijman, Swink)を。この記事はその実装側の話です。
2. 前提:Bevy の "毎フレーム再構築" と WASM(ここだけは要る)
後半の設計は Bevy 固有の2つの性質に強く規定されているので、そこだけ先に。
- ECS(Entity Component System)+ データ指向:状態は Component/Resource に持ち、 毎フレーム走る System が読み書きする。今回の演出も「トリガ状態を Resource に書き、 System が毎フレーム消費する」形で書く
- 毎フレーム再レイアウト型のUI:本作はレイアウトシステムが毎フレーム、スプライトの色や位置を基準値へ描き直す。 これは後述のフラッシュ実装を「宣言的」にできる鍵になる
- ターゲットは wasm32-unknown-unknown:ブラウザで動かすので、後半の公開節(§5)が現実の壁になる
3. 理論の足場(1段落だけ)
juice の理屈は既存の名作講演・書籍が語り尽くしています(末尾リンク)。実装で効く要点だけ:
- ヒットストップ(着弾の瞬間に時間を数十ミリ秒止める)=時間的マスキングで「効いた」を刻む
- スクリーンシェイク=衝撃の誇張(アニメ12原則の Exaggeration)
- 被弾フラッシュ=プレイヤーの入力に対する feedback を返す
以下は全部この3つを Bevy でどう実装したか、です。
4. 実装
4.1 ダメージ強度 → 演出パラメータ(純関数)
まず「ダメージ量 → 演出の強さ」を純関数に切り出します(ECS 型に触れないのでユニットテスト可能)。
const BIG_DAMAGE_THRESHOLD: i32 = 25; // HP100 の 1/4 が一撃で飛ぶ = 大ダメージ
const SHAKE_TTL: f32 = 0.15;
struct DamageFxParams {
hitstop_secs: f32, // Time<Virtual> を止める長さ(REAL 秒で計測)
shake_magnitude: f32, // カメラ最大オフセット(design px)
bar_flash_ttl: f32, // HPバーのフラッシュ長
}
fn damage_fx_params(damage: i32) -> DamageFxParams {
if damage >= BIG_DAMAGE_THRESHOLD {
DamageFxParams { hitstop_secs: 0.10, shake_magnitude: 14.0, bar_flash_ttl: 0.5 }
} else {
DamageFxParams { hitstop_secs: 0.05, shake_magnitude: 6.0, bar_flash_ttl: 0.3 }
}
}実測値(要調整の出発点):
| 演出 | 通常ダメージ | 大ダメージ(HP≥25=1/4) |
|---|---|---|
| ヒットストップ | 0.05s | 0.10s |
| シェイク振幅 | 6px | 14px(design 720×1280 基準) |
| バーフラッシュ | 0.3s | 0.5s |
カードゲームはテンポが遅いぶん、ヒットストップは一般に言われる「20ms 程度」より 長め(50〜100ms)でも許容できました。ジャンルでこの許容量は変わります。
4.2 ヒットストップ:仮想時計 pause + 実時計デッドライン(デッドマン構造)
Bevy には Time<Virtual>(pause できるゲーム内時間)と Time<Real>(実時間)があります。
全アニメを仮想時計基準にしておけば、仮想時計を pause するだけで演出が一括停止します
(アニメごとに「今止まってるか」を書く必要がない)。
問題は解除です。解除条件まで仮想時計に置くと、自分ごと凍って永久停止します。 そこで 解除デッドラインだけは凍らない実時計に置く。これで、途中で画面状態がどう 遷移しても pause は必ず解ける——「デッドマンスイッチ」構造です。
#[derive(Resource, Default)]
pub struct GameFeelFx {
shake: Option<(f32, f32)>, // (仮想時計 started_at, 振幅px)
hitstop_until: Option<f32>, // ← REAL 時計のデッドライン
bar_flash: [Option<(f32, f32)>; 2], // プレイヤー別 (仮想 started_at, ttl)
}
/// 全体 Update に登録する(InGame ゲートしない)。こうすると状態遷移をまたいでも
/// 時計が凍りっぱなしになることが構造上あり得ない。
pub fn tick_hitstop(
real_time: Res<Time<Real>>,
mut virtual_time: ResMut<Time<Virtual>>,
mut fx: ResMut<GameFeelFx>,
) {
let Some(deadline) = fx.hitstop_until else { return; };
if real_time.elapsed_secs() >= deadline {
virtual_time.unpause();
fx.hitstop_until = None;
} else if !virtual_time.is_paused() {
virtual_time.pause();
}
}おまけの効用:シェイクの時計は仮想時計のままにしておくと、ヒットストップ中はシェイクも 自動で静止し、解除後に揺れ始めます。「フリーズ → ガクッ」という定石の順序が、順序制御の コードを一切書かずに時計の構造から勝手に出てくる。
4.3 スクリーンシェイク:二乗イージング減衰 + 2軸デコリレーション
fn shake_offset(elapsed: f32, magnitude: f32) -> Vec2 {
if !(0.0..SHAKE_TTL).contains(&elapsed) { return Vec2::ZERO; }
let t = elapsed / SHAKE_TTL;
let decay = (1.0 - t) * (1.0 - t); // 二乗イージング。線形だと止まり際がだらつく
let phase = elapsed * 90.0;
// 2軸を別周波数・別位相に。同位相だと円運動=「回り」に見え「揺れ」にならない
Vec2::new((phase + 0.9).sin(), (phase * 1.3 + 2.2).cos()) * magnitude * decay
}2点だけ勘所:
- 減衰は二乗(
(1-t)^2)。線形だと終わり際がヌルい - 2軸を別位相に。位相オフセットで
t=0から振幅を非ゼロにして、出だしをガツンと出す
4.4 HPバーのフラッシュ:毎フレーム上書きレンダラとの "宣言的" 共存
ここが Bevy の毎フレーム再レイアウトと相性が良い所です。フラッシュ用システムを レイアウトの後段で走らせ、期限内だけ色を上書きする。期限が切れたら何もしない—— 翌フレームにレイアウトが基準色へ勝手に戻す。復元コードが要らない(宣言的)。
// レイアウトが毎フレーム色をリセットするので、期限切れ = 何もしない = 自動復元
fn hp_fill_flash_color(t: f32) -> Color {
let base = (0.53, 0.08, 0.11);
let hot = (1.0, 0.35, 0.30);
Color::srgb(lerp(hot.0, base.0, t), lerp(hot.1, base.1, t), lerp(hot.2, base.2, t))
}もう一つ実戦的な罠:Fill(残量バー)だけ光らせると、低HPでバーが細くフラッシュが見えない。 背景(Back)も同時に光らせて、残量に依らず被弾が見えるようにします。
4.5 純関数にはテストを、画面全体の演出は "最大値で1回"
- 減衰・色 lerp・閾値判定は純関数にしてユニットテスト(
shake_offset/hp_fill_flash_color/damage_fx_params)。ECS から切り離しておくと後で値を詰めるのが安全 - 反射ダメージなどで両者が同フレーム被弾することがある。シェイク/ヒットストップは 画面グローバルな演出なので加算せず、最大ダメージ1回分だけ。対象別の演出(バーフラッシュ)は 対象ごとに出す
4.6 ハマった話 2つ
焼き込み済み演出の二重化:勝利バナー(動画由来の連番フレーム)に紙吹雪が既に焼き込まれて いたのに、スプライトの紙吹雪を実装して二重になり撤去しました。動画由来素材を使うときは 「その演出はどこに存在すべきか」を実装前に洗うこと。
パーティクルは決定論で:撤去した紙吹雪は seed → 純関数 f(seed, t) で位置・回転を導出する
書き方にしていました。乱数状態を持たず、ユニットテスト可能で、リプレイ安全。手法自体は
今後のパーティクルに再利用できます。
5. WASM でブラウザ公開するまで(ここが本当の壁)
多くの入門は localhost で止まりますが、実運用は配信でハマります。本作のデプロイスクリプトから。
5.1 wasm-opt の -all で "ブラウザで動かない" ビルドが出来る
cargo build --release → wasm-bindgen の後、サイズ最適化に wasm-opt をかけます。ここが罠。
# rustc の wasm32-unknown-unknown は bulk-memory / sign-ext などを既定で吐く。
# wasm-opt は MVP feature 前提で検証するので、これらを明示しないと入力を弾く。
# ただし rustc が実際に吐くものだけを有効化する。
wasm-opt --enable-bulk-memory --enable-sign-ext --enable-mutable-globals \
--enable-nontrapping-float-to-int \
-Oz grimoire_royale_bg.wasm -o grimoire_royale_bg.wasm.opt
-allを使ってはいけない。-allは GC / 例外ハンドリング / reference-types まで 有効化してしまい、実ブラウザで instantiate 不能なビルドが出来ました (Chromium 131 に対し Playwright で検証)。「必要な feature だけを明示的に有効化」が正解。
5.2 バージョン刻印と自動タグ
配信ページには可視のビルドラベル(vX.Y.Z (短縮ハッシュ 日付))を焼き、キャッシュ由来の
不具合報告をコミットに紐付けられるようにしています。semver は git タグが正本で、
Conventional Commits から git-cliff が bump(feat→minor / fix→patch、major は手動)。
デプロイごとに自動タグ、push は手動。
6. まとめ(再現のヒント)
- ヒットストップは 「仮想時計 pause + 実時計デッドライン」 のデッドマン構造にすると、 状態遷移で凍りっぱなしにならず、シェイクの「フリーズ→ガクッ」も時計の構造から勝手に出る
- 毎フレーム再レイアウト型のレンダラなら、フラッシュは期限切れ=何もしないで自動復元=宣言的
- 減衰・色・閾値は純関数+テスト。画面全体演出は最大値で1回、対象別演出は対象ごと
wasm-opt -all禁止。rustc が吐く feature だけ明示有効化。実ブラウザで instantiate 検証を
AI でゲームを作る人へ(この記事の本題)
上の4つは、どれも LLM が黙っていたことです。「大富豪を作って」で LLM が吐くコードに、
デッドマン構造のヒットストップも、wasm-opt の feature 明示も、低HPで Back も光らせる配慮も、
入っていませんでした。手触りは、人間が知識として持ち込んで初めて生まれる。
AI と組んでゲームを作るなら、コードを書かせる前に game feel の語彙を自分が持ち、プロンプトで明示的に要求する必要があります。 「juice を足して」では足りない—— 「着弾で 50ms ヒットストップ、二乗減衰のシェイク、被弾で HPバーをフラッシュ」まで 言語化できて初めて、LLM は正しく実装できる。この記事の実測値と設計は、その語彙のたたき台です。
AI にコードを書かせる時代に、人間の担当は「何を気持ちよさと呼ぶかを知っていること」に 移った。game feel は、AI がまだ肩代わりしてくれない数少ない領域のひとつです。
参考(出典)
- Martin Jonasson & Petri Purho, "Juice it or lose it"(juice の実演)
- Jan Willem Nijman (Vlambeer), "The Art of Screenshake"(30 の微調整)
- Steve Swink, "Game Feel"(理論の原典)
- Frank Thomas & Ollie Johnston, "The Illusion of Life"(アニメ12原則)