地形生成 実装ガイド
概要
本ドキュメントは、src/domain/generation/(定義と実行)・
src/loader/parseGeneration.ts(ロード)にまたがる地形生成の実装を、実際の
クラス名・関数名を使ってトップダウンに説明するものです。
- 「なぜこう設計したか」は本書では説明しません。
TerrainGeneration.md・ExplorationSystem.mdを参照してください。本書は、1つのファイルを読むだけでは 把握できない呼び出し関係の地図を提供します。 - 個々のクラス・関数の正確な契約(引数・戻り値・例外)は、常にソースコードとTSDocコメントを 正とします。本書と矛盾を見つけたらソースコードを信じてください。
読む順番の目安: 全体の呼び出し関係は 1 節、個々のクラスの詳細は 2 節以降、ファイルを直接開く前の インデックスは 8 節(ファイル一覧)。
1. 全体の呼び出し関係
地形生成は大きく分けて次の入口を持ちます。ロード(YAML → WorldCodex.generation、ゲーム起動時に1回)と、
生成・実体化(WorldCodex.generation → 実際の WorldObject の島、ゲーム開始時に1回)です。
[ロード]
WorldCodexYamlLoader.load(label, yamlText) src/loader/WorldCodexYamlLoader.ts
└─ loadGenerationSections(loader, label, root) src/loader/parseGeneration.ts(地形生成のルートキーを読む)
├─ parseAxis / parseGeneratorLayer → loader.generationAxes へ蓄積
├─ parseLocationType → loader.generationLocationTypes へ蓄積
└─ parseGenerationScope → loader.generationScopes へ蓄積
WorldCodexYamlLoader.buildAndReset()
└─ buildGenerationDefs(loader, objectDefsByGlobalId) src/loader/parseGeneration.ts(object_def/axis/location_type の相互参照を検証)
└─ new GenerationDefs(axes, locationTypes, scopes) src/domain/generation/GenerationDefs.ts
→ WorldCodex.generation プロパティへ格納 src/domain/WorldCodex.ts
[生成・実体化]
startNewGame(codex, characterDefName, seed, rng) src/domain/generation/NewGame.ts ← ゲーム開始の入口
├─ world・character の WorldObject を生成(worldはnew WorldObject、characterはWorldSession.createObject)
├─ generateIsland(codex.generation, "island", seed) → IslandMap
│ │ src/domain/generation/TerrainGenerator.ts
│ ├─ 1. placeSites(scope, rng) → Site[](座標のみ) SitePlacer.ts
│ ├─ 2. assignAxisValues(defs.axes, sites, seed, scope) → Site.axisValues を埋める AxisSampler.ts
│ ├─ 3. assignTypes(defs, scope, sites) → Site.type を確定 LocationTypeMatcher.ts
│ ├─ 4. triangulate(sites) → readonly [number, number][] DelaunayTriangulator.ts
│ ├─ 5. buildPathNetwork(sites, delaunayEdges, scope, defs.axes) → IslandEdge[] PathNetworkBuilder.ts
│ └─ 6. assignNames(sites, rng) → Site.name を確定 NameAssigner.ts
├─ spawnIslandIntoWorld(session, map) → SpawnedIsland → 各SiteをWorldObjectとして生成し、道も生成 IslandSpawner.ts
└─ placePlayer(island, character) → 開始地点へキャラクタを配置、Locationを返す IslandSpawner.ts
generateIsland(TerrainGenerator.ts)までは WorldObject に一切触れない純粋な計算です(IslandMap/Site/
IslandEdge はただのデータ)。WorldObject の生成・配置が始まるのは IslandSpawner からです。この境界を
意識すると、「レイアウトのバグ」(src/domain/generation/ 側)と「実体化のバグ」(IslandSpawner 以降)の
どちらを疑うべきかを素早く切り分けられます。この境界は検査が保ちます——実体化を担うファイル以外が
WorldObject とその包みを輸入していないこと(tests/architecture/layers.test.ts)と、IslandMap が
生成の結果しか欄に持たないこと(tests/generation/terrainGenerator.test.ts。対応表は数の配列でも
書けるので、輸入を見るだけでは足りません)。
2. ロード: YAML → GenerationDefs
地形生成関連の処理は、object_defs/traits を読む本体 src/loader/WorldCodexYamlLoader.ts とは別モジュール
src/loader/parseGeneration.ts に分離されています。WorldCodexYamlLoader が持つ地形生成の蓄積フィールド
(generation で始まる名前のもの)は、loader 引数として parseGeneration.ts 側の関数へ渡されます。
loadGenerationSections(loader, label, root):load()の中から呼ばれ、地形生成のルートキー(どれを読むかは この関数自身が持ちます)を対応する蓄積フィールドへ溜めます(object_defs/traitsの蓄積と同じパターン。 複数ファイルへ分割してもloadを繰り返し呼べば1つに集約されます)。parseAxis/parseGeneratorLayer:axes.'name'1件をAxisDef(GeneratorLayerの リストを持つ、src/domain/generation/AxisDef.ts)へ変換します。parseLocationType:location_types.'name'1件をLocationTypeDef(src/domain/generation/LocationTypeDef.ts)へ変換します。object_defフィールドはこの時点ではobjectNames.internするだけで、実在検証は行いません(後述のbuildGenerationDefsまで遅延)。parseGenerationScope:generation_scopes.'name'1件をGenerationScopeDef(guaranteesを含む、src/domain/generation/GenerationScopeDef.ts)へ変換します。buildGenerationDefs(loader, objectDefsByGlobalId):WorldCodexYamlLoader.buildAndReset()の中から、全object_defsの 解決が終わった後に呼ばれます。LocationTypeDef.objectDefGlobalIdが実在するか、axis_preferences/hard_limits/guaranteesが参照する軸名・LocationType名が実在するかをここでまとめて検証し、GenerationDefsを組み立てて返します。生成関連のYAMLが1つもロードされていなければundefinedを返します (WorldCodex.generationがundefinedになりうる、という契約はここに由来します)。
3. generateIsland(TerrainGenerator.ts): ステップの内訳
TerrainGenerator.ts(generateIsland 1関数のみをエクスポート)は、以下のモジュールの関数を順番に呼ぶだけの
オーケストレータです。各モジュールも状態を持たない関数の集まりで、Site/IslandEdge の配列を受け取って
書き換える・新しく作る、という素朴な手続きです。
3.1 placeSites(scope: GenerationScopeDef, rng: Pcg32): Site[](SitePlacer.ts)
座標だけを決めます(軸値はまだ持ちません)。
- サイト総数を
rng.nextInt(scope.siteCountMin, scope.siteCountMax + 1)で抽選(nextIntは半開区間、site_countのmaxは含む値)。 - 外周リング配置:
coastCount(総数の約35%、4〜7個にクランプ)個のサイトを、半径COAST_RING_MIN_RADIUS〜COAST_RING_MAX_RADIUS(ISLAND_RADIUS比)の円環へ、角度を均等割りして ジッタを加えながら配置(Site.onCoastRing = true)。 - 内陸配置: 残りのサイトを、半径
INTERIOR_MAX_RADIUS以内へベストキャンディデート法(候補CANDIDATES_PER_SITE個のうち、既存サイトからの最小距離が最大のものを採用するループ)で配置 (Site.onCoastRing = false)。候補の半径はMath.sqrtで引き、枠へ面積あたり一様に散ります。
3.2 assignAxisValues(axes, sites, seed, scope)(AxisSampler.ts)
各 Site の axisValues(Map<string, number>)を埋めます。
- 各
AxisDefのlayers(GeneratorLayerのリスト)を、モジュール内関数sampleLayer(layer, site, seed)で[0, 1]にサンプルし、layer.weightで重み平均します。'distance_field'→1 - (原点からの距離 / ISLAND_RADIUS)(ISLAND_RADIUSはSitePlacer.tsからimport)'layered_noise'→noiseAtIslandPoint(seed + layer.seedOffset, site.x, site.y, layer.octaves, layer.frequency)(ValueNoise.ts)
- 結果を
axis.range(PropertyRange)へ量子化してSite.axisValuesにaxis.nameキーで代入。 scope.clampsHullSitesToCoastが真なら、Site.onCoastRingなサイトのcoastal_distance(定数COASTAL_DISTANCE_AXIS_NAME)をscope.coastBandMaxDistance以下へクランプします。
3.3 assignTypes(defs, scope, sites)(LocationTypeMatcher.ts)
各 Site.type(LocationTypeDef)を確定します。
- モジュール内関数
assignGuaranteedSitesがscope.guaranteesの強制割当先をまとめて決めます。保証 1 件のcount個ぶんを受け口(GuaranteeOpening)に開き、assignEligibleSitesInOrderが受け口を宣言順に見て、orderForGuaranteeで軸値の最大/最小順に並べたSiteのうちhard_limitsを満たすものから取らせます。 取ってよいのは、そのSiteを抜いても残る受け口が同じ数を当てられる候補だけで、この数はmaxAssignableCount(増加路を辿る=二部グラフの最大マッチング)が数えます。相手の見つからなかった 受け口は、同じ並び順の残るSiteから補います(保証は絶対のため)。 - 残る
Siteを、モジュール内関数bestDistanceIgnoringCrowdingが返す最良距離の昇順に並べ、1つずつ モジュール内関数nearestTypeAvoidingFull(types, site, scope, counts)へ渡します。選ぶ基準は、LocationTypeDef.satisfiesHardLimitsを満たす型のうちLocationTypeDef.normalizedDistanceFrom(正規化した重み付き距離)に同じ型の既存個数ぶんの割増(scope.crowdingPenaltyPerDuplicate)を掛けた 値が最小のものです。scope.maxSitesPerTypeまで埋まった型は避け、全型が埋まっていれば上限を無視して 選び直します。それでも該当が無ければisFallbackかつfallbackPriority最大の型へフォールバック します(それも無ければ例外)。
3.4 triangulate(sites): readonly [number, number][](DelaunayTriangulator.ts)
Bowyer-Watson 法によるDelaunay三角形分割です。すべての Site を包む仮想の「スーパートライアングル」から
始め、Site を1点ずつ挿入するたびに外接円判定(モジュール内関数 inCircumcircle)で無効化された三角形を
削除・再分割します。最後にスーパートライアングルの頂点を含む三角形を除いて、無向辺の集合を返します。
3.5 buildPathNetwork(sites, delaunayEdges, scope, defs): IslandEdge[](PathNetworkBuilder.ts)
- サイト間の距離を
scope.metersPerDistanceUnitでメートルへ直します。抽象座標を現実の長さへ 変換するのはここ1箇所で、以降の距離はすべてメートルです。 - Kruskal法で最小全域木(MST)を求めます(
unionFind配列 + ローカル関数find)。 - MSTに含まれなかった辺(
rest)を距離順に走査し、モジュール内関数shortestPathDistance(Dijkstra)で 求めた「現在のグラフでの2点間最短距離」が、直結距離 ×scope.extraEdgeDetourThresholdを超えていれば その辺を復活させます。 - 採用した各辺について、モジュール内関数
travelMinutesで移動時間(分、TRAVEL_MINUTES_STEP刻み・ 下限も同じ1刻み)を計算し、IslandEdgeを作ります。標高の値をメートルへ直す倍率はdefs.metersPerElevationUnit(scope)が返します(スコープが持つ上端の高さと、軸が持つ値域の商)。
3.6 assignNames(sites, rng)(NameAssigner.ts)
Site.name(LocationName)と Site.variant を確定します。Site を LocationType ごとにまとめ、
1 つだけの型は型の識別子だけを名前にし(亜種は付きません)、複数ある型はモジュール内関数
shuffled(values, rng) で並べ替えた variants を 1 つずつ配ります。亜種が尽きた分には通し番号
(LocationName.ordinal)を持たせます。表示文字列はここでは作りません——LocationName は識別子の
組み合わせで、文字列にするのは Localization.locationName(Localization.md)です。亜種の props を
実体へ書き込むのは IslandSpawner(4 節)です。
rng は配置とは別の列(Pcg32.forPurpose(seed, 'names'))です。配置の引く回数を変えても、命名の
結果は動きません。
4. 実体化: IslandSpawner
generateIsland(TerrainGenerator.ts)の結果(IslandMap、まだ WorldObject を含まないデータ)を、実際の世界
(world を根とするツリー)へ実体化します。
spawnIslandIntoWorld(session, map): 実体化した島(SpawnedIsland、下記)を返します。map.sitesを1つずつsession.createObject(site.type.objectDefGlobalId)し、site.variantがあれば そのpropsをgetProperty(...).setNumberWithoutEventsで書き込んでから、world.locationsスロットへmoveToSlotOrRejection。map.sitesを1つずつ、そのSiteに接続するmap.edgesを集め(filter/map)、ObjectDef.tryGetPropertyDef(progressId).range.maxから探索率100%の進捗progressMaxを読み、pathRequiredProgresses(道の本数, progressMax)でrequired_progressの並びを受け取ります。 割り当ての式はこの関数が1箇所で持ちます——同じ割り当てを測る側 (src/analysis/pathDiscovery.ts、stats/terrain.yamlのpath_discovery)も同じ関数を通るので、 式を変えても統計だけが古いまま残ることがありません。pathをsession.createObjectし、getProperty(...).setNumberWithoutEventsでtravel_minutes/required_progress/destination_id(接続相手のinstanceId)を書き込み、undiscovered_fixturesスロットへmoveToSlotOrRejectionします。- 生成した道を「どのサイトからどのサイトへ向かう道か」で引けるように控えておき、
map.edgesを1本ずつ 辿って両端の道へ互いのinstanceIdをreturn_path_idとして書き込みます(発見が両側同時になる、ExplorationSystem.md3.1 節)。
placePlayer(island, character): 開始地点をselectStartSite(src/domain/generation/StartSiteSelection.ts)に選ばせ、SpawnedIsland.landOfでその土地を引いて、charactersスロットへmoveToSlotOrRejectionした上でLocation(src/domain/wrappers/Location.ts)を返します。選ぶ順はContentSkeleton.md2.3節。
SpawnedIsland(src/domain/generation/SpawnedIsland.ts)は、生成結果(map)と、そこから湧いた土地の
WorldObject の対応を束ねたものです。対応は実体化と同時に出来上がる(コンストラクタがすべての
サイトの土地を要求する)ので、「まだ実体化していないサイト」という状態がありません。読む側は
「サイト → instanceId → WorldObject」を自分で辿らず、landOf(site)・siteOf(instanceId)・
nameOf(instanceId)・lands を使います。土地の名前(LocationName)の唯一の出所もここです
——個体ごとに決まる名前なので、型(ObjectDef)の側は知りません。
5. データの流れ(型で見る層)
| 層 | 主な型 | 特徴 |
|---|---|---|
| 定義(ロード後不変) | GenerationDefs(AxisDef/LocationTypeDef/GenerationScopeDef、src/domain/generation/) |
WorldCodex.generation として1つだけ存在。YAMLの内容そのもの |
| 生成の中間・最終結果(純粋計算) | Site/IslandEdge/IslandMap(src/domain/generation/IslandMap.ts) |
WorldObject を一切含まない。generateIsland(TerrainGenerator.ts)が返す。座標・軸値・確定した LocationTypeDef・命名・辺を持つだけの、ただのデータ |
| 実体化後(実行時状態) | WorldObject(Location/Path でラップ、src/domain/wrappers/) |
IslandSpawner が Site/IslandEdge を読んで生成する、実際にゲームが動かす対象 |
| 生成結果と実体の対応 | SpawnedIsland(src/domain/generation/SpawnedIsland.ts) |
spawnIslandIntoWorld が返す。サイトとその土地の対応を持ち、実体化と同時に出来上がる(4節) |
IslandMap(中間層)を経由することで、generateIsland(TerrainGenerator.ts)は完全に決定的な純粋関数として単体テスト
でき(tests/generation/terrainGenerator.test.ts)、IslandSpawner 以降の実体化のテスト
(tests/generation/islandSpawner.test.ts)と関心事が分離されています。
6. 決定性の仕組み: 用途ごとの乱数列
1つの seed から、用途ごとに独立した列を作ります(Pcg32.forPurpose、RandomPurpose)。
| 用途 | 引く場所 | 何を決めるか |
|---|---|---|
sites |
placeSites(SitePlacer.ts) |
サイト総数と座標 |
names |
assignNames(NameAssigner.ts) |
亜種の配り方 |
play |
WorldSession.rng(Rng.seededRng) |
初期値ロール・pick の抽選・開始時刻 |
AxisSampler は列を引かず、seed を直接 ValueNoise のハッシュへ渡します(状態を持たない純関数)。
LocationTypeMatcher・DelaunayTriangulator・PathNetworkBuilder は乱数を一切使いません(Site の
座標・軸値が決まった時点で結果は一意に決まります)。
列を分けて守れるのは「他の用途が何回引いたか」の変化に対してだけです。上流が出した値が変われば
下流は動きます——placeSites を変えれば、軸のノイズを別に引いていても軸値は変わり、型も名前も変わります。
play の列だけは下流を持たないので、「同じ seed なら WorldSession.rng に何を渡しても島のレイアウトは
変わらない」という契約が成り立ちます(tests/generation/islandSpawner.test.ts が検証しています)。
7. エンジン拡張との接点
地形生成の実装にあわせて GameElementDefinition.md へ追加した汎用エンジン拡張(duration/move、
ExplorationSystem.md 4節)は、以下のコードに対応します。
duration:InteractionDef(操作の中身)がDeclaredNumber | undefined型のdurationフィールドを持ち、実行時に自分で時間を進めます(順序はActionSystem.md2節)。所要時間の解決はInteractionDef.minutesFor。move:MoveEffect(ActiveEffectの一種)です。applyの中で動かす物と移動先をObjectRef.resolve(context)(src/domain/ObjectRef.ts。プロパティ経由の指定はowner.findRoot().findSelfOrDescendantByInstanceId(instanceId)で解決します)で求め、行き先のスロットを 名指ししていなければmover.moveIntoFirstAcceptingSlot(destination)、名指ししていればdestination.tryGetSlot(...)の結果をmover.moveToSlotOrRejection(slot)へ渡して配置します。findRoot/findSelfOrDescendantByInstanceId/moveIntoFirstAcceptingSlot/moveToSlotOrRejectionはいずれもWorldObject(src/domain/WorldObject.ts)に定義した汎用メソッドです。- 道の発見・移動の入口:
Location.explore(agent)(src/domain/wrappers/Location.ts)がexploreアクションの実行とrevealDueFixtures(undiscovered_fixtures→fixturesの移動)を1回の呼び出しに まとめています。Path.travel(agent)(src/domain/wrappers/Path.ts)がtravelアクション を実行します。
8. ファイル一覧(索引)
| ファイル | 役割 |
|---|---|
src/domain/generation/AxisDef.ts |
軸1本の定義(層を積んで軸値を作る手順、3.2節) |
src/domain/generation/LocationTypeDef.ts |
LocationType 1件の定義(軸の好み・上限は3.3節、亜種は3.6節・4節) |
src/domain/generation/GenerationScopeDef.ts |
生成スコープ1件の定義(サイト数・偏り・網羅の保証、3.1節・3.3節) |
src/domain/generation/GenerationDefs.ts |
上記の束(WorldCodex.generation の中身、2節) |
src/loader/parseGeneration.ts |
YAML → 上記Defsのパース(2節) |
src/domain/Pcg32.ts |
用途ごとの列を作る決定的RNG(6節) |
src/domain/generation/ValueNoise.ts |
シード付き格子値ノイズ(3.2節) |
src/domain/generation/IslandMap.ts |
生成結果のデータ(座標・軸値・型・命名・辺、5節) |
src/domain/generation/SitePlacer.ts |
3.1節: 座標配置 |
src/domain/generation/AxisSampler.ts |
3.2節: 軸値サンプリング |
src/domain/generation/LocationTypeMatcher.ts |
3.3節: LocationTypeマッチング |
src/domain/generation/DelaunayTriangulator.ts |
3.4節: Delaunay三角形分割 |
src/domain/generation/PathNetworkBuilder.ts |
3.5節: MST+辺復活+移動時間 |
src/domain/generation/NameAssigner.ts |
3.6節: 命名 |
src/domain/generation/TerrainGenerator.ts |
3節全体のオーケストレータ(generateIsland) |
src/domain/generation/IslandSpawner.ts |
4節: 実体化(spawnIslandIntoWorld/placePlayer/placePlayerAt) |
src/domain/generation/StartSiteSelection.ts |
開始地点の選抜(ContentSkeleton.md 2.3節) |
src/domain/generation/SpawnedIsland.ts |
4節: 実体化された島(サイトと土地の対応) |
src/domain/generation/NewGame.ts |
ゲーム開始の入口(startNewGame)・StartedGame |
src/domain/MoveEffect.ts |
7節: move 効果動詞 |
src/domain/InteractionDef.ts |
7節: duration フィールド |
src/domain/wrappers/Location.ts |
7節: 探索の入口(explore/revealDueFixtures) |
src/domain/wrappers/Path.ts |
7節: 道のビュー(travel) |
対応するテストは以下のとおりです。
| テストファイル | 対象 |
|---|---|
tests/loader/generationYamlLoader.test.ts |
2節(parseGeneration.ts) |
tests/world-codex/terrainGenerationYaml.test.ts |
実ファイル terrain_generation.yaml と locations.yaml の対応 |
tests/generation/terrainGenerator.test.ts |
3節(generateIsland の不変条件) |
tests/generation/islandSpawner.test.ts |
4節・6節(IslandSpawner/NewGame、決定性) |
tests/domain/actionDuration.test.ts |
7節(duration) |
tests/domain/moveEffect.test.ts |
7節(move) |