スロットシステム設計
概要
スロット(親子関係とコンテナ、GameElementDefinition.md 7節)が
実行時にどう実装されているかを記述する設計ドキュメントです。重さの伝播は
ContainerSystem.md、中身入りの容器は
LiquidContainerSystem.md を参照してください。
データ構造はセルの並びとスタックの2階層(1 節)で、受け入れ判定は重ならない問い——型が合うか
(accept)・位置があるか(cell_count/max)・かさが入るか(capacity)——に分けます(2 節)。
束ねてよいか(stackable)は枠ではなく物の性質です(4 節)。スロット専用の新しい文法はありません。
定義は SlotDef、実行時状態は Slot(受け入れ判定)と CellLayout(枠の並び)と ObjectStack、
検証は tests/domain/cells.test.ts・stacking.test.ts・objectStack.test.ts 等です。本書は実装済みの
仕組みの記述で、未決事項は 7 節に整理しています。
1. データ構造: セルの並びとスタックの2階層
スロットの中身は「セルの並び」として持つ(Slot.cells)。
- セル = 1つの
ObjectStack(同種のまとまり)か、空(null)。位置 = セルの添字。 ObjectStack= 見た目上1単位として積み重なる同種インスタンスのリスト(束ねて見せることの宣言はGameElementDefinition.md7.6節)。- 親子関係の正の情報源は親側のスロット配列で、子側の
WorldObject.parentSlotは逆引きキャッシュ。 ツリーそのものの決まり(1つの子は1つの親、worldを根とすること)はGameElementDefinition.md7.1節。 - 枠は自分の持ち主を知っている(
Slot.owner)。受け入れ判定に要る規約プロパティも、断る理由に 書く名前も、呼び出し側から渡されずに自分で辿る。
セルとスタックの区別を畳み込んだ派生ビューが Slot.contents で、位置を要さない内部処理(タグ判定・
重さ集計など)はこちらを読む。スタックの分かれ方まで見たい読み手には Slot.stacks がある。
Slot が中身を並びで返す口は、読んだ時点の顔ぶれを写して返す——これは実装の都合ではなく契約で、
受け取った側は辿っている途中でそのスロットの中身が出入りしても構わない(時間経過は、中身を辿りながら
子を消す。WorldObject.tick)。中身の並びの実体を持つのは ObjectStack で、それを直に読む経路
(Slot.cells を辿る側)は、後で読み返すなら自分で写し取る。写しでなくなれば
tests/domain/stacking.test.ts が落ちる。
出入りは唯一の汎用操作 move_to_slot(WorldObject.moveToSlotOrRejection →
attachToSlotOrRejection)経由のみ。
親子整合・weight 伝播・passive エッジの登録という副作用を1箇所に集約する。
枠が受け入れないものを押し込む経路は無い。 行き場を失った物は WorldObject.spillTo で親、さらに
その親…と落ちていき、どこにも入らなければ世界から消える
(GameElementDefinition.md 9.4節)。どの段でも枠の宣言はそのまま効く。
出入りの入口は、違いが位置をどう指すかだけ(WorldObject)。
moveToSlotOrRejection(slot, at?)— 位置は任意。省略すると置き場所は枠に任せる。insertSameSlotOrRejection(slot, placement)—same_slotの置き換え専用。placementは位置ではなく 「origin が居たセルと、そこに同種が残るか」で、どこへ置くかは配置時に決まる(同 9.4節)。reorderInParentSlot(at)— 今いる枠の中での並び替え。moveToSlotOrRejectionで同じ枠を指すのとは 別物で、付け替えはいったん抜いてから入れるため抜いた時点でセルが詰まり、指した位置の意味がずれる。 動かすのも1個ではなくスタック丸ごと。
行き先は Slot そのもので指す。 「どのオブジェクトの、どの名前の枠か」を組で渡すと、2つが
食い違う書き方が作れてしまう。枠を引くのは WorldObject.tryGetSlot / getSlot(プロパティと同じ
try/get の対)で、引いた後は枠だけが行き先になる——世界の変化の記録(WorldChange.from / to)も、
画面がカードを並べる場所(CardPlace)も同じ Slot を指す。
2. 受け入れ判定: 重ならない問い
スロットは中身を直接持たず、枠(セル)の並びを持つ。何が入るかも何個入るかも枠ごとに決まり、
Slot.rejectionFor は次を検証し、入らないならその理由を返す(入るなら undefined)。
いずれも定義が無ければ無制限。表に並ぶキーを定義するのは
GameElementDefinition.md。
| 制約 | 答える問い | 書く場所 |
|---|---|---|
accept(同 7.2節) |
何が入るか | 枠。型の指定(同 4.1節)でマッチ |
max(同 7.2節) |
その枠に何個積めるか | 枠。束ねられる型にだけ効く(4節) |
cell_count / cells(同 7.2節) |
枠がいくつあるか | スロット |
capacity(同 7.3節) |
どれだけ(かさ)入るか | スロット。中身の volume 合計 + 候補の volume が上限以下 |
どの2つも同じことを言えない。 「1枠に4個」と「4枠に1個ずつ」は意味も見た目も違うので、
枠の max と枠の数は入れ替えが利かない。
身につける枠だけは、枠の宣言に加えて物の側の宣言も見る(covers / layer。何が競合かは
GameElementDefinition.md 7.5節)。制約を持つのが枠ではなく中身なので、
上の表には並ばない。
入るか否かとは別に、入れるのにいくらかかるかもスロットが持つ(put_in、同 7.10節)。値段を枠に置くのは、
プレイヤーが物を入れる経路が複数ある(カードへ重ねる・レーンへ落とす・端の矢印で送る)ため——経路の側に
書くと、書き漏らした経路だけが無料になる。 時間を課すのは入れる側だけで、出すのは常に一瞬。
枠は宣言順に埋まるのではなく、型の合う枠へ入る。 「板1枚と棒4本」の材料欄へ棒を先に入れれば、
板の枠は空いたまま棒の枠が埋まる。合流できる枠(同種・maxに空きあり)があればそちらが優先される
(「同種は1スタック」、4〜5節)。
受け入れが重なる枠でも、入る組み合わせが在るなら入る。 刃物の枠と尖った石の枠を持つ材料欄へ尖った
石を先に入れると、石は先に宣言された刃物の枠へ入る。そこへ石斧(刃物の枠にしか入らない)を入れると、
入っている石を型の合う別の空き枠へずらして刃物の枠を空ける(CellLayout の pathToCellFor)。
型の合う空き枠が直接あれば中身は動かさない。置ける数を答える側(vacancyForIgnoringVolume)も同じ
ずらし方で数えるので、入れれば入る物を受け入れ判定が断ることはない。検査は tests/domain/cells.test.ts
「受け入れが重なる枠」。
数はスロットに持たせない。 型ごとの個数(「板1枚と棒4本」)は枠の形そのもので表す。レシピが
要求する量を書く先は requires だけで、枠には書かない(同 7.2節)。
placement(同 7.7節)はここには入らない。「このスロットへこの物を入れられるか」ではなく「エンジンが宛先を
自分で選ぶ走査に加わるか」を表すため、判定するのは Slot.rejectionFor ではなく走査する枠を集める側
(ObjectDef.placementSlotDefs——枠がその走査に加わるかは SlotDef.allows が答える)。名指しの
move_to_slot はこの属性を見ない。
3. 枠数を決めたスロットと、決めていないスロット
両者の違いは「空になったセルを残すか」「枠を増やせるか」「位置として枠を指せるか」で、
どれも cellsToKeep 1つから決まる。置き方(どこへずらすか)のロジックは共通。
cell_count(またはcells)あり(例: プレイヤーの手持ち。枠数はキャラクタごと): セル配列は常にその長さで、 空セルを null として保持する。位置が安定する。- なし: 空になったセルは削除して前詰めする(null を含まない)。
枠数を決めることと位置が安定することは同じことなので、これらを別々のキーにはしない。読む側へも
1つの問いとして答える(SlotDef.cellCountPolicy——宣言した数か、'grows')。別々に答えると、同じ1つの
判断を読む側の数だけ組み立て直すことになる。枠が5つだと
分かっていれば「5番目が空いている」と言えるが、長さが可変の並びには指し示すべき空き枠が無く、詰めるしか
ない。逆に、枠数を決めた瞬間に位置は安定させられる。
プレイヤーによる手動配置・並び替えは、どちらのスロットにもできる。位置の指定は「セルとセルの隙間」
(0が先頭のセルの前)か「空きセルそのもの」のいずれかで行い、SlotPosition 1つで表す。
どちらを使えるかを指す側は知らなくてよい。 空きセルを指せるのは枠数を決めたスロットだけだが、
前詰めスロットの空きセルは末尾の受け皿しか無いので、そこでの「空きセル」はその位置の隙間と同じことを
意味する。この読み替えは並びの側(CellLayout)が行うので、画面は指で示した位置をそのまま渡す。
CellLayout.insertAt: 指定した位置へ、外から1個を入れる。枠を指した場合はその空きセルを埋める (埋まっていれば失敗)。隙間を指した場合は、まず右方向へ、それが無理なら左方向へ既存のセルをずらして 場所を作る。前詰めスロットは、ずらす先の空き枠を末尾に生やしてから同じようにずらす。CellLayout.moveStackTo: スロットの中での並び替え。スタックを丸ごと動かす。1個ずつの出し入れで 並び替えることはできない——挿入時の合流優先により、抜いた1個は残ったスタックへ戻ってしまうため。 枠を指した並び替えは指したセルとの単純 swap で、空きセルを指せば他のセルを動かさずにそこへ移る。 隙間を指した並び替えで詰める向きは、スタックが抜けた跡の側を先に試す(そちらには必ず空きがあるので、 遠くのセルを動かさずに済む)。
挿入は、合流できる既存スタックがあるときは指定された位置より「同種は1スタック」(4〜5節)を優先して そちらへ入れる。
どの指定でも、枠の宣言(accept・max)に合わない置き方はしない。 指した枠も、隙間へ入れるために
ずらされる中身の移る先も、並び替えで入れ替わる相手の移る先も同じく見る。合わなければ失敗で、型の合う
別の枠へは振り替えない。受け入れ判定(Slot.rejectionFor)は位置を渡されれば同じ問いに答えるので、
指した位置へ置けないことは動かす前に分かる。
same_slot spawn の置き換え配置は CellLayout.placeSameSlot が実装する。置き換え位置の決定(元のセル・
同種が残っているか・1つの効果から2個目以降が生まれたときの並べ先)は、効果適用の入口で捕捉した
SameSlotSpawnSite が担う(ActionSystem.md 5節)。どう書けばどちらになるかは
GameElementDefinition.md 7.6節。
- 元のセルを引き継ぐのは、起点の同種がその枠から消えたときだけ。 空いた元のセルへそのまま入るので、 種類が変わるアイテムが周囲の並びを崩したり新しい枠へはみ出したりしない。
- 同種が残っているなら「起点の隣」。 前詰めスロットでは起点の直後へ挿入する。枠数を決めたスロットでは、 まず起点の右隣から空きセルを探し、間のセルをすべて右へずらして割り込ませる。右に空きが無ければ左を 探し、間のセルを左へずらす。どちらの向きも前詰めはせず、押し出されたセル同士の相対順序は変わらない (ずらすのはセル単位なので、押し出されるスタックの中身はバラけない)。
- どちらの向きにも空きが無ければ配置は失敗し、起点自身の親へこぼれる(
fallbackが無いのと同じ扱い、 1節)。 - 合流できる既存スタックがあれば、位置の計算より合流が優先される(4〜5節)。合流したものはセルを 消費しないので、元のセルは枠を要る次の1つのために空いたまま残る。
- 1つの効果が複数を生む場合、元のセルを引き継ぐのは枠を要る最初の1つで、以降は直前に置いたものの隣へ 宣言順に並ぶ。
例(cell_count: 4、_ は空きセル): A(0) _(1) B(2) _(3) で A から C が生まれると(起点は残る)、
右隣の1番が空いているのでそのまま入り A C B _。続けて A から D が生まれると、右隣の1番は埋まって
いるので空いている3番までの間(C・B)を右へずらし A D C B。さらに A から E が生まれても、4枠が
すべて埋まっているため配置に失敗して親へこぼれる。生まれた側が起点と合流できる型(A から A)なら
新しいセルを消費しないので、このあふれは起きない。
右に空きが無い場合: _(0) _(1) A(2) B(3) で A から C が生まれると、右(3番)は B で埋まっているため
左を探し、空いている1番へ入って _ C A B。続けて B から D が生まれると、右(4番)はスロットの外なので
左を探し、空いている0番までの間(C・A)を左へずらして C A D B。
置き場の決まりを崩すと tests/domain/stacking.test.ts が落ちる——上の例がそのままケースとして並んで
いる。
4. 束ねてよいか(stackable)は物の性質
stackable は object_defs 側に書く(既定 true)。
stackable: true: 同一性(5節)が一致する既存スタックへ合流する。石を100個持っても枠は1つ。stackable: false(例: 道・かご): 同種でも常に個体ごとに別スタック。1個ずつ枠を使う。
入れ物ではなく物の性質である理由は、束ねたくない理由が個体の側にあるため。道は行き先が個体ごとに
違い、かごは中身が個体ごとに違うので、束ねると代表の行き先・中身しか触れなくなる。同じ物は、地面に
あっても手に持っても籠の中でも束ねない(詳細は DesignNotes.md)。
枠の max(GameElementDefinition.md 7.2節)は束ねられる型にだけ
効く。stackable: false な型を受ける枠に max: 4 と書いても実質1個で、2通りの言い方ではなく
片方の軸がもう片方の有効値を縛るだけ。
5. スタックの同一性
ObjectStack は生成時に seed の ObjectDef をスナップショットし、合流判定(canMerge)はこれとの
一致を要求する。水入り甕と茶入り甕が別スタックになるのは、中身入りの容器が中身ごとに別の型だから
(LiquidContainerSystem.md 1 節)で、スタック側は型を1つ見るだけでよい。
- スナップショットは生成後不変。
become(GameElementDefinition.md9.9節)でメンバーの型が変わり合致しなくなったとき動くのはメンバーの側で、今いる枠へ入れ直しを 頼む(Slot.restack)。これが「同種は1スタックにまとまる」不変条件を回復する。 - 「同種のみが積み重なる」は
ObjectStack.tryInsert自身が保証し、呼び出し側の事前確認に 依存しない。 - セルの位置を型(
ObjectDef)で引くことはしない: 位置は常に具体的なObjectStackで特定する (Slot.indexOfStack)。
6. スタック内の並び順(stack_order)
型ごとの表示専用の並び順(StackOrderDef。書き方と ascending の向きは
GameElementDefinition.md 7.6節)。指定プロパティの値で、新規
メンバーの挿入位置を決めるだけで、挿入後の値の変化に追従した再ソートは行わない
(add は同種で同じ速度で変化するため、挿入時点の相対順序が保たれる前提)。
同値は既存メンバーの後ろ(挿入順維持)。
この前提は宣言の側で保つ。 並び順が見る実体値を時間経過以外——操作の効果・操作の間だけ効く
積み上げ・range の端のイベント——が動かす宣言が入ると、tests/world-codex/stackOrder.test.ts が
落ちる。そうした書き手は束の1枚にだけ当たるので、当たった札が順序の外れた位置に居座る。数えない
ものもある——可逆な寄与(modify)は実体値を動かさないので並びも動かさず、値を端へ倒すだけの
既定のクランプは、端が並びの外側なので順序を崩さない。
7. 未決事項・今後の検討課題
restackで入れ直されたスタックは元のセル位置を保たない(最初の空きセル/末尾へ入る)。 中身の入れ替わった容器の表示位置を保持する必要があるか- 手動並び替え(枠数を決めたスロット)と
stack_orderの関係(並び替え対象はスタック単位であり スタック内はstack_order順のまま、が現状の挙動) - 1つのプロパティでは順が決まらない並び(複数のプロパティが絡むもの)を
stack_orderで表すか