探索・道システム設計
概要
本ドキュメントは、TerrainGeneration.md の地形生成パイプラインによって作られた
Location(土地)が、生成されたあとにどう振る舞うかをまとめたものです。「島にどんな土地がいくつ、どう
配置されるか」は TerrainGeneration.md の関心事、「1つの土地に立ったプレイヤーが何をできるか(探索する・道を
見つける・移動する)」は本書の関心事、と役割を分けています。
GameElementDefinition.md の既存文法(traits・slots・props・interactions・
pick)と、汎用拡張 duration(同 11.3 節)・move(同 9.6 節)だけで組み立てており、探索・道専用の
新しい文法は導入していません。
本ドキュメントは検討結果であり、確定仕様書ではありません。実際のクラス名・メソッド名を使った実装の
呼び出し関係(IslandSpawner・MoveEffect・Location.explore 等)は
TerrainGenerationImplementation.md にまとめています。
1. location trait と explorable trait の役割分担
「場所」であるという構造(スロット)と、「探索できる」という性質(進捗・道の発見)は、別々の trait
に分けています。家・作業小屋のような探索できない場所もスロットは必要なため、スロットは explorable では
なく location trait(core.yaml)側に置いています。
location(core.yaml): あらゆる場所が共通して持つ構造。スロット(items/fixtures/characters)と、その場を満たしているもののプロパティ(明るさ・気温・居心地・慰め・構えられた 間合い。一覧はcore.yamlが持つ)と、world.locationsスロットの枠が受け入れる型のタグ配布。explorable(locations.yaml): 探索できる場所だけが追加で持つ構造。探索進捗プロパティと、 未発見の設置物を隠しておくスロット(undiscovered_fixtures)。
# core.yaml
traits:
location:
tags: [location]
slots:
items:
cell: {accept: {tag: item}}
fixtures:
cell: {accept: {tag: fixture}}
characters:
cell_count: 1
cell: {accept: {tag: character}}
# locations.yaml
traits:
explorable:
slots:
# locationのfixturesより後に宣言する(探索で湧く設置物が隠し側へ入らないように)。
undiscovered_fixtures:
cell: {accept: {tag: fixture}}
props:
exploration_progress: {} # valueなし = 実装する側で必須(GameElementDefinition.md 5節)
草原などの探索可能な土地は両方(traits: [location, explorable])、家のような場所は location のみを
参照します。
1.1 スロット(location trait)
すべての場所は次のスロットを持ちます。いずれも capacity
(サイズの合計制限、GameElementDefinition.md 7.3 節)は指定していません。
items: アイテムが置かれるスロット。fixtures: 木や植物、建築物や家具、浅い洞窟などの設置物が置かれるスロット。characters: キャラクタを入れるスロット。cell_count: 1(枠が1つなので位置も安定する、 スタック数1つだけ)。
items/fixtures の区別はタグ(item/fixture)だけで行い、探索の発見物(2 節)を spawn する際、
どちらのスロットへ入るかは著者がスロット名を指定せずとも自動的に決まります(spawn の into が起点の持つ
スロットを宣言順に走査する、GameElementDefinition.md 9.4 節)。items を
宣言順の先頭に置いているのは、spawn が配置に失敗した際のこぼれ落ち(起点自身の親へ、同 9.4 節)が
同じく先頭のスロットから試すためで、「持ちきれない発見物は地面(土地の items)に落ちる」という
直感的な結果を、追加のルールなしに実現しています。
【いつか: 洞窟内部】 探索で見つかる浅い洞窟(shallow_cave)は、それ自体が location を名乗る入れる
場所ですが、そこから奥へ続く道はまだありません(Someday.md)。
1.2 道の2スロット(隠しスロット方式、explorable trait)
土地同士の繋がりは、探索によって見つかる path という専用オブジェクトとして表現します。「発見」は
隠しスロット方式で実装しています。生成時点で道は undiscovered_fixtures(隠し)に置かれ、探索の進捗が道ごとの
必要値に達すると fixtures(公開)へ移動して「発見」されます。この移動は既存の move_to_slot(唯一の汎用
スロット移動操作、SlotSystem.md 1 節)1回で表現でき、UI 側は「fixtures の中身をそのまま表示する」という単純な
規約だけでよく、未発見の道を非表示にする特別な処理を持ちません(道を1スロットに置き移動アクションの条件で
実行だけ禁止する方式は、UI側に隠す処理が別途必要になり「発見」が実体を持たないため不採用)。
2. 探索(explore アクション)
「探索で何が見つかるか」と探索率100%までの探索回数(進捗プロパティの上限)は、explorable trait ではなく
土地ごとの object_defs(locations.yaml)が個別に定義します。explorable trait 自身が持つのは、進捗を
保持する箱(exploration_progress)と道の発見に使う2スロット(1.2 節)という共通の器だけです。
object_defs:
grassland:
traits: [location, explorable]
props:
exploration_progress:
value: 0
# 探索率100%に達するまでの探索回数
range:
min: 0
max: 12
interactions:
explore:
trigger: menu
duration: 15
# 何が見つかっても進捗は1つ進む
add: {self: {exploration_progress: 1}}
pick:
- weight: {prop: berry_find}
spawn: {object: berry_bush, into: self}
- weight: {prop: spring_find}
spawn: {object: spring, into: self}
# ...
- weight: 18
spawn: {object: water_spinach, into: self, count: 2}
# ...
- 探索率100%までの回数:
exploration_progressのrange.max(土地ごとに異なる)。 - 100%に達しても探索は続けられます。
exploreに進捗の上限を見るconditionsは置いていません。 上限を超えた進捗はrangeの既定のクランプ(GameElementDefinition.md6.3 節)でrange.maxに 張り付くため、探索率は100%のまま、pickによる発見物だけが増え続けます。100%到達で変わるのは、 生成時に仕込まれた道(3.2 節)がそれ以上見つからなくなることだけです。 - 発見物:
pick(同 10 節)による重み付き抽選です。ハズレの候補は置かず、1 回の探索で必ず 1 個以上 見つかります。候補ごとに見つかる数は 1〜3 個で、実りの多い土地は平均 2 個、荒野・山頂・岸壁のような 乏しい土地は平均 1.6 個になるよう重みを配っています(tests/world-codex/explorationYield.test.tsが 検証)。設置物(木・茂み・洞窟の入口など)は 1 回に 1 つまでとし、数で増えるのはアイテムだけです。 進捗+1(add)はどの候補が選ばれても起こるので、pickの外に1つだけ置いています(同 9.7 節)。 - 発見量のつまみ: 一部の候補は
weightをリテラルではなくプロパティ参照({prop: berry_find})で 持ちます。素の重みは土地のpropsが持ち、亜種が土地ごとに上書きします (TerrainGeneration.md 3.6 節)。「少しだけ木苺の多い森」の類を、地形の 種類を増やさずに作るための仕組みです。つまみは、その土地を特徴づける候補にだけ置きます。 duration: 1回の探索にかかる時間(分、GameElementDefinition.md11.3 節)。全土地ともminutes_per_tickの 1 tick(15 分)に揃えています。 1回の探索がちょうど1 tick 分の世界の変化に 対応するため、探索を繰り返しても時計とゲーム内の変化が刻みからずれません。土地ごとの探索の重さは、 探索率100%までの回数(exploration_progressのrange.max)だけで表します。
2.1 出くわす獣は、単独の候補
生きた獣(animals.yaml)も pick の候補です。 島に獣を放つ専用の仕組みは持たず、獣が世界に現れる
経路は探索と罠(TrapSystem.md)だけです。どの土地にどの獣が出るかは
Animals.md 3〜5 節の分布に従い、その素の重みは他の発見物と同じつまみ
として土地の props が持ちます(locations.yaml)。
探索と罠は、同じ獣を別の形で渡します。 探索が渡すのは出くわした獣で、自由に動き、その獣の
警戒しだいで逃げも襲いもします(現れたときの警戒は獣ごとに違う、HuntingSystem.md 3.1 節)。
罠が渡すのは掛かった獣で、留守のあいだに働き、獲物は枠の中で拘束されています
(TrapSystem.md 5 節)。ネズミ・ヤケイが両方に載っているのはこのためで、罠の値打ちは「その獣が手に入ること」ではなく
「見ていない間に、動けない状態で手に入ること」です。
獣の候補は、その獣を1匹だけ湧かせます(tests/world-codex/explorationYield.test.ts が検証)。
獣は収穫ではなく出くわすもので、木の実や枝と一緒に並ぶと実りの一部に見えてしまうためです。重みは
低く抑え、novice が1回の探索で獣に出くわすのは、どの土地でも1割に届きません
(tests/world-codex/explorationYield.test.ts)。
獣のつまみは、探索する人の狩猟の腕を base の土台にします
(../world/Skills.md 5 節。expert はつまみ 1 つ
につき +4)。土地が持つのは素の重みだけで、宣言していない獣は卓に無いので、腕がいくら高くても
出くわしません。実りの候補には積みませんが、卓の合計が増えるぶん実りの出る割合は下がります——
腕を上げた探索は、どの土地でも獣へ寄ります(同じ検査)。
上乗せは、つまみ 1 つにつき同じ量が積まれます。 受け取る総量は獣の種類の数に比例する
ので、種類の多い土地ほど腕が効きます——腕を上げたぶん獣へ寄る幅は、獣の種類が多い土地ほど
大きくなります(同じ検査)。この偏りは狙いどおりで、土地ごとに上乗せを割る調整は
入れません(../world/Skills.md 5.2 節)。
海区の見張りにも、同じ上乗せが積まれます(獣の割合の線は土地のもので、海区には掛かりません)。海区は土地と同じ explorable(../world/Voyage.md)で、
魚の群れと海鳥の群れ(shoal_find・seabird_find)が獣のつまみにあたります——どちらもその場に湧いて
しばらくすると去り、獲る手が狩猟の腕を配ります。拾えるもの(流木・海藻・卵)には積みません。
湧いた獣は wariness を持った状態で土地の items に立ちます(HuntingSystem.md
3.1 節)。放っておいても stay_remaining が尽きれば立ち去る(同 5.6 節)ので、島に溜まり続けることは
ありません。手を出した個体だけは立ち去らず(同節)、傷が癒えるか、洗えない傷が膿んで倒れるまで
世界に残ります——溜まらないことを支えているのは、こちらが手を出していないぶんです。
3. 道(path object_def)と移動
object_defs:
path:
tags: [path]
props:
travel_minutes:
# 生成時にインスタンスごと上書きされる
value: 60
# 道の長さに、今歩いている人の遅れを継ぐ(world/Characters.md 荷重の効き方節)
base: {subject: agent, prop: travel_delay}
required_progress:
# 同上
value: 1
destination_id:
# 同上(移動先LocationのインスタンスID)
value: 0
return_path_id:
# 同上(移動先にある、こちらへ戻る道のインスタンスID)
value: 0
interactions:
travel:
trigger: menu
conditions:
- {in_slot: fixtures} # 発見済み(fixturesスロット)の間だけ実行できる
# 担ぎ手の遅れはtravel_minutesのbaseが継いでいるので、ここはその1つを読むだけ
duration: {prop: travel_minutes}
move:
subject: agent
to_prop: destination_id
travel_minutes/required_progress/destination_id/return_path_idはいずれもpathの通常のpropsで、地形生成がsetNumberWithoutEvents(src/domain/generation/IslandSpawner.ts)で書き込みます——return_path_idは辺の両端の道が揃ってから、残りはインスタンス生成の直後に。object_defsレベルの初期値はプレースホルダで、実際に使われるのは常にインスタンスごとの上書き後の値です。 インスタンスIDを運ぶ初期値の0がどの個体も指さないことはGameElementDefinition.md9.6 節 (to_prop)が持ちます。conditions: [{in_slot: fixtures}](GameElementDefinition.md14.2 節)が「未発見(undiscovered_fixtures側)の間は移動できない」を表します。1 節の隠しスロット方式と組み合わさり、「発見されていない道は移動も できない」が自然に両立します。move(GameElementDefinition.md9.6 節で新設した汎用の active 動詞)が、agentをdestination_idが指すインスタンスへ移動させます。移動先をobject_defsの id(型)ではなくプロパティ値(インスタンスID) で指しているのは、同じLocationTypeの土地が1つの島に複数存在しうる(例: 「花咲く草原」と「露の草原」) ため、型ではなく生成時に確定した特定の個体を指す必要があるからです。
3.1 道は辺1本につき両端へ2個、発見は両側同時
地形生成(TerrainGeneration.md 3.5 節)が確定させる Location 間の1本の繋がりに対し、path インスタンスを
両端に1個ずつ生成します。片方は「Aの undiscovered_fixtures に居て、destination_id はBを指す」、もう片方は
その逆です。2個に分けるのは、どちらの土地から見るかで行き先(=カードに出す土地名)が変わるためです。
発見は両側同時です。生成時に両端の path へ互いの instanceId を return_path_id として書き込んでおき、
片方を公開する際にもう片方も一緒に公開します(Location.revealDueFixtures)。片側だけを公開すると、
渡った先の土地を required_progress に達するまで探索し直さない限り戻れず、行き止まりに閉じ込められた
ように見えます。歩いた道の帰り方は分かる、という直感とも一致します。
required_progress は「その土地を探索して自力で見つける」ための条件として引き続き働きます。一度も渡って
いない土地の道は、その土地の探索でしか見つかりません。
3.2 required_progress の割り当て(探索率100%に達する前に道が見つかることの保証)
「探索率が100%に達する前にすべての道が見つかる」ことを、データの調整ではなく生成側の不変条件として 保証しています。
ある土地に接続する道が K 本あるとき、それぞれの required_progress を、探索の上限を max として
[2, max − 1] の範囲へ均等間隔で割り当てます(src/domain/generation/IslandSpawner.ts)。最初の道が進捗2で
見つかる(1回目の探索でいきなり道が見つからないようにする)のを最速とし、最後の道は必ず max − 1 以前に
見つかります。この不変条件は tests/generation/islandSpawner.test.ts が全接続について検証しています。
100%到達後も探索は続けられますが(2 節)、進捗は max に張り付くため、新たに条件を満たす道はもう
現れません。「探索率100%=この土地の道は出尽くした」という保証は、この割り当てが与えています。
道の本数が3本以上でも、割り当てはこのままとする
「最初の道が見つかるまで」が長すぎないかを測って決めたもので、覆すのに人間の判断は要りません。
最初の道は、道の本数にも土地の型にもよらず、必ず2回目の探索で出ます(30 分)。
割り当ての下端が K に依らないためで、その土地を探索率100%まで開くのにかかる
185 分に対してごく手前です。
「長すぎる」は起こりえないので、調整する先がありません。
本数が効くのは間隔のほうで、道が増えるほど詰まります(実測は
stats/terrain.yaml の path_discovery_by_degree)。詰まる側は
止める理由がありません——道が多い土地ほど次の行き先が早く揃うのは、道が多いことの意味そのものです。
その土地に居続けた時間だけを数えています(通う移動は含みません)。読み方は
TerrainStats.md、破れたときに落ちるのは
tests/generation/islandSpawner.test.ts の「最初の道は、道の本数によらず同じ進捗で見つかる」です。
4. エンジン拡張(duration・move)
duration・move は地形・探索専用ではなく、どんな interactions/pick からも使える汎用の
文法拡張です。
duration(GameElementDefinition.md11.3 節): アクションの実行にかかるゲーム内時間(分)。 リテラルか{subject, prop}参照(weightと同じ二択)で指定します。時間は効果の適用より先にWorldSession.advanceWorldTimeで進めます(順序とその帰結は ActionSystem.md 2 節)。move(GameElementDefinition.md9.6 節): 対象オブジェクトを、selfのプロパティが指す インスタンスIDのオブジェクトへ移動させる active 動詞。
5. カプセル化: 探索の入口を1箇所にする
Location.explore(src/domain/wrappers/Location.ts)を、探索の唯一の入口としています。explore アクションを
実行し、成功したら続けて Location.revealDueFixtures(進捗が必要値に達した設置物を、隠しスロットから公開スロットへ
移す)を呼びます。
プレイヤー側の入口も同じく1箇所です。PlayerCharacter.explore(src/domain/wrappers/PlayerCharacter.ts)が
「今いる土地に自分を agent として渡す」という手順を引き受けるため、UI は自分の居場所を知らなくてよく、
探索できない場所に居る場合は false が返ります。
explore アクション(YAML側)の実行と、後処理の revealDueFixtures を呼び出し側(UI等)に分けて呼ばせません
(CLAUDE.md の「自分のことは自分でする」方針)。