寝ルダの伝説 〜3人の勇者と眠らない男〜

https://github.com/KOU050223/the-legend-of-nelda

TypeScript

React

Node.js

堀大輔を救いたい 俺たちが供養しないと......

ウオミー

アリス

無音

推しアイデア

30分睡眠 「ショートスリーパー」の商標保有者

作った背景

時:ホットな男、堀大輔 楽器:オカリナ 伝説: 配信で伝説を作った リメイク -> 堀大輔をネムダのラスボスとしてリメイク 楽器 -> オカリナで眠らせる

推し技術

無駄なテスト量... ちゃんと寝た方がいいんじゃないですかって言うのやめてもらっていいですか😡

プロジェクト詳細

The Legend of Nelda

3人の大輔たちを操作し、ショートスリーパー堀大輔の魔の手から世界を救う物語。 image

image

image

ハックツハッカソン〜モサカップ〜2026〜にて作成 テーマ:「伝説・時・リメイク・楽器」

作った背景

時のひと:堀大輔を救いたい!

ゼルダの伝雑の3勇者のやつを参考に三猿(見ざる・聞かざる・言わざる)を協力ゲームに落とし込みたかった。

よくある協力ゲームは「アタッカー / ヒーラー / タンク」のように 強みで役割を分ける。 本作は逆で、弱み(制約)で役割を分ける

  • 見ざる → 常に踊っているので視界が揺れて状況を把握できない
  • 言わざる → 何を言っても**「わっしょーい」としか伝わらない**
  • せざる → キーボードもマウスもコントローラーも触れない

制約を押し付けるだけだと理不尽なので、それぞれに制約と表裏一体の強みを持たせた。

Pay大輔(言わざる) 「何をすべきか」を知っている ── でも言葉で説明できない オドルノDaisuke(見ざる) 「そこへ行って何とかする」 ── でも自分では状況が見えない オラ大輔(せざる) 「普通ではできない方法で実行する」 ── でも普通の操作ができない

答えを一番よく知っている人が、それを一番説明できない。

この構造が、そのまま「3人でやる必然性」になっている。

推し技術

  1. 音声認識 + MediaPipe によるキーボードレス操作(オラ大輔 MediaPipe)
  2. 音声を「わっしょーい」へ変換して中継する(Pay大輔 / LiveKit)
  3. オカリナの音程認識をゲームコマンドにする(Web Audio API + ピッチ検出)
  4. AI堀大輔による読み上げ (Fish Audioで頑張って作った)

プロジェクト詳細

ゲーム概要

3人のプレイヤーがボス「堀大輔(睡魔)」の1つのアリーナで戦う、1マップ完結型の3Dボスアクション

  • 3人同時プレイ / 自由移動あり
  • 敵はボスのみ(雑魚敵なし)
  • 1戦あたり 5〜8分
  • HPが0になると死亡ではなく 「布団に入って寝ようとする」。他プレイヤーが駆けつけて「寝るな!」入力を連打すると蘇生する

勝利条件はボスのHPを0にすること。

開発の進め方:まず1人用で作った

最終形はいきなり3人分割だが、最初から情報と操作を3人へ割ると、ゲームシステム自体が成立しているか検証できない

そこで Phase 1 では、あえて

1人で操作して普通に成立するボス戦

を先に作り切った。固定カメラ・左回避 / 右回避 / ガード / 攻撃の4操作のみ。

その上で Phase 2 で、この1人が使っていた 視覚情報・聴覚情報・判断・操作 を3人へ分解した。 「面白いボス戦」であることを先に確定させてから制約を乗せる、という順序にした。

無駄な制約たち トライ大輔

1. オラ大輔:リアルの手で操作する

「せざる」の力により、Keyboard / Mouse / Controller を一切使えない

両手を画面前に上げ、Webカメラで追跡してゲーム操作へ変換する。

memo:ここ追記

2. Pay大輔:「わっしょーい」変換ボイスチャット

「言わざる」の力により、本人が何を喋っても他プレイヤーには「わっしょーい」としか聞こえない

Pay本人: 「右の祭壇!右!右!」 他プレイヤー: 「わっしょーい!わっしょーい!!」

LiveKit(WebRTC)でボイスチャットを実装した上で、Pay大輔だけ生音声をそもそも送信しない

代わりに、ブラウザ内で声を解析して

  • intensity(声量)
  • durationMs(長さ)

だけを抽出し、reliable data topic nelda.wasshoi.v1 へ送る。受信側がその強さと長さに応じて「わっしょーい」を合成再生する。

{ "type": "...", "intensity": 0.8, "durationMs": 620 }

生音声・PCM・文字起こし・録音URLは一切送らない。 実装は以下で構成した。

モジュール役割
voice-activity-detector.ts発話区間の検出
adaptive-noise-gate.ts環境ノイズに追従する可変ゲート
system-wasshoi-engine.ts声量・長さ →「わっしょーい」生成

権限もサーバー側で固定した。トークン発行時に Pay へは canPublishSources: [DATA] のみ、Odoruno / Ora へ [MICROPHONE, DATA] を付与する。 クライアントから渡された role を信用しない設計にしてあるので、Pay大輔が細工して生声を流すことはできない。

情報量は最も多いのに、伝達能力は最も低い。 だから Ping・ジェスチャー・声量の強弱を駆使することになる。

3. オカリナ:音程をコマンドにする

マイクから吹いたオカリナの音程を判定し、ド・ミ・ソをゲームコマンドへ対応付けている。

マイク → pitchy(ピッチ検出) → NoteClassifier → NoteStabilizer → OcarinaCommand

pitchy で基本周波数を取り、12音階へ分類し、ゆらぎを安定化させてからコマンドにする。

ここで意識したのは 「対象外の音を丸めない」 こと。

/** * 音声基盤は12音階すべてを返す。ここは「ゲームが使う音だけ拾う」層で、 * 対象外の音を C / E / G へ丸めない。D を無理に DO や MI にすると、 * 吹き間違いが正解入力として通ってしまう。 */ export type OcarinaCommand = 'DO' | 'MI' | 'SO'; export const IGNORE = 'IGNORE' as const;

「近い音を拾ってあげる」親切心は、吹き間違いを正解として通してしまうので、明確に IGNORE を返す設計にした。

4. Game Logic を Pure TypeScript へ隔離する

Player Input Game Logic (Pure TypeScript) Game State / Events ┌───────────────┐ │ Rendering │ │ Audio │ │ HUD / UI │ └───────────────┘

React Component / R3F Component が戦闘ルールそのものを持たないようにした。判定は純粋関数で書き、結果を Game Event として発行し、描画・音・UIはそれを購読する。

resolvePlayerAction({ attack, action, inputTime }) // → { result: "PERFECT_DODGE", nextState: "COUNTER_WINDOW" }

これは方針ではなく、lint で機械的に検証している。

src/game/** から three / @react-three/* / react を import すると pnpm lint が落ちる(.oxlintrc.jsonno-restricted-imports)。

src/ ├─ game/ Game Logic(Three.js・Reactへ依存しない) ├─ input/ DOM/カメラ/マイク → PlayerAction ├─ rendering/ React Three Fiber ├─ audio/ Audio Cue ├─ ui/ HUD / イベントUI └─ store/ Zustand(表示状態のみ)

この分離のおかげで、Phase 2 で1人用を3人プレイへ分解するとき、戦闘ルール本体をほとんど書き換えずに済んだ。 入力源がキーボードからARマーカーや音声へ増えても、GameAction という共通の契約に変換するだけで済む。

副次的な効果として、戦闘ロジックがブラウザ非依存になるのでテストが速く書ける

5. モーション定義を1ファイルへ集約する

開発初期はモーション定義が3箇所へ散っていた。Blenderのビルドスクリプト、表示層の MOTION_CLIPScharacter-models.tsclip

どれか1つを足し忘れるとキャラが一切動かなくなるが、気付く手段が無かった。

そこで assets/motion-manifest.json 1本へまとめ、Blender(Python)側とTypeScript側の両方が同じファイルを読むようにした。

  • scripts/motion_manifest.py が「ビルドで作るクリップ」と clips の一致を確認
  • motion-manifest.test.ts が「clips」と「実際のGLBの中身」の一致を確認

両方向から突き合わせるので、まだ生成できないクリップを書くと両方のテストが落ちる

状態からクリップを決める部分は、総当たり表ではなく優先順ルールにした。 ボスだけで BossPhase(6) × AttackPhase(4) あるが、意味のあるマスはごく一部なので、実際に使う行だけを短く持てる。

6. アリーナの数値を「体感時間」から決める

アリーナは半径24の円。この数字は雰囲気で決めていない。移動速度4 units/秒から逆算した。

距離所要何のための値か
中心 → 端246.0秒ブルーライト照射から逃げ続けられる長さ
端 → 端4812.0秒散ったあと合流し直せる長さ
装置 → 隣の装置29.47.4秒結界で「間に合うか」の緊張が出る距離
最遠地点 → 最寄りの安全地帯17.34.3秒ボス攻撃の予兆時間の下限。これより短いと端にいる人が詰む

座標はすべて src/game/arena/arena.ts に Pure TypeScript で持ち、描画と判定の両方が同じ値を参照するARENA_RADIUS を1箇所直せば、床・岩の壁・木立・装置・安全地帯がすべて追従する。

使用技術

アーキテクチャ図

image

フロントエンド

"react": "19.2.8", "three": "0.186.0", "@react-three/fiber": "9.7.0", "@react-three/drei": "10.7.8", "zustand": "5.0.15", "typescript": "7.0.2", "vite": "8.3.0"

入力・通信

"@mediapipe/tasks-vision": "1.0.1", // 手の検出 "pitchy": "4.1.0", // ピッチ検出(オカリナ) "livekit-client": "2.17.2", // ボイスチャット "ws": "8.18.0" // WebSocket(権威サーバー)

インフラ

cloudflare workers: 認証サーバーのデプロイ render: websocketのデプロイ

3Dアセット

BlenderMCP でAI(Astraとか)が制作し、GLBへ書き出している。座標系の事故が多発したので、ルールをドキュメント化して固定した。

| 空間 | 上方向 | キャラの正面 | | Blender の編集 | +Z | -Y | | GLB(書き出し) | +Y | +Z |

  • 原点は足元(Y=0が接地面)。腰や重心に置かない
  • 単位はメートル。等身大の実寸で作る

向きが揃っていないモデルは、ゲーム側で回さずBlenderで直す。表示側に個別の補正を積むと、モデルが増えるたびに補正が散らばるため。

モーション

https://www.mixamo.com を使用してモーションを取得し、純粋なモデルとモーションをそれぞれトリ合体しました

image

開発環境・品質管理

| 分類 | 採用 | | Runtime | Node.js 24.19.0 | | Package Manager | pnpm 11.25.0 | | Lint / Format | oxlint / oxfmt(Rust製で高速) | | Test | Vitest + React Testing Library | | 環境固定 | Nix (flake.nix) + direnv | | Git hooks | lefthook | | CI | GitHub Actions | | デプロイ | Cloudflare Workers (Static Assets) |

環境は Nix で Node.js と pnpm のバージョンごと固定した。nix develop すれば全員が同じ環境になる。

CI は PR と main への push で以下を順に実行する。

install → lint → format:check → typecheck → test → build

デプロイは Cloudflare Workers Builds(Git連携)が担当し、PR作成時にブランチプレビューURLが自動発行されてPRコメントへ投稿されるので、レビュー時に実物を触って確認できる。

テスト

前日にあったフロントエンドカンファレンスでテスト大事だなぁって思ったのでAGENTS.mdにテスト周りの整備をしました

テスト名には実装名ではなく「保証したい動作」を書くルールにしました。「storeを別のものに替えたら / 関数名を変えたら名前が嘘になるなら、それは実装の名前になっている」という判定基準まで明文化して、AIエージェントが it('setSleepinessが呼ばれる') みたいなテストを量産するのを防ぎます

バグ修正は「再現テストを書く → 失敗することを確認 → 直す → 通ることを確認」の手順を原則化。

本作はB級3DアクションRPGの「壊れて見えるが内部はちゃんと動いている」手触りを狙っているので、意図して残すバグは *.bug.test.ts に隔離しています。「現在どのバグが意図なのか」がファイル一覧で読める状態にしておかないと、善意のリファクタで演出が消えてしまうのでカバレッジ率はKPIにしません。壊れると影響が大きいロジック・境界値・状態遷移・過去に発生したバグがテストされていることの方が大事

方針全体は docs/testing-strategy.md 書き方の手順は docs/testing-guide.md に分離して、AGENTS.md からは参照だけ貼る構成にする src/AGENTS.md に置くことで、src/** を編集するコーディングエージェントに確実にルールが届くようにしているのが地味にポイントです。

最終カバレッジ

================ Coverage summary ================ Statements : 67.2% ( 3959/5891 ) Branches : 61.5% ( 2314/3762 ) Functions : 69.02% ( 867/1256 ) Lines : 69.1% ( 3581/5182 )

AIガードレール

AGENTS.md / docs/ に仕様と設計方針を先に書き、Agentがそれを参照しながら実装する運用にした。

特に効いたのが、前述の 「Game Logic に three / react を import させない」を lint ルールにしたこと。 方針をドキュメントへ書くだけだと、AIも人間もいずれ破る。機械的に落ちるようにしておくと、設計が勝手に守られる。

同様に、モーション定義のマニフェスト突き合わせテストも「壊れたら気付ける」ための仕組みとして入れている。

これからの展望

  • オカリナ最終フェーズの役割分担を確定させる
  • 観客用画面の実装
  • ゲーム内で睡魔を倒したら、現実で寝ている人を起こす仕組みへの接続

おつかれ堀大輔

1週間配信30分睡眠フィナーレ

https://www.youtube.com/live/uFPpeBlsWOg?si=VS3ig_geGSPrgiCJ

ウオミー

@KOU050223