地形生成システム設計

概要

本ドキュメントは、島の地形生成システムに関する設計と実装をまとめたものです。 GameElementDefinition.md が掲げる「ハードコードしない」「汎用エンジンに 任せる」「ファイル追加だけで拡張できる」という設計方針(2 節)に準拠し、以下を目的とします。

本書が扱うのは、島の座標・軸・LocationType・パスネットワークを生成するアルゴリズムそのもの (Domain.Generation、WorldObject に一切触れない生成時点だけの純粋な計算)です。生成された Location(土地)が生成されたあとにどう振る舞うか(スロット構成・探索・道の発見・移動)は ExplorationSystem.md が扱います。

本書は設計判断(なぜこのアルゴリズムか)を扱い、実際のクラス名・メソッド名を使った実装の呼び出し関係は TerrainGenerationImplementation.md に切り出しています。 コードを読む・変更する際はそちらを参照してください。

実装は src/domain/generation/ 以下、定義データは src/assets/world-codex/terrain_generation.yaml(生成パラメータ)・locations.yaml(土地・道の object_defs)にあります。

1. 用語定義

用語 説明
Axis(軸) 標高・湿り気など、地点が持つ連続値パラメータの 1 次元。ジェネレータ(ノイズ/距離場など)によって値が決まる。値は 0〜100 の整数(後述)
Site(地点) 座標と軸ベクトルのみを持つ、LocationType 未確定の中間状態のノード
環境(Environment) 軸ベクトルが表す性質そのもの。データ構造として実体化はしない(クラスや YAML 定義を持たない)。Site 同士の軸ベクトルの近さという関係性としてのみ存在する概念
LocationType 「草原」「洞窟」など、配置の定義(YAML)。プレイヤーには見えない設計者側の語彙
Location(土地) 実際に生成・命名された実体。「草原」「花咲く草原」のようにゲーム内で識別可能な名前を持つ。パスネットワークのノードでもある
Structure(構造物) 内部に子 Location のグラフを持つ、特殊な Location。「島の中に埋め込まれた入れ子の島」として扱う(いつか、3.7 節参照)
Coordinate/Position 単なる座標値。Site/Location が持つプロパティの一つ

2. 生成パイプライン全体像

[generation_scopes (YAML)]
   ↓ サイト数の抽選 + 座標配置(外周リング + 内陸への散布)
[Site群] (座標のみ)
   ↓ Axis定義(YAML)のジェネレータ(距離場/ノイズ)を各座標でサンプリング
[Site群] (座標 + 軸ベクトル)
   ↓ guarantees(軸カバレッジの強制割当) → LocationType の axis_preferences との最近傍マッチング
   ↓ (フォールバック処理)
[Location群] (LocationType確定)
   ↓ Delaunay三角形分割 → MSTで間引き → 迂回率に応じて一部の辺を復活
[Locationネットワーク] (距離・移動時間つきパスで接続)
   ↓ LocationTypeの識別子(同型が複数ならvariants)で命名
[Location群] (命名済み)
   ↓ IslandSpawner が object_defs を spawn し、world.locations へ配置
   ↓ 辺ごとに path を両端へ生成し、undiscovered_fixtures(隠しスロット)へ配置(ExplorationSystem.md)
[実体化済みの島]

座標に依存する軸のジェネレータ(距離場・座標ベースのノイズ)は座標なしでは評価できないため、 パイプラインの順序は「座標決定が最上流」に統一しています。

構造物内部の生成はまだ動いていません(3.7 節)。島の生成と構造物内部の生成は、同一の生成ロジックを スコープ別パラメータ(generation_scopes)で共有する設計です。

3. 各ステップの詳細仕様

3.1 Axis定義とジェネレータプリミティブ

Axis は汎用プリミティブの重み合成(generator.blend)で値を生成します。軸の値は 0〜100 の整数です (ジェネレータの内部計算には実数を使い、AxisDef.range へ量子化する時点で整数に丸めます)。

実装済みのプリミティブは次のとおりです。

blob_scatter(局所的に濃い領域を作る散布)・modifiers(他の軸との合成演算)は、実際の10土地種の定義には まだ必要にならなかったため未実装です(6 節)。

axes:
  elevation:
    range:
      min: 0
      max: 100
    stretch_sites_to_range: true
    generator:
      blend:
        - {type: distance_field, reference: edge, weight: 90}
        - type: layered_noise
          octaves: 3
          frequency: 2
          seed_offset: 11
          weight: 10

stretch_sites_to_range: 1 回の生成で出たサンプルの最小・最大が range の両端へ来るよう、 量子化の前に引き伸ばします。宣言した値域が実際に現れることを保証できるのはこれだけです ——ジェネレータの値はサイトの座標で決まり、どこにサイトが置かれるかは事前に決まらないため、 式をどう組んでも「上端に届くサイトがある」とは言えません。値域が現実の単位へ読み替えられる軸 (標高のメートル、3.5 節)で要ります。

引き伸ばしは値の幅だけを保証し、分布の形は元の式のままです。elevation でノイズの重みが 距離場に対して低いのはそのためで、加算で乗せた起伏はそのまま海岸の高さのばらつきになります (重み 30 では、海に接する土地が海抜 100 m を超えます)。

設計上の注意: 軸の種類・数はハードコードしません。Axis 定義自体が YAML で完結し、LocationType 側は 「言及した軸だけ気にする」設計にすることで、軸の増減に対して LocationType 定義が壊れないようにします (3.2 節参照)。実際の定義は src/assets/world-codex/terrain_generation.yaml の axes を参照してください。

3.2 LocationTypeマッチング(軸ベース)

各 LocationType は、軸空間上の「理想点+許容範囲」として定義します。

location_types:
  jungle:
    object_def: jungle                 # 実体化に使う型(locations.yamlのobject_defsのid)。土地も他の
                                       # あらゆる要素と同じobject_defsで表現される
    applicable_scopes: [island]
    # 移動コストの倍率(1 = 等倍。3.5節のtravel_minutesに使う)
    move_cost: 1.6
    axis_preferences:
      humidity:
        ideal: 90
        tolerance: 20
        weight: 120
      elevation:
        ideal: 30
        tolerance: 30
        weight: 60
      # 言及しない軸は自動的に「無関心」(マッチング距離の計算に一切寄与しない)
    hard_limits:
      # 絶対的な除外条件。海岸帯には出ない
      coastal_distance: {min: 16}
      humidity: {min: 65}

マッチングは、言及した軸だけを対象に正規化した重み付きユークリッド距離による最近傍探索です。

D(type, site) = sqrt( Σ_i w_i * ((v_i - ideal_i) / tolerance_i)^2  /  Σ_i w_i )

(i は type が axis_preferences に持つ軸のみを走る。v_i は site の軸値)

この式の設計判断:

stages(GameElementDefinition.md 6 節)は1次元の区間判定である一方、こちらは複数軸にまたがる多次元の 最近傍探索であるため、専用のマッチング処理(Domain.Generation.LocationTypeMatcher)として実装しています。

3.3 マッチできない場合の処理(フォールバック)

3.4 出現数について(同じ型の抑制とguaranteesによる保証)

同じ LocationType は max_sites_per_type 個までにします。 同じ地形は環境も発見物も見た目も同じなので、 並べても島は広くなりません。上限で急に打ち切るのではなく、既に置いた同じ型の個数に応じてマッチング距離へ 割増(crowding_penalty、率)を掛け、上限へ届く前から他の型へ譲らせます。

これは重複を減らすだけでなく、島に出る地形の種類を増やします。上限が無いと軸空間の中央付近に理想点を持つ 型が大半の Site を取り、端に寄った型がほとんど出ません(実測値は stats/terrain.yaml の location_type_counts)。

下限(最低出現数)のレンジ管理はしません。 軸ベースの最近傍マッチングは各 Site が独立に 判定されるため、個数の厳密な制御には本質的に向いていません。

一方で「島には必ず山が1つある」のような要求は、軸の分布(カバレッジ)を事後チェックするだけでは保証できません (カバレッジがあっても、実際にそこへ Site が配置され、かつマッチングでその型が選ばれるとは限らないため)。 そこで、generation_scopes に guarantees(明示的な強制割当)を持たせ、最近傍マッチングの前に 確定的に処理します。

generation_scopes:
  island:
    guarantees:
      - {location_type: mountain_peak, axis: elevation, pick: max, count: 1}

axis が pick(max/min)側の値を持つ Site から count 個を選び、location_type を強制的に割り当てます (hard_limits を満たす Site を優先し、足りなければ満たさない Site からも補います。保証は絶対のため)。 確率的な再生成を使わないため、決定性(同じシード → 同じ島)と停止性(無限に再生成しない)の両方を 保っています。

どの保証も hard_limits を満たす Site を得られる配り方が在るなら、必ずそれを採ります。 保証を 1 件ずつ 順に見て、そのたびに候補の先頭から固定すると、先に見た保証が取った Site のせいで、後の保証だけが hard_limits を満たさない Site へ回ります(最高標高の Site を山頂が取り、そこにしか置けない火口が 山腹へ降りる)。そうならないよう、取ると後の保証が置き場を失う候補は飛ばします(当てられる数は 二部グラフの最大マッチングで数えます。材料の割り当てと同じ形で、GameElementDefinition.md 13.1 節)。 それでも置き場の無い保証だけが、hard_limits を満たさない Site へ降ります。

取り合いになったときは、先に宣言した保証が優先します(宣言順は、書き手が決められる唯一の順序です)。 どちらが取っても両方が成り立つなら、先に宣言したほうが pick 側の Site を取ります。優先が効くのは 上の「置き場を失わせない」範囲までで、先の保証の望みより、後の保証が置けることが先です。

3.5 座標配置とパスネットワーク生成

島の大きさと歩く速さ【確定】

出どころ: #656(島の大きさと歩く速さ)

大きさと速さは、それぞれ別のキーで宣言する

上の値をどう持つかを決めたもので、覆すのに人間の判断は要りません。

generation_scopes.island の diameter_meters・elevation_top_meters・walk_meters_per_hour として、それぞれ現実の単位で別々に宣言します。速さが当たるのは move_cost が 1.0 の土地です。 1つの値に縮尺と速さを兼ねさせると、どちらも外の 知識と突き合わせて検算できなくなるためです (DesignPrinciples.md の「現実に単位があるものは、その単位で持つ」 「1つの原因に、複数の結果を兼ねさせない」)。

3.5.1 座標配置(海岸に囲まれ、かつ海岸過多にならない配置)

島は単純な円盤(半径 SitePlacer.ISLAND_RADIUS)とみなします。抽象座標系の直径(ISLAND_RADIUS の 2 倍)が diameter_meters に当たり、抽象 1 単位はそれをこの直径で割った長さになります。 ノイズも距離場も ISLAND_RADIUS で正規化しているので、縮尺を変えても地形の見た目は変わりません。要求「海岸に囲まれた島を、海岸が多くなり すぎないように生成する」を、次の配置枠の分離によって実現しています(円盤へ一様に散布すると、 面積比の関係で外周付近のサイトが多数を占めてしまい、単純な後処理だけでは制御しづらいため、配置そのものを 分けています)。

  1. 外周リング: 島を囲む海岸候補を、半径85%〜95%の円環上へ、等間隔+ジッタで配置します。個数は generation_scopes.island の想定サイト数の約35%(ただし4〜7個にクランプ)です。この個数の上限・下限が、 「島を囲むのに十分な数」と「海岸が多くなりすぎない」の両方を同時に満たす調整弁になっています。
  2. 内陸: 残りのサイトを、半径75%以内へベストキャンディデート法(Mitchell's best-candidate algorithm)で 散布します。候補は枠へ面積あたり一様に引き、既存のどのサイトからも最も離れた候補を毎回選ぶことで、 サイト同士が均等に散らばります。Poisson-disk sampling ではなくこの方式なのは、生成するサイト数を 直接指定できるためです(Poisson-disk は半径と面積から結果的な個数が決まるサンプラで、個数を 指定できません)。

海岸の数を決めているのは、この枠の分離そのものです。 内陸の枠(半径75%以内)と外周リング (85〜95%)の間に隙間があるので、内陸のサイトは海岸帯へ届きません。

内陸のサイトを中心へ寄せる調整弁は、宣言に持ちません。 候補の半径分布を中心寄りへ歪めても、 採るのは「既存のどのサイトからも最も離れた候補」なので、寄せたぶんは間隔の側で戻されます ——採られたサイトの半径は、候補の半径ほどには動きません。端から端まで振っても、海岸帯の土地の 割合は動かず、道の網目も島の顔が変わるほどには動きません。動くのはどの型の土地が島に出るかの ほうで、 これは「中心へ寄せる」という名前からは読めない効き方です。名乗った働きが名乗りに見合わず、見合う 働きは名前から読めない弁になるので、置きません(実測は DesignNotes.md「地形生成」)。内陸の散布を中心寄りへ振りたくなったら、 候補の分布ではなくベストキャンディデート法が候補を採る基準の側を変えます。

外周リングに配置されたサイトは、coastal_distance 軸のサンプリング後に generation_scopes.island.coast_band 以下へクランプされます(hull_coast: true の場合)。これにより、海岸型 LocationType(hard_limits で coastal_distance が海岸帯以下と定めている型)が必ずこの位置に配置され、「島が海岸で囲まれる」ことを 配置の構造そのもので保証しています。

3.5.2 パスネットワーク

辺同士の交差を解決する処理はありません。採用する辺は常に Delaunay 辺の部分集合であるため、交差は数学的に 起こりません。Delaunay に含まれない任意のショートカット辺を追加したい要求が出てきた場合に、その時点で 改めて設計します。

道オブジェクト自体(path object_def)の生成・探索による発見・移動アクションは ExplorationSystem.md を 参照してください。

回り道の量は、行き止まりが消える下の線で決まる

上の extra_edge_detour_factor をどの値にするかを決めたもので、覆すのに人間の判断は要りません。

2 とします。決めているのは下の線で、上の線は今のところ遠くにあります(どちらも500シードの実測)。

上の線は生存の採取の量で動きます。 往復に使えるのは屋外の枠から1日を賄う採取を引いた残り (stats/terrain.yaml の daily_budget)なので、採取が重くなれば上限は下りてきます——採取が 枠の3分の1を占めるほどになると、上限は 2.5 あたりまで下ります。この節の 2 は下の線から出ているので、 そこが動いても揺れません。

往復の実測は stats/terrain.yaml の base_farthest_round_trip で、上の線を割ったときに落ちるのは tests/generation/terrainGenerator.test.ts の 「どの島でも、拠点から最も遠い土地まで日帰りで往復できる」です。

3.5.3 砂浜と岸壁を分ける高さ

海岸の型が名乗る高さを決めたもので、覆すのに人間の判断は要りません。

砂浜は標高軸の 5 まで、岸壁は 6 からとします。 どちらも hard_limits の elevation で、 同じ高さには出ません。

引く理由は分布の調整ではなく、名前が嘘になるからです。線を引く前は、砂浜も岸壁も海岸帯の高さ (海抜 0〜68 m)のどこにでも出ていました——海抜 60 m の砂浜と、海抜 0 m の岸壁が同じ島に並びます。 岸壁とは海へ落ち込む崖のことなので、高さは名前そのものです。 砂浜も同じで、浜は海面の高さに あります。砂浜の上限は浜の背後の段丘まで含めた高さで、岸壁の下限はその1つ上の目盛りです(軸の1目盛りが何 m にあたるかは 3.5.2 節の metersPerElevationUnit)。

岩海岸には線を引きません。 「岩だらけ」は高さではなく地面の性質なので、高さを名乗っていません。 海岸帯のどの高さにも出られる型が1つあることで、線を引いても行き場を失うサイトは生じません。

代償は測ってあります。 海岸帯のサイトが選べる型が3つから2つへ減るぶん、max_sites_per_type (3.4 節)を守るとどの型も選べないサイトが出て、同じ海岸の型が上限より1つ多く並ぶ島が出ます (500 シードのうち 2.2%、超えるのは常に1つまで)。3.4 節のとおり上限は絶対の条件ではないので、 これは仕様どおりの逃げ道です。逃げ道が常用されていないことは tests/generation/terrainGenerator.test.ts の「同じ地形が並びすぎず、上限を外れるのは例外に留まる」が 見張ります。

型ごとの海抜の実測は stats/terrain.yaml の site_elevation_by_location で、線が消されたときに落ちるのは同ファイルの「砂浜と岸壁は、同じ高さには出ない」です。

3.6 命名処理

  1. Site を LocationType ごとにまとめます。
  2. その型が島に 1 つだけなら、型の識別子だけが名前になります(表示は「草原」)。
  3. 複数あるなら、その型の variants(亜種)をシャッフルして 1 つずつ配ります(表示は「木苺の草原」)。
  4. variants が足りなければ、残りに通し番号を付けます(表示は「草原(第四)」)。名前として読めないので、 亜種は想定される個数ぶん用意します。

決まるのは識別子の組み合わせだけで、表示文字列は持ちません。 WorldCodex は識別子だけを持つという 規約(Localization.md)に従い、Site.name(LocationName)は「型・亜種・通し番号」 を持ち、文字列の組み立ては Localization.locationName が行います。

名前に島のどこに在るかを出しません。 地形の把握はプレイヤー自身の仕事なので、位置が分かる修飾語を 付けると、行き先の名前を見ただけで島の形が割れてしまいます。亜種の名前も同じ制約に従います (対応表の側で自動テストが見張ります)。

亜種(variants)

亜種は識別子と、実体化した土地へ書き込むプロパティの上書きの組です。IslandSpawner が spawn した 土地へ props を書き込み、探索の抽選がその値を候補の weight として読みます (locations.yaml の weight: {prop: ...}。ExplorationSystem.md 2 節)。表示名は対応表が持ちます (Localization.md の location_texts)。

sandy_beach:
  object_def: sandy_beach
  variants:
    # 素の重みは13<!-- codex: locations.yaml object_defs.sandy_beach.props.palm_find.value -->
    - {id: palm, props: {palm_find: 26}}
    - {id: white_sand}                     # 素の亜種(名前だけが変わる)

3.7 構造物(Structure)生成【いつか: 洞窟内部】

generation_scopes にスコープごとのパラメータプリセットを持たせる仕組み自体は実装済み (現状 island のみ定義)で、structure_interior のようなスコープを追加すれば、同じ TerrainGenerator 一式が同一ロジックのまま再帰生成できる設計です。探索の発見物 shallow_cave(fixture かつ location)は それ自体が入れる場所ですが、そこから奥へ続く道と、内部の子 Location グラフはまだ生成しません (Someday.md)。

4. これまでに合意し、実装した設計原則

稀な極端環境(weirdness 軸)は未実装です。「高周波ノイズで稀な異常環境を作る」という当初案は、 高周波ノイズが本質的に「多数の小さな異常点」を作るもので「稀」にはならないという矛盾を含むため、 実装方針を保留にしています(6 節)。

5. 生成パイプラインの前段・後段(範囲外の確認事項)

本書が扱うのは Axis 定義から Location の実体化・命名までの地形生成パイプラインそのものです。生成された Location に対する探索アクション・道の発見・移動、キャラクタ・アイテム・設置物のスロット構成は ExplorationSystem.md で扱います。Structure(洞窟等)内部の生成は 3.7 節の とおり「いつか」で、範囲外です。

6. 未決事項・今後の検討課題