コードの構造

src/ のコードがどこに属し、何を知ってよいかを決めます。新しいファイルをどこへ置くかは、 下の表のどれに答えるかで決まります。 各機能の設計理由はそれぞれの設計文書にあり、本書は 置き場所と境界だけを扱います。

1. 構成要素と置き場

この表が置き場の唯一の記載場所です。 組み立て・層の外・codex ビューアは層ではありません (2節)。残りが層です。

名前 答えること 知ってはいけないもの 置き場
世界 何が在り、何が起きるか。プレイヤーに見えていない土地・物も含む 画面のことば全部・Phaser・解析・codex ビューア src/domain/(src/loader/ が定義を読み、src/locale/ がことばを与える)
映し 世界のうち、プレイヤーに映っている分。今の断面と、遅れて見せる変化 Phaser・矩形・ミリ秒・解析・codex ビューア src/game/view/
意匠 それをどんな色・絵・寸法で見せるか 世界の状態・Phaser・解析・codex ビューア src/game/looks/(色・寸法・領域・時間の見せ方)
素材 どのファイルがどの絵か 画面の寸法・色・Phaser・解析・codex ビューア src/art/
汎用部品 Phaser の足りない分・間違っている分を埋める表示物 このゲーム——語彙も寸法も配色も。解析・codex ビューア src/ui/
部品 Phaserの表示物を作る・持ち続ける・触らせる 世界の語彙(レシピ・スロット名・プロパティ名)・解析・codex ビューア src/game/ui/
解析 宣言をどう読むか。答えは必ず近似になる(5節) 効果・条件の木そのもの(読み上げ口だけを使う)・codex ビューア src/analysis/
組み立て どの部品をどこへ置き、映しの答えを誰へ渡すか 解析・codex ビューア。それ以外の層はすべて知ってよい唯一の場所(2節) src/main.ts・src/game/DeviceScreen.ts・src/game/*Scene.ts・src/game/ui/*Window.ts
層の外 層のどれでもないコード Phaser・解析・codex ビューア 横断の道具(src/util/・src/game/errorReport.ts)、保存と起動時の入力(src/save/・src/scenario/・src/game/launchSeed.ts)、外から材料を受け取る口(src/asset-pack/。AssetPack.md)
codex ビューア 定義YAMLを型・プロパティ・操作の単位で辿って読ませる別ページ Phaser(ゲームの画面を持たない) src/codex-viewer/

src/ 直下と src/game/ 直下がこの表に出そろっていることは、自動テストが数えます (tests/architecture/layers.test.ts)。置き場を1つ足したのに表へ書き忘れると落ちます。唯一の 例外は src/assets/(データファイルはコードではないので、どの行にも属しません)。

「知ってはいけないもの」は規約で、自動テストが見張っているのはその一部です。 どこを見張って いるかは同じテストの PHASER_FREE・ANALYSIS_FREE・VIEWER_FREE が持ちます——ここへ書き写すと、 一致を誰も検査しない2つ目の一覧が増えるので置きません。

解析と codex ビューアは、この2行を除くどこからも見えません(表の「知ってはいけないもの」)。 解析を知ってよいのは codex ビューアだけで、近似を遊びの本体へ持ち込まないためです(理由は5節)。 codex ビューアは逆にどこからも見えません——宣言をどう見せるかの語彙が、ドメインやゲームの契約へ 混ざらないようにするためです。

層の外に並ぶものは性質が違います。それでも同じ行に居るのは、どれも「何を知ってよいか」で 位置が決まらないからであって、似ているからではありません。

映しは、世界を写した像です。行動のたびに作り直され、時間のかかる行動では tick ごとの断面として 控えられます(RecordedView)。「何が出ていて、その上の操作が何を意味するか」を答え、描きません。

部品が持ってよい状態は「今見せているもの」だけです。「次に何を見せるべきか」は映しが答えます。

ディレクトリが置き場です。ただしこれはコードの話で、データファイルはどこにも属しません——絵・定義 YAML・表示文字列・シナリオはすべて src/assets/ 以下に置き、それを読むコードだけが置き場を持ちます。 public/ はこれと別で、ビルドが素通しで配るファイル(アセットパックのZIPなど、取得しに行かれる 配布物)だけを置きます。バンドルへ入るなら src/assets/、URLのまま配るなら public/ です。

2. 依存の向きと、層ではないもの

ノードは1節の表の行そのもので、食い違っていないことは自動テストが数えます (tests/architecture/layers.test.ts)。破線の枠は層ではないもの。層の外 はどこからでも使えるので、 入ってくる矢印は描いていません。

置き場はノードに書きません——1節の表が唯一の記載場所で、ここへ写すと一致を誰も検査しない 2つ目の一覧が増えます。矢印も依存してよい主な向きで、実際の import の全数ではありません。 どこへ依存してよいかを決めるのは1節の表の「知ってはいけないもの」で、図はその向きを読みやすく したものです。

flowchart LR
    Assembly["組み立て"]
    Parts["部品"]
    Common["汎用部品"]
    Looks["意匠"]
    Art["素材"]
    View["映し"]
    World["世界"]
    Analysis["解析"]
    Outside["層の外"]
    Codex["codex ビューア"]

    Assembly --> Parts & Common & View & Looks & Art & World
    Parts --> Common & Looks & Art
    View --> World & Looks & Art & Parts
    Looks --> Art
    Analysis --> World
    Outside --> World
    Codex --> World & Art & Analysis

    classDef notLayer stroke-dasharray: 5 4
    class Assembly,Outside,Codex notLayer

意匠は映しからも部品からも参照されます(札の色は映しが選び、その色で描くのは部品)。

組み立て・層の外・codex ビューアを層と呼ばないのは、層どうしを分けている「何を知ってよいか」で 位置が決まらないからです。組み立ては解析を除く全部の層を知ってよく、層の外はどの層からも使われ、 codex ビューアはゲームの画面を持たない別ページとして層の並びの横に居ます(ゲームを消してもこちらは 動きます)。どれも並びの中に居場所がありません。

起動(src/main.ts)が組み立ての一番外側で別の層ではないのも同じ理由で、知ってよい範囲が組み立てと まったく同じだからです。層として並べると一番上に太いものが乗っているように見えますが、上下ではなく 結線です。

この境界は自動テストが見張ります(tests/architecture/layers.test.ts)。禁じた先へ import で到達 できないことを、間に何本挟まっていても辿って確かめます。契約を型として輸入するぶんを数えないのは、 Phaser が付いてくるかを見ている境界だけです(型はビルドで消えます)。知っていること自体を禁じて いる境界では型も数えます——汎用部品がこのゲームの型を1つでも署名に置けば、そこはこのゲーム抜きでは 読めなくなるからです。

組み立ては Phaser の中に居るのでそのままでは自動テストにかけられません。そこで順序そのものに意味が ある判断は、Phaser に触らない側へ出します——1回の操作を運ぶ手順(src/game/view/operationSteps.ts)と、 経過を実時間で再生する刻み(src/game/view/elapsePlayback.ts)。組み立てに残るのは「その1つずつを どう見せるか」だけになります。

3. 迷ったときの判定

定義は読んでよい。読んで判断してはいけない

定義(src/domain/ のうち、ロード後に書き換わらないもの)は読み取り専用です。映しや部品が読んでも 壊れる不変条件が無いので、公開範囲を絞ること自体は目標になりません。何を公開する必要があるかは どんな画面を作るかで決まり、論理的な線は引けません——枠を描くなら cellCount は要るし、発見テーブルを 見せるなら pick の中身が要ります。

引ける線は「読んだ値で誰が判断するか」の側です。

公開を増やすときは、格納の形ではなく問いの形で足します(visibleSlotGlobalIds・artSuffixes()・ describe(names)・readBy(reader)・SlotDef.cellCountPolicy)。画面が増えれば口も増えますが、増えるのは口の実装であって、 定義の内部が1つずつ剥がれていくことにはなりません。

定義とインスタンスのどちらへ訊くかも、これで決まります。

答えにインスタンスの状態が要るならインスタンスへ、型だけで決まるなら定義へ訊く。

だから PropertyValue.alert はインスタンスにあり(どの値で判定するか——実体値か実効値か——を 呼び出し側に決めさせない)、名前・ゲージの宣言・タグの有無は property.def から直に読みます。 インスタンスに定義への素通しを生やすと、この線がぼやけます。WorldObject.def・Slot.def・ PropertyValue.def はいずれも公開で、素通しは持ちません。

この線を引くのはクラスであって、ディレクトリではありません。 同じものの宣言と実体が別のクラスと して並んでいること(ObjectDef と WorldObject、SlotDef と Slot、PropertyDef と PropertyValue)が線の現れで、どちらへ訊くかはメソッドがどちらに生えているかで決まります。 定義は受動的なレコードではなく世界を引数に取る振る舞い(ConditionNode.evaluate・ InteractionDef.tryExecute)なので、宣言と実行はもともと同居しています——src/domain/ 直下を宣言と 実体のフォルダへ分ける形が不採用なのはそのためで、測ったものは DesignNotes.md「定義と実行時状態」が持ちます。

そのため直下に置くサブディレクトリは、1つの話題で閉じたまとまりだけです。どれが在るかは tests/architecture/layers.test.ts の DOMAIN_SUBDIRS が 持ちます——ここへ書き写すと、一致を誰も検査しない2つ目の一覧が増えるので置きません。直下のまとまりが 増減したらそこが落ちるので、宣言と実体で分けるにせよ、話題をもう1つ増やすにせよ、決めごとのほうを 先に直すことになります。

4. Phaser をやめるとどうなるか

書き換えるのは部品・汎用部品・組み立てだけです。 残りはどれも Phaser を知ってはいけない側なので、 判断も語彙もそのまま残ります。

部品の中でも、規則を抱えているもの(掴んだと見なす閾値、点がカード本体か隙間か、押し続けの間隔)は Phaser に依っていません。書き換えの前に映しへ寄せられる分がどれかは、そのときに見直します。

汎用部品(src/ui/)は逆にPhaser にだけ依っています。9patch がCanvasレンダラで描かれない、 日本語が折り返されない、pointerup が押し始めた場所と無関係に届く——間違っている分を埋めるものは、 Phaser をやめれば役目ごと消えます。足りない分を埋めるものは残り、次の土台もそれを持っていなければ 同じものを書き直すことになります。

5. 解析(src/analysis/)——近似の置き場所

定義を読んで数値を導く側です。収支レポート(stats/balance.yaml)と クラフトネットワークがこれを使います。世界を動かさずに「1個手に入れるのに何分か」を答えるので、 遊びの本体からは切り離されています。

ここに置く理由は、答えが必ず近似になるからです。実行時のオブジェクトが1つも無い文脈で 数値を出すには、居ない相手のぶんを何かで埋めるしかありません——祖先(置かれている土地)の値、 使う物(武器)の値、条件つきの増減が同時に成立するか、抽選の重みを確率と見なしてよいか。 どれも定義の側は答えを持っておらず、「そう置いた場合」を決めた側だけが答えられます。

その仮定をドメインへ置くと壊れます。定義クラスは本来の宣言より解析のほうが長くなり、 解析だけが要る内部が公開され、そして近似が「定義がそう言っている」ように見えてしまいます。

ドメイン(src/domain/) 解析(src/analysis/)
答えること 宣言に何が書いてあるか それをどう読むか
例 range は 0〜24、pick の候補は3つで重みはこれ、add: {child: {…}} がある 24へ届くのに25 tick、重み70は確率0.553、隣に炉が在れば焼ける

境界の向きは解析 → ドメインの一方通行で、tests/architecture/layers.test.ts が import を辿って見張ります。型として輸入するのも数えます——StaticValueResolver を引数に取った 時点で、その仮定はドメインの契約になってしまうからです。

越えるのは問いの形だけで、答えは越えません。 「この起点のこのプロパティは何か」という問いは ドメインが持ち(ReferenceValueResolver)、世界が在る場面では ReferenceContext が今の実効値で、 オブジェクトが1つも無い場面では解析の解決器が近似で答えます。だから宣言の読み方——所要時間の端数を どう落とすか、宣言が無ければ何分か——は宣言を持つ側(InteractionDef.minutesFor)に1つだけ在り、 解析はそこへ自分の答え手を渡して引きます。解析だけの都合——生成時のロールのどちらの端で読むか (StaticValueResolver の end)——は、端を決めた後の問いになってから渡るので、境界を越えません。

効果の木は、読み上げてもらう

解析はプロパティの増減も産出も知りたいのですが、効果の木(ActiveEffect)を外へ出すと カプセル化が崩れます。そこでドメインは読み上げ口だけを持ちます(EffectReader)。

分かれ道の受け方は、読み上げ自身が読ませる口として持ちます (pick・conditional の説明から辿れます)。読み手は自分の問いに合う口を呼ぶだけです——「それが 起こりうるか」を問うなら分かれた先を全部読ませ、「何がどれだけ起こるか」を問うなら1つずつ受けて、 重みで畳むか、著者が書いた枝だけを数えます(どちらの枝かは枝自身が名乗るので、読み手が枝を見比べる 必要はありません)。 呼ぶのは解析に限りません——ビューアも遊びの本体も同じ口を呼びます。どれが自分の問いに合うかは口の側が 説明するので、受け口には自分の問いを書くだけで済みます(読み手が増えたときに倣う先が要るため)。

読み上げのメソッドは動詞ごとに分かれていて、readBy は抽象です。動詞を1つ足したときに読み手が 黙って取りこぼさないようにするためで、実装を書かない効果はコンパイルが通りません。

入れ子も、読み下せる宣言として渡す

pick の候補や all/any/not の子のように、読み上げの途中でさらに読み下すものがあります。 ここで木のクラス(ActiveEffect・ConditionNode)を渡すと、読み手は読むだけでなく適用(apply)や 評価(evaluate)までできてしまいます。渡すのは readBy だけを持つ宣言 (EffectDeclaration・ConditionDeclaration)で、入れ子をどう畳むかは読み手が決めます。

木そのものを組み立てるのはドメインとローダーだけで、 tests/architecture/layers.test.ts が読み手からの輸入を 見張ります(どこを見るかは同テストの TREE_READERS)。