TL;DR
- 作ったもの: TypeSafe AI の意思決定モデル Jev (System One) をゲームループに組み込んだブラウザRPG 『JEV RETRO DUNGEON』
- AIは一文字もテキストを生成しない。ダンジョンの属性、モンスターの遺伝子、敵の行動、BGMの旋法など20の判断を「選ぶ」だけ(→ 3章)
- リポジトリ内の画像ファイル0枚・音声ファイル0個・ランタイム依存0。ドット絵もBGMも全部コードが生成する
- モンスターは 約2.2億通り(実測・全パターン描き分け検証済み)
-
APIキー不要。内蔵シミュレーターで
npm run devから30秒で遊べます - ライセンス: MIT License(商用・個人問わず改変・再利用自由。→ 11章)
👉 df-yamashitamasashi/jev_blog — articles/03
↑ これ、全部コードです。画像ファイルは1枚も使っていません。
目次
- 1. まず30秒で動かす
- 2. この記事の主役:AIに文章を書かせないという選択
- 3. Jevの担当範囲とコードの担当範囲
- 4. ダンジョンをどう生成させているか
- 5. モンスターをどう生成させているか
- 6. BGMをどう作曲させているか
- 7. 60fpsを止めないための設計
- 8. 討伐カードという"共有される出口"
- 9. アーキテクチャと数字
- 10. 遊び方
- 11. ライセンス
- 12. まとめ
1. まず30秒で動かす
APIキーは要りません。キーが無いときは内蔵のオフライン推論シミュレーターが即座に応答するので、そのまま遊べます。
git clone https://github.com/df-yamashitamasashi/jev_blog.git
cd jev_blog/articles/03/jev-retro-dungeon
npm install && npm run dev
# http://localhost:3000/
| ダンジョン探索 | コマンドバトル |
|---|---|
![]() |
![]() |
| たいまつの明かりの中を進む。青いタイルが下階への階段、金色が宝箱 | 敵の名前は「DNAハッシュそのもの」。フレーバー名は付けていません |
矢印キーで歩くとエンカウントします。B キーで即座に戦闘が始まるので、モンスター生成だけ見たい人はこちらをどうぞ。絵だけを延々とリロールしたい場合は http://localhost:3000/gallery.html を開いてください。
2. この記事の主役:AIに文章を書かせないという選択
ゲームにAIを組み込む、と言うとまず思い浮かぶのは「NPCと自由に会話できる」「シナリオが動的に生成される」といった 生成型LLM(System Two) の使い方です。これは強力ですが、ゲームループの中では致命的な弱点があります。
60fpsのゲームは、1フレームあたり16.7ミリ秒しか猶予がありません。 2〜4秒かかる生成AIをフレーム内の判断に使うことはできません。
そこで本プロジェクトでは、逆のアプローチを取りました。
AIに文章を書かせない。AIには「選ばせる」。
Jev (System One) は、自然言語ではなく型付けされた構造化データを返す意思決定モデルです。パースも要らず、switch 文にそのまま流せます。
| 観点 | 生成型LLM(System Two) | 意思決定モデル(Jev / System One) |
|---|---|---|
| 向いている仕事 | 会話、シナリオ、世界観テキスト | 状態判定、パラメータ決定、行動選択 |
| 出力 | 自然言語・Markdown | 型付き構造化データ(Choice / Score / Noul) |
| 後処理 | パース、バリデーション、リトライ | 不要(型が保証される) |
| ゲーム内の居場所 | 非同期のイベント・会話シーン | ゲームループの中 |
3. Jevの担当範囲とコードの担当範囲
ここが本題です。「どこまでAIに任せて、どこからはコードで守るか」 の線引きを、先に全体像として出します。以降の章は、この表を各要素で詳しく見ていくだけです。
3.1 Jevに投げている問いは全部で20個
8回のリクエストに分けて、合計20問を投げています。返ってくるのは Choice / Score / Noul の3種類だけです。
| # | いつ | 質問キー | 型 | 選択肢 | 決まるもの |
|---|---|---|---|---|---|
| 1 | 階段を降りたとき | floorTheme |
Choice | 5 | フロアの属性(通常/紅蓮/氷結/虚無/黄金) |
| 2 | 〃 | dangerScore |
Score | 1〜4 | フロアの危険度 |
| 3 | 1歩あるくごと | shouldEncounter |
Noul | 0〜1 | 敵と遭遇すべきか |
| 4–11 | 敵の出現時 |
bodyGene 〜 paletteGene
|
Choice×8 | 12/12/10/12/10/10/8/16 | 遺伝子8スロット |
| 12 | 敵のターン | monsterAction |
Choice | 4 | 通常攻撃/呪文/痛恨の一撃/防御 |
| 13 | 宝箱を開けたとき | equipmentSlot |
Choice | 4 | ぶき/たて/よろい/そうしょく |
| 14 | 〃 | equipmentTier |
Score | 1〜4 | 武具のレアリティ |
| 15 | 強敵の撃破後 | prefixName |
Choice | 5 | 武器に宿る二つ名 |
| 16 | 〃 | attackBonus |
Score | 1〜4 | 攻撃力の上昇幅 |
| 17 | フロアのBGM | scale |
Choice | 2 | 旋法(スケール) |
| 18 | 討伐カード生成 | epicTitle |
Choice | 5 | プレイヤーに贈る称号 |
| 19 | 〃 | dangerScore |
Score | 1〜4 | 討伐難度 |
| 20 | 〃 | flavorLore |
Choice | 4 | カードのフレーバー |
3.2 コードが決めていること(=Jevに訊いていないこと)
逆に、以下は一切Jevに訊いていません。ここが壊れるとゲームが成立しないからです。
| 領域 | コードが決めているもの |
|---|---|
| 構造 | 迷路の壁・床の配置、階段と宝箱の座標、たいまつの可視範囲 |
| 数値 | モンスターの実ステータス(遺伝子補正の合算 × 階層倍率)、レアリティ判定、属性の推定、DNAハッシュ、武具の実数値、レベルアップの伸び |
| 確率 | 会心の一撃 18% / 逃走成功 65% / 宝箱から装備が出る 50% / エンカウントの歩数天井(3歩・7歩) |
| 音楽 | BPM、ルート音、メロディとベースの音型、矩形波デューティ比、ドラムの手数 |
| 表現 | カラーランプの導出、ドット絵の合成、8言語の文言、描画順 |
線引きの基準はシンプルです。
「破綻したら詰むもの」はコード。「外しても遊べるもの」はJev。
到達不能な迷路、HPがマイナスの敵、聴くに耐えないBGM——これらは1回出ただけで体験が壊れます。一方、フロアが紅蓮でも氷結でも、敵が防御を選んでも攻撃を選んでも、ゲームは成立します。後者だけをAIに渡す。
3.3 3つの型の実物
共通しているのは、判断基準をコードではなく日本語の criteria / legend に書くという点です。ゲームバランスの調整が「if文の書き換え」ではなく「文面の推敲」になります。
// Choice: 選択肢とその意味を渡す
floorTheme: {
type: "choice",
instructions: "ダンジョン地下階層の環境属性を選択してください",
criteria: {
normal: "湿り気を帯びた古の洞窟(通常属性)",
crimson: "マグマの熱気漂う紅蓮の回廊(火炎属性)",
frost: "氷柱と冷気に包まれた凍土の地下迷宮(氷結属性)",
shadow: "紫の瘴気が渦巻く深淵の魔窟(暗黒属性)",
golden: "金色の光が微かに差し込む古代宝物殿(黄金属性)",
},
},
// Score: 1〜4 の各段階が「どういう状態か」を言葉で定義する
dangerScore: {
type: "score",
instructions: "この階層の魔物の凶暴度とエンカウント危険度を評価してください",
legend: {
"1": "初級の洞窟、魔物の気配は疎ら",
"2": "中級の迷宮、適度な警戒が必要",
"3": "上級の魔境、強力な魔物が徘徊",
"4": "最深層の地獄、死と隣り合わせの試練",
},
},
// Noul: 真偽の確率が返る
shouldEncounter: {
type: "noul",
instructions: "この歩行ステップで魔物と遭遇(エンカウント)すべきですか?",
},
4. ダンジョンをどう生成させているか
src/usecases/gameDirectorUseCase.ts
役割分担:迷路はコード、性格はJev
3章の線引きを、ダンジョンで具体的に見ていきます。迷路の形はJevに作らせていません。
| 決めるもの | 担当 | 理由 |
|---|---|---|
| 壁・床の配置、階段・宝箱の位置 | コード(手続き的生成) | 到達不能なマップを絶対に作らないため |
| フロアの属性(通常/紅蓮/氷結/虚無/黄金) | Jev (Choice) | 「雰囲気」は基準を言葉で書ける |
| 危険度スコア 1.0〜4.0 | Jev (Score) | プレイヤーの状態を見て加減してほしい |
AIに構造を作らせると「宝箱が壁に埋まる」「階段に到達できない」といった破綻の検証が必要になります。そこを手続き的生成に任せ、Jevには「このフロアはどういう場所か」だけを決めさせる。これで破綻ゼロのまま、フロアごとの表情が変わります。
渡している状態
const state = {
floorNumber,
heroHpRatio: hpRatio,
heroLevel: hero.stats.level,
gold: hero.stats.gold,
stepsTaken: hero.stepsTaken,
// 瀕死かどうかを明示的に言葉で渡す
situation: hpRatio < 0.3 ? "critical_low_hp" : "stable",
};
situation のようにモデルが解釈しやすい語彙に変換して渡すのがコツでした。hpRatio: 0.24 という数値だけよりも、"critical_low_hp" という一語があるほうが判断が安定します。
返ってきた属性はそのまま3か所に波及します。
floorTheme ─┬─▶ タイルの配色(黄土色 / 溶岩赤 / 氷青 / 瘴気紫 / 黄金)
├─▶ BGMの旋法候補の絞り込み(→ 6章)
└─▶ モンスター生成時の state に同梱(→ 5章)
エンカウント判定は Noul + 歩数の天井
1歩ごとに Noul(真偽の確率)を訊き、0.40 以上ならエンカウントとしています。
const shouldEncounter = encAns?.type === "noul" ? encAns.noul >= 0.4 : true;
ただし確率判定だけに任せると「10歩歩いても敵が出ない」ことが起こります。レトロRPGとしてはテンポが死ぬので、ゲームループ側に歩数の天井を入れています。
// 3歩未満は判定すらしない / 7歩以上で確定エンカウント
if (!this.jevStatus.isThinking && this.stepsSinceLastBattle >= 3) {
if (this.stepsSinceLastBattle >= 7) {
const monster = await this.gameDirector.forceEncounter(hero, floor);
this.startBattle(monster);
} else {
// 「前回の戦闘からの歩数」を state に渡す(累計歩数ではない)
const { shouldEncounter, monster } = await this.gameDirector.checkEncounter(
hero,
floor,
this.stepsSinceLastBattle,
);
// Jevの判定に加えて、歩数に応じた確率も足す(5歩なら +60%)
const stepChance = (this.stepsSinceLastBattle - 2) * 0.2;
if ((shouldEncounter || Math.random() < stepChance) && monster) {
this.startBattle(monster);
}
}
}
AIの判断を「最終決定」ではなく「入力の1つ」として扱う。体験の下限を守るのはゲーム側の責任です。
5. モンスターをどう生成させているか
5.1 8つの遺伝子スロット
モンスターは CryptoPunks / CryptoKitties のように、8つの遺伝子スロットの組み合わせで定義されます。
| スロット | 種類数 | 例 |
|---|---|---|
| からだ | 12 | スライム / 獣 / 竜 / ゴーレム / 亡霊 … |
| め | 12 | 単眼 / 魔眼 / 蜘蛛の複眼 / 虚無の眼窩 … |
| くち | 10 | 牙 / 大顎 / 深淵の顎 / 鉄格子 … |
| つの | 12 | 悪魔角 / 一角 / 鹿角 / 兜 / 光輪 … |
| つばさ | 10 | 蝙蝠翼 / 天使翼 / 甲羅 / 背棘 … |
| しっぽ | 10 | 竜尾 / 蠍針 / 九尾 / 棍棒 … |
| オーラ | 8 | 紅蓮 / 氷霧 / 雷漿 / 星雲 … |
| はいしょく | 16 | 深海 / 地獄紅 / 王金 / 深淵紫 … |
12 × 12 × 10 × 12 × 10 × 10 × 8 × 16 = 221,184,000
約2.2億通り。 これは机上の掛け算ではありません。全74パーツを実際に32×32バッファへ描画して比較したところ、同じ絵になるパーツは1つもなく、16配色すべてが異なるカラーランプを生成することを確認しています。
5.2 Jevは「選ぶ」だけ
8スロット分の問いを1リクエストにまとめて投げます。スロットごとに8回叩くのではなく、1往復で8つの決定が返ってきます。
src/usecases/generativeMonsterUseCase.ts
const { response } = await this.jevClient.systemOne({
state: {
floorNumber: floor.floorNumber,
ambientElement: floor.ambientElement, // 4章で決まった属性
dangerScore: floor.dangerScore,
heroLevel,
heroHpRatio,
},
questions: {
bodyGene: {
type: "choice",
instructions:
"フロア環境と危険度に応じた魔物の基本骨格・種族を選定してください",
// 選択肢はDNAカタログからそのまま生成する
criteria: Object.fromEntries(
DNA_CATALOG.bodies.map((b) => [b.id, b.nameKey]),
),
},
eyesGene: { type: "choice" /* ... 12種 ... */ },
mouthGene: { type: "choice" /* ... 10種 ... */ },
hornsGene: { type: "choice" /* ... 12種 ... */ },
wingsGene: { type: "choice" /* ... 10種 ... */ },
tailGene: { type: "choice" /* ... 10種 ... */ },
auraGene: { type: "choice" /* ... 8種 ... */ },
paletteGene: { type: "choice" /* ... 16種 ... */ },
},
});
criteria をカタログから自動生成しているので、パーツを1つ足せばプロンプトも選択肢も自動で増えます。 AI側のコードには一切触りません。
5.3 遺伝子から「強さ」を合成する
各パーツにはステータス補正が定義されています。
{ id: "h_ram", nameKey: "battering_ram", drawType: 11,
statModifiers: { hp: 0, atk: 4, def: 3, agi: -1 } }, // 突進角:攻守が上がり鈍重
{ id: "w_insect", nameKey: "wasp_wings", drawType: 4,
statModifiers: { hp: 0, atk: 1, def: 0, agi: 5 } }, // 蜂翅:圧倒的に素早い
Jevが選んだ7スロット分の補正を合算し、階層による倍率を掛けて最終ステータスにします。
const totalMod = parts.reduce(
(acc, p) => ({
hp: acc.hp + p.statModifiers.hp,
atk: acc.atk + p.statModifiers.atk,
def: acc.def + p.statModifiers.def,
agi: acc.agi + p.statModifiers.agi,
}),
{ hp: 0, atk: 0, def: 0, agi: 0 },
);
const floorMult = 1.0 + (floor.floorNumber - 1) * 0.25; // 1階ごとに +25%
const maxHp = Math.max(15, Math.round(totalMod.hp * floorMult));
const attack = Math.max(6, Math.round(totalMod.atk * floorMult));
const defense = Math.max(3, Math.round(totalMod.def * floorMult));
const agility = Math.max(4, Math.round(totalMod.agi * floorMult));
// レアリティも遺伝子から導出する(竜型は無条件でボス)
const isBoss = bodyId === "b_dragon" || floor.floorNumber % 5 === 0;
const rarity = isBoss
? "Legendary"
: totalMod.atk + totalMod.def >= 28
? "Epic"
: totalMod.atk + totalMod.def >= 18
? "Rare"
: "Common";
つまり 「速そうに見える敵は実際に速い」。見た目と強さが別々に決まるのではなく、同じ遺伝子から両方が導かれます。 レアリティすらJevに訊いていません。遺伝子が決まれば自動的に決まるからです。
5.4 描画:2色から9トーンを導出する
16パレットは primary と secondary の2色しか持っていません。そこから9段階のトーンを決定論的に合成します。
export function buildRamp(primary: string, secondary: string): ColorRamp {
return {
shadowDeep: darken(primary, 0.68),
shadow: darken(primary, 0.4),
base: primary,
light: mix(primary, secondary, 0.45),
highlight: lighten(mix(primary, secondary, 0.6), 0.35),
accentDeep: darken(secondary, 0.45),
accent: secondary,
accentLight: lighten(secondary, 0.4),
// 完全な黒だと浮くので primary を微量だけ混ぜる
outline: mix("#000000", primary, 0.12),
};
}
パーツ描画側は BASE SHADOW ACCENT といった意味のコードだけを32×32バッファに書き込み、最後にランプで色を解決します。この分離のおかげで、配色を1つ足すだけで全1,382万通りのシルエットが新色で描き直せます。
下の画像が分かりやすいと思います。上段は「からだ」だけを変えたもの、下段は「はいしょく」だけを変えたものです。
6. BGMをどう作曲させているか
音声ファイルは1つも読み込みません。Web Audio APIのオシレーターで全部合成しています。
6.1 Jevに訊くのは「旋法」ただ1つ
ここは意識的にAIに任せる範囲を最小にしました。
| 要素 | 担当 | 決め方 |
|---|---|---|
| 旋法(スケール) | Jev (Choice) | フロア属性で絞った2候補から選択 |
| テンポ (BPM) | コード | 110 + min(floor × 4, 32) |
| ルート音 | コード | 階層を5音のリストで巡回 |
| メロディ/ベースの音型 | コード | 4パターンから階層と旋法で選択 |
| 矩形波のデューティ比 | コード | 紅蓮=0.25 / 氷結=0.125 / その他=0.5 |
| ドラムの手数 | コード | 4階以上=heavy / 2階以上=march / それ以外=simple |
src/usecases/bgmComposerUseCase.ts
// フロア属性で候補を絞ってから、Jevに最終選択させる
const scaleCandidates: ScaleType[] =
element === "crimson"
? ["phrygian", "harmonic_minor"] // 不穏・邪悪
: element === "frost"
? ["dorian", "whole_tone"] // 浮遊・冷たい
: element === "shadow"
? ["whole_tone", "phrygian", "harmonic_minor"]
: floorNumber >= 5
? ["harmonic_minor", "phrygian"] // 深層は緊張感
: ["minor", "dorian", "pentatonic"];
const { response } = await this.jevClient.systemOne({
state: { floor: floorNumber, element, role: "bgm_composer" },
questions: {
scale: {
type: "choice",
instructions:
"フロアの緊迫感と魔力属性に合致する音楽スケールを選択してください",
criteria: {
[scaleCandidates[0]]: "第1推奨スケール",
[scaleCandidates[1]]: "第2推奨スケール",
},
},
},
});
なぜ旋法だけなのか。 曲の「色」を決めるのは旋法です。フリジアンにすれば不穏になり、ホールトーンにすれば足元が消える。逆に音符の並びまでAIに任せると、聴けない曲が出る確率が跳ね上がります。ループBGMは何十回も聴かされるので、外れを引いたときの被害が大きい。
だから 「外しても聴ける範囲」を先にコードで確保し、その中で表情を変える1つのつまみだけをAIに渡す 構成にしました。生成AIをプロダクトに入れるときの一般解でもあると思います。
6.2 選ばれた旋法が音になるまで
BgmTrack という型付きデータに落としてから、シンセに渡します。
return {
floorNumber,
themeName: "Inferno Corridor (Floor 3)",
scale, // ← Jevが選んだ唯一の値
rootNote: 50, // MIDIノート番号
bpm: 122,
melodyPattern: [0, 1, 3, 4, 3, 1, 0, -1, 0, 3, 4, 6, 4, 3, 1, 0], // 音階インデックス
bassPattern: [0, 2, 3, 0, 4, 3, 2, 0],
pulseDuty: 0.25,
drumStyle: "heavy",
};
melodyPattern は音名ではなく音階上のインデックスです。だから旋法を差し替えるだけで、同じ音型のまま曲の性格が変わります。
// 旋法 = ルート音からの半音距離テーブル
dorian: [0, 2, 3, 5, 7, 9, 10, 12, ...], // 冒険・古代
phrygian: [0, 1, 3, 5, 7, 8, 10, 12, ...], // 溶岩・邪悪
pentatonic: [0, 3, 5, 7, 10, 12, 15, ...], // 東洋・ダンジョン
whole_tone: [0, 2, 4, 6, 8, 10, 12, ...], // 虚無・浮遊感
harmonic_minor: [0, 2, 3, 5, 7, 8, 11, 12, ...], // 決戦・緊張感
あとは rootNote + intervals[melodyPattern[i]] を周波数に変換してオシレーターを鳴らすだけです。主旋律は矩形波、ベースは三角波、パーカッションはホワイトノイズ+ハイパスフィルター——ファミコンAPU(矩形波×2・三角波・ノイズ)の声部構成をそのまま踏襲しています。
7. 60fpsを止めないための設計
ゲームループにネットワーク越しのAIを挟む以上、「遅い」「落ちる」「キーが無い」を全部前提にする必要があります。
フォールバックを"例外処理"ではなく"標準動作"にする
// APIキーが無ければ、通信を試みずに即座にオフライン推論へ
if (!this.apiKey) {
return {
response: this.simulateDecision(request),
latencyMs,
isSimulated: true,
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs); // 2秒で打ち切り
try {
const res = await fetch(`${this.baseUrl}/v1/systemone`, {
/* ... */ signal: controller.signal,
});
// ...
} catch (err) {
// タイムアウト・通信断・APIエラー — すべて同じ着地点へ
console.warn(
"[JevClient] Falling back to intelligent simulator due to error:",
err,
);
return {
response: this.simulateDecision(request),
latencyMs,
isSimulated: true,
};
}
オフライン時でもゲームはまったく同じように動きます。返却型が同じなので、呼び出し側には分岐が1つもありません。画面右上のランプの色だけが状態を示します(金=判断中 / 水色=シミュレーター / 緑=実API)。
APIキーを設定していない共同作業者も、CIも、この記事を読んだあなたも、npm install 直後からフルにゲームを触れます。
⚠️ 内蔵シミュレーターはモデルではありません
オフライン推論は Jev の代役であって、本物のモデルではありません。中身は決定論的なヒューリスティクスです。
型 オフライン時の挙動(実測) Choice 選択肢から一様ランダム(フロア属性のみ、浅い階層では normalも含めて抽選)Score 階層とHP残量から算出。B1F=1.0 → B3F=1.7 → B5F=2.4 → B8F=3.5 Noul 前回の戦闘からの歩数と危険度から算出。3歩=不成立、4歩=成立 実APIを繋ぐと、ここが文脈を読んだ判断に変わります。右上のランプが水色ならシミュレーター、緑なら実APIです。
なお、この記事を書きながら計測したところ、当初のシミュレーターは state のキー名が呼び出し側と食い違っていて(
hpRatioを読もうとしているのに送っているのはheroHpRatio)、Score が常に 1.3、Noul が常に 0.3 を返す状態でした。オフラインでは敵に遭遇できているのは100%「歩数の天井」のおかげ、という有様です。修正済みですが、AIの判断が完全に死んでも体験が壊れなかったという意味では、7章の設計の意図しない実証になっていました。
判断を待つ間もループは回り続ける
Jevへの問い合わせは async で投げっぱなしにし、requestAnimationFrame のループは止めません。応答待ちの間の入力は読み捨て、連打で同じターンが二重に走らないようガードしています。1フレームで例外が出てもループの連鎖が切れないよう、フレーム全体を try/catch で囲っています。
8. 討伐カードという"共有される出口"
モンスターを倒すと、その個体の討伐カードが生成されます。
- カード画像もその場でCanvasに描画(当然、素材画像なし)
- 800×1240px で書き出すので、そのままSNSに貼れます
- 8つの遺伝子コードと各スロットのステータス補正を全部載せてあるので、同じ個体を再現できます
- タイトルの
#0x…は8桁の16進DNA-ID。各スロットのカタログ番号を1桁ずつ並べたもので、約2.2億通りすべてに異なるIDが付きます
カードの称号(【剛剣の勇士】など)とフレーバーの選定にもJevを使っていて、ここでは戦闘の内容を state として渡しています。瀕死から逆転すると「不撓不屈の勇者」、余裕を持って沈めると「剛剣の勇士」という具合です。
「2.2億通りのうちの1体」を引いた証拠が手元に残るので、ガチャ的な引きの強さがそのまま共有コンテンツになります。
9. アーキテクチャと数字
描画(Canvas 60fps)・音声合成・Jevの意思決定を混ぜないため、Clean Architecture で分離しています。
src/
├── domain/ # Hero, Monster, DNA遺伝子カタログ, 装備, BGM, 8言語辞書
├── usecases/ # ゲームロジック × Jev意思決定(探索 / 戦闘 / 作曲 / カード生成)
├── adapters/ # Jev APIクライアント + オフライン推論シミュレーター + サウンドエンジン
└── presentation/ # Canvas描画, ドット絵合成, Web Audioシンセ, 入力, i18n
Jevを呼ぶのは usecases/ だけで、presentation/ は一切知りません。だから描画とテストがAPIから完全に独立します。
実測値です。
| 項目 | 値 |
|---|---|
| TypeScript 行数 | 7,948行 / 32ファイル |
| ランタイム依存パッケージ | 0(devDependenciesは typescript / vite / vitest のみ) |
| 画像・音声ファイル | 0個(フォントのみGoogle Fontsから読み込み) |
| 本番ビルド(JSバンドル) | 135.59 kB(gzip 48.45 kB) |
| 単体テスト | 25件 全パス |
| 対応言語 | 8言語(日英仏伊独中韓印) |
| モンスター組み合わせ | 221,184,000通り |
外部アセットが0なので、dist/ を静的ホスティングに置くだけで動きます。CDNもアセットパイプラインも要りません。
10. 遊び方
cd jev_blog/articles/03/jev-retro-dungeon
npm install
npm test # 25テスト
npm run dev # http://localhost:3000/
| キー | 動作 |
|---|---|
| WASD / 方向キー | 移動 / メニュー選択 |
| SPACE / ENTER / Z | 決定 / 調べる / メッセージ送り |
| ESC / X | 戻る / キャンセル |
| E | そうび画面(武具の着脱) |
| H | やくそう使用 |
| B | 即時エンカウント |
宝箱から出る武具の種類(ぶき/たて/よろい/そうしょく)は Choice、レアリティは Score でJevが決めます。一方で実際の攻撃力・守備力の数値はコードが算出します(12 + 階層 × 4 など)。「どの枠を出すか」はAI、「いくつにするか」はコード、という分担です。
また Common より上のモンスターを倒すと、武器が覚醒します。
// 二つ名は Choice、上昇幅は Score。名前の組み立てはコード側で行う
const prefix = this.i18n.t(selected.prefixKey); // 例: 「ひかりの」
const bonus = Math.round(scoreAnswer.score * 3); // 例: 1.3 → +4
newWeapon.name = `${prefix} ${this.i18n.t("weapon_base_name")}`.trim();
→ 「どうのつるぎ が ひかりの剣 に覚醒した!(こうげき力 +4)」
二つ名という言葉の選択だけをJevに任せ、文字列の組み立ては8言語対応のテンプレート側で行っています。AIに文章を生成させると多言語化が破綻するので、ここでも「選ばせるだけ」を徹底しています。
APIキーを使う場合は、画面右の TYPESAFE_API_KEY 欄に入力して 保存 を押してください。TypeSafe AI のダッシュボードで取得できます。
11. ライセンス
本ソフトウェアおよびサンプルコードは MIT License で公開しています。
商用・非商用を問わず、複製・改変・再配布・自作プロジェクトへの組み込みなど、自由にご利用いただけます(著作権表示の保持のみ必要です)。自作ゲームやAI意思決定パイプラインの研究・プロトタイプ・教材としても自由にご活用ください。
全文は LICENSE をご確認ください。
12. まとめ
- AIに文章を書かせない。型付きの選択肢を選ばせるだけなら、ゲームループに入れられる
- 「破綻したら詰むもの」はコード、「外しても遊べるもの」はAI。迷路の形・ステータス計算・曲の音符は手続き的に作り、AIには「どういう場所か」「どういう気分か」だけを決めさせる(→ 3章)
- AIの判断は最終決定ではなく入力の1つ。エンカウントは歩数の天井で下限を守る
- 選択肢はデータから自動生成する。カタログに1行足せば、プロンプトも描画も自動で追随する
- フォールバックを標準動作にする。キーが無くても通信が落ちても、体験が変わらない
- 意味と色を分離する。パーツは「BASE / SHADOW / ACCENT」だけを書き、色は最後に解決する
- 組み合わせ数は机上の掛け算だけで信じない。生成物どうしが実際に区別できるかは、全パーツを描画して比較するまで分からない(→ 5章)
意思決定モデルは「LLMの下位互換」ではなく、LLMでは物理的に入れない場所に入れるモデルです。1フレーム16.7ミリ秒の世界は、その代表格だと思います。
コードは全部読める状態で公開しています(MIT License です。→ 11章)。手元で動かして、変なモンスターが出たらぜひ共有してください。
👉 df-yamashitamasashi/jev_blog — articles/03
関連リンク
- ゲーム本体:
articles/03/jev-retro-dungeon - モンスターギャラリー(生成だけ試す):
gallery.html - 詳細仕様(英日):
articles/03/README.md - Vol.1 Jev詳解(実践ユースケース5選)
- Vol.2 JevでVSCode拡張機能




