液体容器システム設計

概要

液体(水・茶・油)を容器で保持し、飲用・注ぎ移し・蒸発させ、蓋で止め、雨を貯め、水源から汲む仕組みの設計ドキュメントです。定義本体は src/assets/world-codex/liquid_containers.yaml、検証は tests/world-codex/liquidContainersYaml.test.ts。

液体は独立した物ではなく、容器の変種として存在します(1 節)。この一点だけをエンジンへ宣言し (variation_axes)、あとは汎用文法(transfer・become・passives、いずれも GameElementDefinition.md)をそのまま使います。

本ドキュメントは検討結果であり、確定仕様書ではありません。未決事項は 9 節に整理しています。固形物の 入れ物(重さ・負荷・かさ)は ContainerSystem.md が扱います。

1. 中身入りの容器は、容器の変種

「水入りの甕」は、甕と水の親子2つではなく1つの型です。 容器が「中身」の軸を宣言し、その軸の 値ごとの型がロード時に生成されます(GameElementDefinition.md 3.5 節)。

jar:
  traits: [liquid_container, narrow_open_container]
  props:
    fill:
      value: 0
      # この容器の容量(mL)
      range:
        min: 0
        max: 4000
      on_min: {become: {content: none}}   # 尽きたら中身の軸を落とす=空へ戻る
  variation_axes:
    content: {of: {tag: liquid}}

液体の側(water_liquid 等)は配られる trait の束で、インスタンスにはなりません。器の無い水は 存在しない、が禁止ではなく構造で決まります。

振る舞い 個数のオブジェクト 中身入りの容器
量 持たない 自分の fill(mL)。空の容器は 0 のまま動かない
入りきらないとき 全部入るか、入らないか 入るぶんだけ入り、残りは元に留まる(transfer の両側のクランプ)
量が 0 になったとき (0にならない) 素の型(空の容器)へ戻る
上限を超えたとき (増えない) fill の range が受け止める

空の容器も fill を持ちます。 空とは量が 0 であることで、増やせるのは中身の trait を配られた 変種だけです——素の型には量を増やす宣言が1つも無いので、0 のまま動きません。空を表すための目印 オブジェクトも、空の液体も置きません。

【いつか: 図鑑の生成型】 ここで生成される型は図鑑の一覧に載っていません (Someday.md)。

2. 役割分担: 上限と量は容器、種類と振る舞いは中身

量そのものが容器側なのは、同じ水がヤシの殻(250mL)にも甕(4L)にも入るためです。 上限も、尽きたときに 空へ戻ることも、fill を持つ容器自身が宣言します——中身の軸を持っているのも容器なので、両方が 同じ場所にあります。

中身の宣言は「容器に混ざる前提」で書きます。 口の開き方を見る条件が {subject: self, matches: {tag: wide_open_container}} と自分自身を見るのは、容器と中身が同じ1つのオブジェクトだからです。蓋が 載っているかを見る条件も同じく自分自身の枠(lid)を見ます(6.2 節)。

3. 名前・絵・バーの出どころ

生成された型は表示の対応表に自分のエントリを持てないので、素の型と軸の値から組み立てます。

4. 注ぎ移しは transfer

液体は場所が変わるだけで別の概念にはならないので、注ぎ移しは量の移送です。宣言は液体の trait が 持ち、相手が空か中身入りかで 2 つに分かれます。

water_liquid:
  tags: [water]
  interactions:
    # 相手も水入りのとき。注ぎ先が満ちていれば断る。**狭いタグを持つこちらを先に書く**(下記)。
    pour_into_filled:
      trigger: {drag: {tag: water}}
      conditions:
        - {reason: container_full, not: {prop: fill, in_stage: full}}
      transfer: {from: instrument, from_prop: fill, to: self, to_prop: fill, amount: 999999}
    # 相手が空の容器のとき。相手を水入りの変種にしてから移す(becomeが先。移した先を中身入りとして
    # 名乗らせるためで、空の容器もfillを持つ以上、transferを通すためではない)。
    pour_into_empty:
      trigger: {drag: {tag: liquid_container}}
      conditions:
        - {reason: not_empty, subject: instrument, prop: fill, eq: 0}
      become: {subject: instrument, content: water_liquid}
      transfer: {from: self, from_prop: fill, to: instrument, to_prop: fill, amount: 999999}

宣言を持つのは中身入りの側だけなので、どちらの札をどちらへ重ねても self は中身入り・instrument は 相手になります(GameElementDefinition.md 12.3 節)。汲む向きと注ぐ向きで別の宣言は要りません。

異種の混合は、混ぜる組み合わせが現れないことで阻まれます。 種類ごとのタグ(water・tea・oil)に 合致しないので、茶の入った容器へ水を注ぐ組み合わせはそもそも候補になりません。

入りきらない分は注ぎ側に残ります。 transfer が出せる量と受け取れる量の両方でクランプするので、 「あるだけ注ぎ、入るだけ受け、残りは残る」に特別な記述は要りません。

満ちきった器へは注げません(理由は container_full)。1mL も入らない移送は受け取れる個数が 0 に なるので、断る理由を宣言していなければ落とし先から消えて「重ねても何も起きない」に見えます (CardInteraction.md 2.1 節)。満ちる境目は器の側が fill の full 段 として名乗り(2 節)、中身はその名前で訊くだけなので、器ごとに違う容量が中身の宣言へ漏れません ——飲用が hydration の full 段を見るのと同じ形です(5 節)。

この理由が画面へ届くために、pour_into_filled を pour_into_empty より先に書きます。 相手が 名乗るタグ(water)は liquid_container に必ず含まれるので、満水の器同士ではどちらの宣言も 理由付きで断る側に残ります。画面が出すのは宣言順の先頭(CardInteraction.md 2 節)なので、順を逆にすると container_full は一度も出ず、注ぎ足せるはずの相手へ「中身が入っている。 まず空にする必要がある」(not_empty)だけが出ます。not_empty が正しいのは相手が別の液体を 抱えているとき——そこでは種類ごとのタグに合致せず、pour_into_empty だけが残ります。

順そのものを見張るのは tests/world-codex/liquidContainersYaml.test.ts で、液体の種類も器も全数を 走るので、液体を増やしたときに順を逆に書けば落ちます。汲む側(draw_into_filled と draw_into_empty、10 節)も同じ制約を持ち、同じ検査が同じ対として見ています。

注ぎ切った側は空の容器へ戻ります。 fill が 0 になった変種は素の型へ戻り(1 節)、名前の「〜入り」も 中身のバーも同時に消えます。飲み切った容器(5 節)も同じです。

液体の種類を増やしても、容器側に手を入れる必要はありません。

4.1 色(color)

液体の trait は自分の色を color プロパティ(0xRRGGBB の整数)として宣言します。容器カードに出る 「中身の割合を示すバー」がこの色で塗られます(CardView.md 8.2 節)。

UI 側に液体ごとの対応表を持ちません。 表を持つと、後から足された液体が必ず「色の分からない液体」に なり、YAML を書くだけでは画面に出せなくなるためです。UI が知っているのは「color という名前の プロパティが塗りの色である」ということだけで、液体の種類は知りません。宣言していない液体は灰色で 出ます。共通の既定値は置かず、種類ごとの trait がそれぞれ宣言します——何色に見えるかは液体そのものの 性質で、液体一般に既定の色は無いためです。

5. 単位と飲用(drink)

中身の量(fill)の単位は mL、重さ(weight)の単位は g です(1 = 1mL、1 = 1g)。 これらは 別々に選べません。weight は容器の自重に fill × density を足したもので、density は g/mL そのもの(水は 1)なので、水は「量の数値が そのまま重さの数値」になります。水 1L = 1kg を保つ以上、量の単位を 1mL にすると重さの単位は 1g に決まります。

単位が噛み合っているので、換算定数は要りません(mL × g/mL = g)。油のように水より軽い液体は density: 0.92 と実際の比重をそのまま書けます(いまはどの液体も 1 のまま。9 節)。容器の capacity は 1L = 1000、ContainerSystem.md の weight は 1kg = 1000 です。

hydration(characters/)は mL には載せません。「あと何 tick 保つか」を値がそのまま表す側 (GameElementDefinition.md 6.0 節の時間を数えるクラス)に置き、-1/tick で減ります。飲んだ水が体重に 反映されるわけでもないので、体内の水分を体積として持つ意味がありません。

mL から tick 数への換算は、飲用の宣言が持ちます(transfer の amount/to_amount、 GameElementDefinition.md 9.5 節)。飲用 1 回の 250mL = 10 tick 分です。max は 288 です。エンジンは換算率を知りません—— 同じ 1 口でも水より寄与の小さい液体(酒)は、この比を小さくするだけで表せます。

器 1 つが何日ぶんかは、この換算率と容量の割り算です。 甕(4,000mL)は 16 杯=1.7 日ぶん、 ヤシの器(250mL)は 1 杯=2.5 時間ぶん。持ち出す量を決める場面で読むのはこの数で、航海に積む 甕の数もここから出ます(Voyage.md 3.9.6 節)。画面はこの割り算をしません ——残量は量のまま出し、日数へ直すのはプレイヤーの仕事です (GameEndings.md 9.2 節)。数が宣言とずれれば tests/diagnostics/waterAndBloodPace.test.ts が落ちます。

これは素の減りでの目盛りで、上限のほうです。 器に入れたまま置けば蒸発が引き(6 節)、脂を切らせば 水分の保ちが半分になる(DigestionSystem.md 8 節)ので、どちらも この日数を短くする向きにしか働きません。

飲用は液体トレイト側のメニュー型の操作。transfer で自分の fill から 1回 250(=250mL)出し、 agent.hydration を 10 増やします。transfer の在庫クランプにより、残量が 250mL 未満なら残っている分だけ 飲みます(増える水分もその比で減ります)。逆に hydration 側の空きが 10 tick 分未満なら入る分だけ飲み、 あふれる分は容器に残ります(allow_overflow 既定の受け側クランプ。空きは mL へ割り戻して比べられます)。

6. 蒸発

evaporating_liquid トレイトの passives(add)で、自分の fill を毎 tick 減らします。 日射に依らない基礎の蒸発と、ancestor.ambient_brightness のしきい値で決まる上乗せの和です。 しきい値は EV(IlluminationSystem.md 1節)で、どのくらいの明るさで水が 減り始めるかとして置いてあります(EV と照度の対応は同節)。

基礎 +≧+12 +≧+14 +≧+16
wide_open_container(ヤシの器) -1 — -1 -1
narrow_open_container(甕) -2 -2 -2 -2

いちばん下の +12 が「水が減り始める明るさ」です。 薄日でも直射がある明るさで、開けた土地では、 晴れた空なら朝のうちに届き、曇りでは1日を通して届きません(下の時刻表)。上に並ぶ段は、雲の無い空の 正午(+16)を最上段に置いた、その間の刻みです。

読むのは世界の日射ではなく、器が居る場所の環境光です(IlluminationSystem.md 2節)。 樹冠と地面の反射が入っているので、日陰に置いた器は上乗せの蒸発をしません。

据え付けの光源は蒸発に効きません。 光源が書くのは hand_brightness と looking_brightness だけで (同 3節)、ambient_brightness には入らないためです。焚き火や松明のそばに置いても、水は余分には 減りません——器を照らす明かりと、水を乾かす日射は別のものです。

しきい値は日射の照度として置いた値のままです。 EV は照度そのもので、ambient_brightness は 「その場所へ届いている照度」なので、比べる相手が世界の日射から器の居る場所へ移っても、線の意味は 「器へ届いている照度がいくつか」で変わりません。樹冠も反射も無い開けた土地(value: 0)では 世界の値と一致するので、置き直しても同じものになります。

土地ごとの効き方(value は ContentSkeleton.md 8.1.2節):

土地 ambient_brightness の value 上乗せ
砂浜 +1 開けた土地より1段ぶん早く効く(曇りの正午でも最下段に届く)
草原・岩場・荒野・山腹・山頂・岩だらけの海岸・岸壁 0 下の時刻表のとおり
筏・小島・本土 0 同上(遮るものが無い。voyage.yaml)
森 −5 効かない
密林 −9 効かない
浅い洞窟 −6 効かない(湧く先は value: 0 の土地だけ)
海区 0 器を置けない(sea_zone が items 枠を落とす)。効くのは筏の積荷・手持ちの器の祖先としてだけ

森と密林で上乗せが丸ごと消えるのは、樹冠が日射の大半を遮るからです(同 8.1.2節)。雲の無い空の 正午でも森の地表は、開けた土地の曇りの正午ほどの明るさしかありません。効かない土地はどれも、どの空・ どの時刻でも最も低いしきい値に届きません(tests/diagnostics/sunlitEvaporationContent.test.ts)。

時間帯の概念は液体側に持ち込みません。 夜は hour の夜ステージが底(−6)へ均す(core.yaml)ので、 上乗せは自動的に消えます。時刻の区切りを変えても蒸発側は無修正で追従します。

上乗せ側は天候を見ません。 雨の空は、最も明るい砂浜のどの時刻でも、最も低いしきい値に届かない ためです(同じ検査)。天候の判定が要るのは基礎の側だけで、湿度が未実装なので雨天を 「湿度が高くて乾かない」の代理として除外しています。蒸発は sheltered(ContainerSystem.md 6 節)を見ません——除外しているのは湿った空気であって、雨が当たるかどうかではないためです。洞窟の中の 器も、外が雨なら乾きません。蓋を載せた容器は、基礎も上乗せも成立しないため蒸発しません (6.2 節)。蒸発しきった液体は消滅します(1節)。

外部への依存は、しきい値が乗っている ambient_brightness の項だけです——世界の側の寄与 (core.yaml の hour・weather それぞれの modify 量)と、土地の側の value(locations.yaml)。 どちらを変えても蒸発量が動きます。

上乗せが効く時刻(開けた土地。hour。日の出6時・日没18時。定義から数え直した値と食い違うと tests/diagnostics/sunlitEvaporationContent.test.ts が赤くなります):

天候 正午の明るさ ≧+12 ≧+14 ≧+16
scorching +16 7-16時 8-15時 11-12時
sunny +15 7-16時 9-14時 —
clear +14 8-15時 11-12時 —
cloudy +11 — — —
雨系 +10以下 — — —

満杯から空になるまでの日数(開けた土地。stats/climate.yaml の weather_hours で加重した実測ベース):

季節 ヤシの器(250mL) 甕(4L)
calm 2.6日 16.6日
wet 9.8日 65.1日
dry 2.3日 14.7日

この表は手で書いていますが、上の passives から数え直した値とずれると npm test が赤くなります (tests/diagnostics/rainWaterContent.test.ts)。数えているのは src/analysis/seasonalRain.ts で、 ancestor.ambient_brightness には時刻と天気の寄与だけ(ambient_brightness の value が 0 の土地)を 置いています。

乾季は平均 30 日 なので、蓋をしていない容器は、平均的な長さの乾季を越えて水を持ち越せません。 森や密林へ置いても同じです——上乗せが消えても基礎の蒸発(甕 -2/tick、ヤシの器 -1/tick)は残り、乾季に降る雨を差し引いても、どちらも平均的な長さの乾季より先に空になります (tests/world-codex/liquidContainersYaml.test.ts)。日陰へ置いても持ち越せるとは当てにできず、乾季をまたがせると 当てにできる手立ては蓋だけです(6.2 節)。

6.1 天候と太陽高度は、明るさ1本へ畳んだままにする

上乗せが読むのは ambient_brightness 1 本のままにします。 天候と太陽高度へ別々の蒸発量を与えません。

畳んだ数からは、clear の朝(7 時)と cloudy の正午が同じ +11 であるように、元の 2 軸が戻せません。 それが蒸発の誤りになるのは、同じ上乗せを乾いた空と曇った空が分け合うときだけです——明るさが同じでも 曇った空の下の空気は湿っているので、そこだけ水が飛びすぎます。逆に晴れ系どうしが分け合うぶんには失う ものがありません。scorching/sunny/clear の差は雲の厚さの差そのもので、それは明るさが持っている からです。

開けた土地では、分け合いが起きません。 曇りの正午は +11 で、いちばん下のしきい値 +12 に届かない (6 節の時刻表)ため、曇りも雨も上乗せを 1 段も受けません。森・密林はそもそも上乗せが丸ごと消えます。

分け合いが起きるのは、地面の照り返しで 1 段明るい砂浜だけです。 そこでは曇りの正午が最下段へ届き、 甕(narrow_open_container)が晴れた朝と同じ速さで乾きます。砂浜はこの世界でいちばん明るい場所—— ambient_brightness の value が正なのはここだけ——なので、島の外(海区・筏)でも起きません。動く面積は 次のとおりで、上乗せが効いている時間のうち曇っていたぶんにあたります(時間/日。上乗せが効くとは、 いちばん低いしきい値 +12 に届いていること)。

季節 上乗せが効く時間 うち曇っていた時間
calm 7.2 0.5
wet 1.7 0.2
dry 8.3 0.4

2 軸を分ければこのぶんは正せますが、そのために天候ごとの蒸発量を持つことになります。砂浜に置いた 口の狭い器の、いちばん下の段だけのために持つ仕組みではないと判断しました。

この表も手で書いていて、数え直した値とずれると npm test が赤くなります (tests/diagnostics/sunlitEvaporationContent.test.ts)。数えているのは src/analysis/sunlitEvaporation.ts で、「砂浜だけ」も同じ試験が見ています——天候の透過率・ しきい値・土地の明るさのどれかが動いて曇りが上乗せへ届くようになれば、そこで赤くなります。

畳みで困るのは蒸発だけです。 ambient_brightness を読む他の側——見えるかどうか (IlluminationSystem.md 5 節)と気温(同 9 節)——が知りたいのは 「その場へ陽がどれだけ届いているか」そのもので、畳んだ数がまさにその答えになっています。明るさに 現れない軸が要るのは、湿った空気が乾きを止める蒸発だけです。

6.2 蓋を載せた容器は、蒸発も雨受けもしない

甕には蓋(jar_lid)があります。 粘土を平たく伸ばして甕と同じ覆い焼きの炉で焼いたもので (pottery.yaml)、甕の lid 枠へ載せている間、中身は 1mL も蒸発しません。基礎の蒸発ごと止まる ので、上乗せだけを落とす形ではありません——塞いだ器は水を飛ばす口を持たないので、基礎の行も 上乗せの行も 1 つ残らず落ちます。

引き換えに、雨も受けません(7 節)。溜め始める操作(collect_rain)は蓋を載せたままでは押せず (理由は lidded)、溜まる側の passives も同じ枠を見ます——片方だけを塞ぐと、始められるのに 増えない器と、蓋をしたまま増える器のどちらかが出ます。

外せば元どおり蒸発します。 蓋は消費されず甕の枠に載っているだけなので、雨を受けたいときは外し、 運ぶときは載せる、という行き来ができます(DesignPrinciples.md の 「線を引いたら、そこから戻る道を1本だけ開けておく」節)。

口の広さは器のタグのまま、塞いでいるかは枠で見ます。 口の広さ(wide_open_container / narrow_open_container)は器の作りそのもので変わりませんが、塞がっているかは載せ外しで変わるためです。 蒸発と雨受けのどの行にも同じ一文が付くのは、口が開いていることと塞いでいないことが揃って初めて 水が動くからで、器が増えても蓋が増えても宣言の形は変わりません。枠を持たない器(ヤシの器)では この条件は常に成り立ちます——持っていない枠を見る条件は偽になるので (GameElementDefinition.md 14.3 節)、蓋の無い器の宣言に手を入れる必要が ありません。

飲むことと注ぐことは止めません。 現実には蓋を外してから注ぐことになりますが、蓋が引き受けるのは 蒸発と雨の 1 点だけにしています——注ぐたびに外させても、払う手間が増えるだけで判断は増えません (同「プレイヤーから考える時間を奪わない」節)。

6 節の日数の表と stats/balance.yaml の rain_water は、蓋をしていない甕の数です。 数えている src/analysis/seasonalRain.ts は、器を屋根の無い場所(sheltered が0)に置いたものとして数え、枠を 見る条件は素通しするので、表の甕の行は開けた場所に蓋をせず置いた器の量になります (BalanceStats.md)。蓋をした甕の行は別に立ちません——蓋の有無は 型の違いではなく枠の中身なので、表が並べる「容器1種」は蓋をしていない側 1 つだけです。

蓋が航海の積み方の選択になることは ContentSkeleton.md 5.3 節、 そこから出る甕の数は Voyage.md 3.9.6 節です。

7. 雨を貯める

蒸発(6 節)の裏返しで、開いた容器は雨が降っている間だけ水を受けます。ゲートは容器の開き方・ 蓋の有無(6.2 節)・ancestor.weather・ancestor.sheltered で、日射のような第 2 の軸が無いぶん単純です。

雨が当たる場所かどうかは sheltered だけで見ます(ContainerSystem.md 6 節)。 浅い洞窟の中や屋根の下へ置いた器は、外が大雨でも増えません。

貯め始めるのは操作、増えるのは自動という 2 段構えです。

溜め始めた瞬間の量は 1mL(collect_rain が become の直後に set する値)です。become で変種に なった直後は空(空の容器が持つ fill 0 を、become が同じ名前のプロパティとしてそのまま運ぶ。1 節・ GameElementDefinition.md 9.9.1 節)で、実際に溜まる量は降り方が決めるので、「まだほとんど無い」を表す 最少値をここへ置いています。

結果の量(mL/tick):

light_rain heavy_rain storm
wide_open_container(ヤシの器、250mL) 10 20 40
narrow_open_container(甕、4L) 20 40 80

1 回の雨の長さは weather_remaining の抽選が決める(ClimateSystem.md 4.2 節)ので、ヤシの器は 1 度の大雨で満ちる 一方、甕は嵐にも 1 度では満たされず、雨季に何度も降られてようやく満ちます(tests/world-codex/liquidContainersYaml.test.ts)。序盤の生命線(ClimateSystem.md 3.2 節)を器 1 つで 成立させつつ、据え置きの大容量には季節ぶんの時間を要求する配分です。蒸発と同じく、値は容器ごとに独立した 数値で持ちます(口径の段の倍率ではありません。9 節)。

8. 重さ

重さは fill × density です(density は単位量あたりの重さ = g/mL、水は 1)。液体は 1個あたりではなく単位体積あたりの重さを持つ、という当たり前をそのまま表します。単位は 5 節、容器の重さへの 合算は ContainerSystem.md が扱います。

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

10. 水源から汲む

器へ水が入る道は、雨(7 節)だけではありません。 真水の湧く設置物へ器を重ねると、その器が 水入りの変種になって口まで満ちます。宣言を持つのは water_source トレイトだけで、名乗った設置物には 同じ操作が付きます(locations.yaml の湧き水が名乗っています)。

汲める器の型は名指ししません。 見るのは器が名乗るタグ(liquid_container と water)だけなので、 器が増えても水源の宣言は変わりません。

雨受け(7 節)と違い、労働時間を持ちます。 雨は降るのを待つほかありませんが、汲むのはその場で 払える作業で、時間を払えば確実に手に入ります。この差は収支表にも出ます——労働 0 の工程を含む経路は 献立に載せられないため、雨で溜める経路は chain_untimed_routes へ落ち、汲む経路だけが chain_routes に並びます(BalanceStats.md)。

屋外の採取なので、明るさと風雨をそろって要求します(IlluminationSystem.md 5 節、ContentSkeleton.md 8.1.4 節)。嵐の日に効くのは雨受けの側で、 汲む道と雨受けは、塞がる天候が食い違っています。

水源を名乗って口を 1 つも持たない設置物は、tests/world-codex/purposeTags.test.ts が落とします。 名乗りだけの水源は、探索の当たりとして出ながら何も返さない設置物になるためです。