[Astro #87] LAYER FIGHTER 始動 ─ React Three Fiber でVRM格闘ゲームのトレーニングモードを作る
はじめに
前回の記事 ではグラディウス風STG制作の総仕上げとして、無限ループバグの修正と VOICEVOX による効果音統合を行いました。
[Astro #86] WitchadiusのED無限ループ修正とVOICEVOX(猫使ビィ)によるシステムボイス実装 // PROTOCOL.LAIN
エンディング周りの同期的フラグ管理とクレジットの描画バグをお掃除。IndexedDBによる事前キャッシュ機構のパイプラインに、猫使ビィのパワーアップシステムボイスを音量1.2で完璧に配線した全レイヤーのリファクタリングログ。
lain-lab.comSTG はひとまず形になったので、今度は完全に毛色の違うジャンル、格闘ゲームに挑戦してみることにしました。
タイトルは仮で 「LAYER FIGHTER」。
バーチャファイター風の3D格闘ゲームを目指します。
いきなり対戦相手のAIを作るのは難易度が高いので、まずは入力テストとアニメーション挙動を確認できる トレーニングモード から作っていきます。
動画:
目指す画面
参考にしたのは、Twitter で見かけた個人開発者の「VF Input Sandbox」のような画面です。
- 中央にキャラクター(VRM)を配置
- 左上に現在の状態(IDLE / KICKING など)を表示
- 右上にテンキー表記の方向入力とボタン入力を可視化
- 下部に入力ログをスクロール表示
格ゲーらしさを出すための「フレーム数表示」も入れます。STATE: [KICKING] (FRAME 13/28) のように、技の進行度がリアルタイムで見える形にしました。
技術スタック
| レイヤー | 採用技術 |
|---|---|
| フレームワーク | Astro + React |
| 3D描画 | React Three Fiber |
| キャラクター | @pixiv/three-vrm |
| アニメーション | @pixiv/three-vrm-animation (.vrma) |
| 音響 | THREE.AudioListener / シングルトン化 |
| 入力 | Keyboard Event + Gamepad API |
VRM と .vrma を組み合わせる構成は ADV(アドベンチャーモード)でも採用していたので、そのコードを下敷きにしつつ格ゲー用に作り直す方針です。
まずは状態管理から
格闘ゲームの本質は 入力 → 状態遷移 → アニメーション再生 の連鎖です。これをきちんと設計しないと、後から「パンチ中に歩けてしまう」「キックがキャンセルできてしまう」といったバグが入り込みます。
最初に定義した状態(FighterState)はこれだけです。
type FighterState =
| 'IDLE' | 'WALKING_F' | 'WALKING_B'
| 'CROUCHING' | 'JUMPING'
| 'PUNCHING' | 'KICKING' | 'GUARDING' | 'HITSTUN';
そして、状態ごとに再生するアニメーションIDをマップで持ちます。
const ANIM_MAP: Record<FighterState, string> = {
IDLE: 'IDLE_CHANGE_POSE',
WALKING_F: 'SYS_RUNNING',
WALKING_B: 'SYS_RUNNING',
CROUCHING: 'SYS_CROUCHING',
JUMPING: 'SYS_RUNNING',
PUNCHING: 'SYS_PUNCH',
KICKING: 'SYS_KICK',
GUARDING: 'IDLE_CHANGE_POSE',
HITSTUN: 'IDLE_CHANGE_POSE',
};
このマップを介すことで「状態が変わったらアニメも自動で切り替わる」仕組みになります。
60FPS固定のゲームループ
格ゲーは「何フレームで技が出るか」がすべてです。ブラウザの requestAnimationFrame は環境によって 60fps / 120fps と変動するので、固定フレーム化が必須でした。
const FT = 1000 / 60;
let acc = 0;
const tick = (now: number) => {
acc += now - lastTime;
lastTime = now;
while (acc >= FT) {
acc -= FT;
// ← ここで1フレーム分のゲームロジック
}
animId = requestAnimationFrame(tick);
};
accumulator パターンと呼ばれる定番の方式です。120fps モニターでも 60fps モニターでも、技のフレーム数が変わらず一定の感覚で遊べます。
物理状態は useRef で
React の状態管理(useState)で毎フレーム位置を更新するとレンダリングが大量に走ってしまいます。そこでキャラの物理情報は useRef に閉じ込めて、React のレンダリングを介さずに直接書き換える設計にしました。
interface PlayerPhysics {
posX: number;
posY: number;
velY: number;
isGrounded: boolean;
facingRight: boolean;
}
const physicsRef = useRef<PlayerPhysics>({
posX: -1.5, posY: 0, velY: 0,
isGrounded: true, facingRight: true,
});
そして useFrame の中でこの Ref を参照して、毎フレーム VRM の position と rotation に反映します。
useFrame((_, delta) => {
if (groupRef.current) {
const p = physicsRef.current;
groupRef.current.position.set(p.posX, p.posY, 0);
groupRef.current.rotation.y = p.facingRight
? ROT_FACING : ROT_FACING + Math.PI;
}
});
これで「左右に歩く」「ジャンプする」「向きを変える」が滑らかに動くようになりました。
ジャンプとしゃがみ
ジャンプは単純な放物線運動です。↑ 入力で初速を与え、毎フレーム重力で減速、地面(posY = 0)で停止します。
const JUMP_VY = 0.18; // ジャンプ初速
const GRAVITY = 0.012; // 重力加速度/frame
// 入力時
if (newDpad === '8' && p.isGrounded) {
p.velY = JUMP_VY;
p.isGrounded = false;
}
// 毎フレーム
if (!p.isGrounded) {
p.velY -= GRAVITY;
p.posY += p.velY;
if (p.posY <= 0) {
p.posY = 0; p.velY = 0; p.isGrounded = true;
}
}
斜めジャンプ(テンキーの 7 / 9)にも対応していて、左右方向の初速も同時に付与しています。
しゃがみは少し悩みました。↓ を押している間ずっとアニメを再生するのではなく、最終フレームでポーズを止めたいのです。three.js の AnimationAction には clampWhenFinished というプロパティがあって、これと LoopOnce を組み合わせると実現できます。
const CLAMP_ANIMS = new Set(['SYS_CROUCHING']);
const isClamp = CLAMP_ANIMS.has(id);
newAction.setLoop(
isClamp ? THREE.LoopOnce : THREE.LoopRepeat, Infinity
);
newAction.clampWhenFinished = isClamp;
パンチやキックも同様に LoopOnce にして、技後にIDLEへ遷移する形にしました。
入力可視化HUD
格闘ゲームのトレーニングモードといえば、入力履歴の可視化です。バーチャファイターやストリートファイターの開発者モードでお馴染みの、テンキー表記とボタン表示を実装しました。
[FRAME 3118] 6 + K → STATE_CHANGE: [IDLE] → [KICKING]
[FRAME 3117] 6 (DPAD_RIGHT)
[FRAME 3116] 2 (DPAD_DOWN)
[FRAME 3115] 8 (JUMP)
[FRAME 3114] 5 (NEUTRAL)
右上には常にテンキーグリッドが表示されていて、今押している方向がハイライトされます。ボタンも × (Z): P、○ (X): K、□ (C): G、△ (V): E のように、PS4のシンボルとキーボードキーを併記する形にしました。
これがあると、コンボの組み立てや技の入力タイミングが目で確認できて、デバッグが圧倒的にやりやすくなります。
PS4コントローラー対応
USBで接続している PS4 純正コントローラーを使いたかったので、ブラウザ標準の Gamepad API を採用しました。毎フレーム getGamepads() で状態を polling します。
const PAD = {
CROSS: 0, // × → P (Punch)
CIRCLE: 1, // ○ → K (Kick)
SQUARE: 2, // □ → G (Guard)
TRIANGLE: 3, // △ → E (Elbow)
DPAD_UP: 12,
DPAD_DOWN: 13,
DPAD_LEFT: 14,
DPAD_RIGHT: 15,
};
// 毎フレーム
const gamepads = navigator.getGamepads();
const pad = gamepads[0];
if (pad?.buttons[PAD.CROSS]?.pressed) buttons.push('P');
左スティック(axes[0] と axes[1])にも対応していて、Dpad とスティック入力を OR で取っています。接続検出は gamepadconnected イベントで行い、HUDに ● PS4 CONNECTED と表示するようにしました。
アニメーション速度の調整
VRMアニメーションは VRoid Hub などのフリー素材からお借りしているのですが、格闘ゲームに使うには動きが遅すぎることが多いです。three.js の AnimationAction には timeScale というプロパティがあって、これを上げると倍速で再生できます。
const ANIM_SPEED: Partial<Record<string, number>> = {
'SYS_PUNCH': 1.8, // パンチ:1.8倍速
'SYS_KICK': 1.6, // キック:1.6倍速
'SYS_CROUCHING': 1.2, // しゃがみ:少し速め
};
newAction.timeScale = ANIM_SPEED[id] ?? 1.0;
これで「もっさりした感じ」が一気に解消されて、格ゲーらしいキレが出るようになりました。
Moves.json で技パラメーターを外部化
ここまでで動作の骨格はできましたが、技ごとのパラメーター(硬直時間、ダメージ、ノックバック量など)をコードに直書きしていると、調整するたびにコードを編集することになります。これは絶対に破綻するので、早めに Moves.json に外部化しました。
{
"moves": [
{
"id": "PUNCH_L",
"state": "PUNCHING",
"animId": "SYS_PUNCH",
"strength": "light",
"startup": 8,
"active": 4,
"recovery": 24,
"hitstun": 12,
"knockback": 0.3,
"damage": 5,
"hitboxes": [
{
"offsetX": 0.5, "offsetY": 1.0,
"width": 0.4, "height": 0.3, "depth": 0.3
}
]
},
{
"id": "KICK_M",
"state": "KICKING",
"animId": "SYS_KICK",
"strength": "medium",
"startup": 10,
"active": 5,
"recovery": 40,
"hitstun": 20,
"knockback": 0.8,
"damage": 10,
"hitboxes": [
{
"comment": "すね(startup中)",
"offsetX": 0.5, "offsetY": 0.8,
"width": 0.3, "height": 0.2, "depth": 0.2
},
{
"comment": "足先(active以降)",
"offsetX": 1.3, "offsetY": 1.2,
"width": 0.4, "height": 0.3, "depth": 0.3
}
]
}
]
}
startup は技が出るまでのフレーム数、active は当たり判定が出ている期間、recovery は技後の硬直フレーム数。格闘ゲームの定番パラメーターです。これで「キックがちょっと遅い」と感じたら startup の数値を変えるだけで調整できます。
ヒットボックスの可視化
判定の調整には可視化が必須です。食らい判定(青)と攻撃判定(赤)をワイヤーフレームのボックスで表示するようにしました。
<mesh ref={atkRef}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="#ff0000" wireframe />
</mesh>
ジオメトリは args={[1, 1, 1]} で正規化しておいて、scale プロパティでサイズを変える方式にしました。これだと毎フレーム新しいジオメトリを作る必要がなく、軽量です。
キックには 2つのヒットボックス(すねと足先)を持たせていて、startup 中はすね判定だけ、active 以降は両方表示される仕組みにしました。本物の格ゲーはこれをもっと精密に時間制御しているはずですが、まずは簡易版でいきます。
敵キャラを配置
トレーニングモードらしく、攻撃してこないサンドバッグ役の敵を配置しました。Assets.json に ENEMY_HACKLORD というIDを追加して、別のVRMモデルをロードします。
"vrm": [
{
"id": "PLAYER_VRM",
"name": "Player Character",
"file": "/models/lain10.vrm"
},
{
"id": "ENEMY_HACKLORD",
"name": "Enemy Character",
"file": "/models/hacklord.vrm"
}
]
そして3Dシーン内で両方を描画して、それぞれにヒットボックスを付けました。プレイヤーは右向き(facingRight: true)、敵は左向き(facingRight: false)で初期配置することで、向かい合った状態になります。
キャラ間の押し返し
最初はキャラ同士が重なってしまっていたので、押し返し処理を入れました。
const MIN_DIST = 1.0;
const dist = ep.posX - p.posX;
const absDist = Math.abs(dist);
if (absDist < MIN_DIST) {
const overlap = (MIN_DIST - absDist) / 2;
p.posX -= overlap * Math.sign(dist);
ep.posX += overlap * Math.sign(dist);
}
2キャラのX距離が1.0未満になったら、重なり分の半分ずつ反対方向に押し返します。これだけで「お互いを押し合う」感じが出て、一気に格ゲーっぽくなりました。
ヒット判定の実装
最後の山場がヒット判定です。プレイヤーが攻撃中(PUNCHING/KICKING)で、かつ active フレームの期間中に、攻撃ボックスと敵の食らいボックスがX軸で重なっていたら「ヒット」と判定します。
const isAttacking = ['PUNCHING', 'KICKING'].includes(stateRef.current);
const move = moves.moves.find(m => m.state === stateRef.current);
if (isAttacking && move && ep.hitstun === 0) {
const af = actionFrameRef.current;
if (af >= move.startup && af < move.startup + move.active) {
for (const hb of getMoveHitboxes(stateRef.current)) {
const atkMinX = p.posX + (p.facingRight ? hb.offsetX - hb.width/2 : -hb.offsetX - hb.width/2);
const atkMaxX = p.posX + (p.facingRight ? hb.offsetX + hb.width/2 : -hb.offsetX + hb.width/2);
const defMinX = ep.posX - 0.5;
const defMaxX = ep.posX + 0.5;
if (atkMaxX > defMinX && atkMinX < defMaxX) {
// ヒット!
audioController.playSe('FTG_SE_HIT_LIGHT', move.damage / 10);
ep.hp = Math.max(0, ep.hp - move.damage);
ep.hitstun = move.hitstun;
ep.posX += p.facingRight ? move.knockback : -move.knockback;
break;
}
}
}
}
// hitstun カウントダウン
if (ep.hitstun > 0) ep.hitstun--;
ここで ep.hitstun === 0 のチェックを入れているのがポイントで、これがないと1回の active 期間中に何度もヒットを取ってしまいます。そして毎フレーム hitstun をデクリメントすることで、ヒット後しばらく経つと再び攻撃を受けられるようになります。
実際にプレイすると、敵が「ダメージ → のけぞり → 後ろに少し吹き飛ぶ → 動けるようになる」という一連の流れが綺麗に出るようになりました。
BGM について
タイトル画面とトレーニングステージのBGMは、毎度お世話になっている 日本一フリーBGM さんからお借りしました。
- タイトル: 「Sasa・朝・Routine」
- トレーニング: 「潮風ループ・シグナル」
サイバーパンクな世界観に対して牧歌的な選曲ですが、敢えてのギャップ狙いで採用しています。Wired Brawl というタイトル候補もあったのですが、最終的に「LAYER FIGHTER」に落ち着きました。
AudioController のシングルトン化
BGMとSEを統一的に扱うために、AudioController クラスをシングルトンで実装しました。
class AudioController {
private static instance: AudioController | null = null;
private listener: THREE.AudioListener;
private seCache: Record<string, AudioBuffer> = {};
public attachCamera(camera: THREE.Camera) {
camera.remove(this.listener);
camera.add(this.listener);
}
public async preloadSe(name: string, url: string) { /* ... */ }
public playSe(name: string, volume = 0.5) { /* ... */ }
public playBgm(url: string, volume = 0.3) { /* ... */ }
}
export const audioController = AudioController.getInstance();
SE は事前に preload してメモリ上にバッファリングしておくことで、ヒット時の発音遅延をゼロにしています。BGM は1曲ずつ差し替え、SE は重なって鳴れるようにワンショットの THREE.Audio を毎回生成する形にしました。
ハマったポイントとして、最初は R3F のカメラに AudioListener をアタッチし忘れていてBGMが鳴りませんでした。audioController.attachCamera(camera) を Canvas 内のコンポーネントで呼ぶことで解決しました。
動作確認
最終的に、こんな機能が揃いました。
- VRMキャラクターの左右移動・斜めジャンプ・しゃがみ
- パンチ・キックの3段階モーション(startup / active / recovery)
- 入力履歴のテンキー表記HUD
- 敵キャラクターとの押し返し
- ヒット判定とノックバック、HP管理
- 効果音とBGMの再生
- PS4純正コントローラー対応
ヒット時にコンソールにこんなログが出ます。
💥 HIT! damage:10 hp:90 knockback:0.8
💥 HIT! damage:10 hp:80 knockback:0.8
💥 HIT! damage:5 hp:75 knockback:0.3
格ゲーらしい手応えのある画面になってきました。
まとめと次回予告
今回はトレーニングモード(攻撃してこない敵に対する打撃テスト環境)まで実装しました。やってみて感じたのは、格闘ゲームは フレーム単位の状態管理 が本当に厳密でないと破綻するということです。STG とは違った種類の難しさがあって、新鮮でした。
次回以降はおそらく次のような順で進めます。
- ガード(防御)処理の追加
- CPU相手のシンプルなAI(待機・接近・攻撃の判断)
- ラウンド制と勝敗判定
- ヒットエフェクト(パーティクル / フラッシュ)
- VS対戦モード
JSON で技パラメーターを外部化した恩恵で、ここからは数値の調整だけで色々な技を増やせるようになりました。小・中・大の3段階攻撃や、必殺技コマンドの追加もそう難しくはないはずです。
VRMモデルと自作ゲームエンジンの相性は予想以上に良くて、これは正直なところ「もっと早くやればよかった」と感じています。引き続き格ゲー編、もう少し続けてみます。