Compare commits
6 commits
2636d580a8
...
f657b6a4a4
| Author | SHA1 | Date | |
|---|---|---|---|
| f657b6a4a4 | |||
| 61718e2981 | |||
| 2cc2f003df | |||
| fc7c94bd69 | |||
| 63e10af865 | |||
| d14550dad6 |
156 changed files with 15117 additions and 2323 deletions
0
.achievement_data/.gitkeep
Normal file
0
.achievement_data/.gitkeep
Normal file
6
.achievement_data/.htaccess
Normal file
6
.achievement_data/.htaccess
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule mod_access_compat.c>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
|
@ -8,8 +8,14 @@
|
|||
ExpiresByType image/webp "access plus 30 days"
|
||||
ExpiresByType audio/mpeg "access plus 30 days"
|
||||
ExpiresByType audio/wav "access plus 30 days"
|
||||
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch "achievement_api\.php$">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.html?$">
|
||||
Header set Cache-Control "no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
level=none expiry=0
|
||||
268
ACHIEVEMENT_AUDIT.md
Normal file
268
ACHIEVEMENT_AUDIT.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# 実績システム再監査レポート v39.16.78
|
||||
|
||||
対象: 全72実績、解除イベント、判定順序、進捗リセット、保存・復元、Undo/Redo、サーバー同期
|
||||
方針: 前回重点対象だった「自分のずんち」「敵の敵は味方」だけでなく、これまで大きく取り上げていなかった実績を再度コード経路から監査した。明確な不具合は修正し、ゲームデザイン判断が必要な条件は変更していない。
|
||||
|
||||
## 1. 今回新たに確定・修正した不具合
|
||||
|
||||
### 1.1 「ミニマリスト」: プレイヤー設置物情報が保存されなくなる
|
||||
|
||||
`ミニマリスト` は「現在存在するプレイヤー設置物が10個以下」を判定するため、各アイテムの `_achievementPlayerPlaced` を必要とする。
|
||||
|
||||
旧実装では、この情報を保存する条件が実質的に `無計画都市` の未解除状態へ依存していた。そのため、`無計画都市` を先に解除した後にセーブ・ロードすると、既存アイテムのプレイヤー設置情報が失われ、`ミニマリスト` の物数判定が過少になる可能性があった。
|
||||
|
||||
**修正:** `無計画都市` と `ミニマリスト` のどちらかが未解除なら、プレイヤー設置情報を保存する。30秒削除用の設置時刻は従来どおり `無計画都市` に必要な場合だけ保存する。
|
||||
|
||||
---
|
||||
|
||||
### 1.2 「サウナ水風呂」: 高温開始時刻が更新され続ける
|
||||
|
||||
条件は「暑すぎる状態になった個体を15秒以内に寒すぎる状態へ移す」だが、旧実装は高温状態を評価するたびに開始時刻を現在時刻へ更新していた。
|
||||
|
||||
このため、長時間高温に置いた後でも、寒冷へ移した直前の評価時刻との差だけで15秒以内と判定され得た。
|
||||
|
||||
**修正:** 快適域外の高温状態へ**入った瞬間**だけ開始時刻を記録し、連続して高温にいる間は更新しない。高温状態を離れてから再び入った場合は新しい試行として時刻を記録する。
|
||||
|
||||
---
|
||||
|
||||
### 1.3 「抵抗器じゃない」: 致死感電した7体目がカウントされない
|
||||
|
||||
旧実装では感電ダメージを与えた後に実績カウントを行っていた。感電が致死になると対象は死亡済みになり、`recordWireShock` の生存チェックで除外されるため、7体目がその感電で死亡すると解除できない場合があった。
|
||||
|
||||
**修正:** 有効な感電を確認した時点で、ダメージ適用より先に実績カウントを行う。致死・非致死のどちらでも、その感電自体は1体分として扱う。
|
||||
|
||||
---
|
||||
|
||||
### 1.4 実績リセット後に隠れた途中進捗が残る
|
||||
|
||||
全実績リセット後も、World・たりない・アイテム本体に直接保持された一部の途中状態が残っていた。そのため、リセット直後に少ない追加操作だけで再解除できる実績があった。
|
||||
|
||||
**修正したリセット対象:**
|
||||
|
||||
- 同一個体への直接給餌回数・直接治療回数
|
||||
- サウナ開始時刻・直前高温状態
|
||||
- たりない保持開始時刻
|
||||
- 喧嘩餅から喧嘩開始までの一時情報
|
||||
- 粘着爆弾の受け渡し回数
|
||||
- `無計画都市` 用の配置時刻
|
||||
- 自然奴隷・自然王の世代進捗
|
||||
|
||||
`ミニマリスト` が現在の配置物を正しく識別するため、既存アイテムの「プレイヤーが配置した物か」という事実自体はリセットで消していない。
|
||||
|
||||
---
|
||||
|
||||
### 1.5 「監視カメラ」: Undo/Redo・地面変更が介入になっていない
|
||||
|
||||
配置・削除・給餌・つつく・射撃などは観察タイマーをリセットしていた一方、Undo、Redo、有効な地面変更は世界状態を変更する操作なのに観察タイマーをリセットしていなかった。
|
||||
|
||||
**修正:** 成功したUndo/Redo、および実際に地面種別が変わった操作を介入として記録する。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前回重点対象の修正状態
|
||||
|
||||
### 「あぁ^~ たまらねぇぜ」
|
||||
|
||||
現在の条件は次の経路で成立する。
|
||||
|
||||
`おしり鋲でずんち蓄積 → おしり鋲を抜いて排出 → 排出ずんちが本人から一度離れる → バウンス柵等で反射・加速 → 本人へ帰還して致死衝突`
|
||||
|
||||
- 生成直後の本人との重なりは衝突扱いしない。
|
||||
- 本人から一度離れた後に帰還した衝突だけを自己帰還として扱う。
|
||||
- 非致死では解除しない。
|
||||
- バウンス柵による反射・加速は資格を失わせない。
|
||||
- 停止済み・プレイヤー保持済みの排出ずんちは対象外。
|
||||
- 致死処理で本人の生存IDが解放されても、ダメージ前に所有者一致を保持するため解除可能。
|
||||
|
||||
### 「敵の敵は味方」
|
||||
|
||||
30体以上の状態で稼いだ連続数は、生存たりないが30体未満になった時点で0へ戻る。危険カテゴリのアイテムによるたりないへのダメージでも従来どおりリセットする。
|
||||
|
||||
前回ユーザー確認済みの「無傷」の範囲、戦闘ペア危険死の期限、機械化産業の履歴AND条件は現行仕様のまま変更していない。
|
||||
|
||||
---
|
||||
|
||||
## 3. これまで大きく取り上げていなかった実績の再監査結果
|
||||
|
||||
### 生態・繁殖
|
||||
|
||||
- `はじめての繁殖`、`ベビーブーム`、`大いなる母` は実際の繁殖成功イベントから接続されている。
|
||||
- `天寿`、`水銀で天寿` は最終確定した死亡理由を受けた後に判定される。
|
||||
- `25体全員が病気` は睡眠病だけを除外し、ずんち病・爆発病・喧嘩病を対象としている。
|
||||
- `管理された楽園` は6日目以降から連続1ゲーム日を計測する。
|
||||
- `安全第一` は25体以上を300ゲーム秒維持し、天寿以外の死亡で再計測する。天寿後に25体を下回れば人数条件によって停止する。
|
||||
- `自給自足` は20体以上・複製機なし・直接給餌なしを600ゲーム秒維持するフィールド内条件。
|
||||
- `雨宿り完了` は現行の雨天である小雨時に、パラソル・巣箱・土管の保護判定を使う。
|
||||
- `町のお医者さん` は応急手当を使った回数ではなく、実際にHPが1以上回復した回数だけを累計する。
|
||||
- `豊穣ヲ希求スル者` はラブ餅の影響下で成立した繁殖だけを累計する。
|
||||
|
||||
**追加の到達不能・イベント未接続は確認されなかった。**
|
||||
|
||||
### 実験・危険行動
|
||||
|
||||
- `6番目の大量絶滅` は同一Worldの10ゲーム秒ローリング区間で死亡50件。
|
||||
- `豆野、殺ッカーやろうぜ!` は通常ボールの速度条件を満たした致死衝突から直接接続される。
|
||||
- `ヘコヘコ中に着火` は着火処理が繁殖儀式状態を消す前に判定される。
|
||||
- `下剤中の餓死` は死亡フレームの下剤状態を保持して判定する。
|
||||
- `物理法則を下側に超越` はアイテム位置の実温度計算を走査しており、温度効果の重畳で到達可能。
|
||||
- `おくすり手帳全埋め` は現行仕様どおり11種類を対象とし、鎖・王冠は除外する。
|
||||
- `革命` は王の喧嘩死と、直前0.1ゲーム秒以内の奴隷による喧嘩ダメージを照合する。
|
||||
- `火に油` は喧嘩餅を受けた個体が30ゲーム秒以内に参加する喧嘩開始を判定する。
|
||||
- `五体満足` / `零体満足` は状態効果付与時と自然ステータス変化時の双方から再評価される。
|
||||
- `混沌ヲ希求スル者` は喧嘩餅影響下の喧嘩開始だけを永続累計する。
|
||||
- `命のバトン` は同じ生存中の粘着爆弾の受け渡し回数を保存・復元する。
|
||||
- `情報化産業` は別々の2基盤を電線類で直接つなぎ、信号が実際に伝達された経路から解除される。
|
||||
|
||||
**追加の確定不具合は「抵抗器じゃない」の致死感電順序だけで、修正済み。**
|
||||
|
||||
### 建築・環境
|
||||
|
||||
- `物でいっぱい` はプレイヤー配置イベントを永続累計し、リンク類の配置も配置として数える。
|
||||
- `清掃業者` はロボット掃除機による有効な清掃200回のフィールド内進捗で、現行ドキュメントと一致する。
|
||||
- `潔癖症` は生存たりない0、生存アリ0、生存アイテムがロボット掃除機のみ、かつ条件を満たす稼働掃除機が存在する場合に成立する。死亡たりないは死亡直後に非表示となり定期圧縮されるため、画面上の死体残留との矛盾は確認されなかった。
|
||||
- `メガロポリス` は100体・複製機10・草ベッド/巣箱/土管合計15の境界値で評価される。
|
||||
- `砂場` は生存中のトイレ5個で成立する。
|
||||
- `そこ公共空間だよ?` は公園フィールドで実際に地面変更が成立した時だけ解除する。
|
||||
- `破格の工事費用` は壁時計1秒以内の有効な地面変更4回を数える。
|
||||
|
||||
**追加の確定不具合は「ミニマリスト」の保存メタデータだけで、修正済み。**
|
||||
|
||||
### 操作・継続
|
||||
|
||||
- `秘密のコレクション` は9スロットすべてに実セーブハッシュがあることを確認する。
|
||||
- `何やってるの?` は空フィールドへのクリックだけを壁時計1秒内に4回数える。
|
||||
- `見なかったことにしよう` は1回のUndo前後で死亡個体が10体以上減った場合に成立する。
|
||||
- `やっぱなし` / `やっぱあり` は成功したUndo/Redoだけを永続累計する。
|
||||
- `割とふわふわ` は共通の「つつく」対象判定からぬいぐるみを飛ばした場合に成立する。
|
||||
- `狙撃手` は命中ではなく発砲333回を永続累計する。
|
||||
- `スペックがたりない` は有効な計測FPSが10未満になった場合で、非表示タブ由来の異常なフレーム間隔を除外する。
|
||||
- `毎日たりない観察` はローカル暦の日付単位の7日連続起動。
|
||||
- `寝ろ` / `めっちゃたりない観察` は壁時計の連続経過で、現行仕様どおりタブ非表示時間も継続として扱う。
|
||||
- `真・たりない観察` は自身以外の71実績が解除済みかを判定する。
|
||||
|
||||
**追加の確定不具合は「監視カメラ」の介入漏れと、全実績リセット後の隠れ進捗残留で、修正済み。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 仕様確認が必要な曖昧点 — 現在は未変更
|
||||
|
||||
以下は実装が壊れているとは断定できず、表示文から複数の解釈が成立するため、回答を受けるまで条件変更を行っていない。
|
||||
|
||||
### Q1. 「ずんちどれい」「たりない王」の「出現」範囲
|
||||
|
||||
現行は**喧嘩戦績による自然変身だけ**で解除する。
|
||||
|
||||
- 鎖を付けて奴隷化 → 対象外
|
||||
- 王冠で王化 → 対象外
|
||||
- 奴隷同士から生まれた生来の奴隷 → 対象外
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 喧嘩戦績による変身だけ
|
||||
- **B: 人工付与以外** — 喧嘩戦績変身 + 生来の奴隷を対象。鎖・王冠は除外
|
||||
- **C: 文字どおり全出現** — 鎖・王冠を含め、状態になった時点で解除
|
||||
|
||||
### Q2. 「末代までの恥」「華麗なる王族」の「○代連続」
|
||||
|
||||
現行は**同じ家系でなくても、自然変身した個体の世代番号が連番なら成立**する。例えば無関係な家系の2代・3代・4代・5代・6代に自然奴隷が1体ずついれば5代連続になる。
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 世界内の世代番号の連続
|
||||
- **B: 直系家系限定** — 親子関係を辿って同じ系譜で連続して状態が続く必要がある
|
||||
|
||||
Q1で生来の奴隷を対象にする場合、Q2の奴隷5代判定にもその出生を含めるかを合わせて決める必要がある。
|
||||
|
||||
### Q3. 「過保護」の「薬で治療」
|
||||
|
||||
現行は、薬カテゴリの直接使用なら効果の善悪を問わず治療回数に加算する。対象には応急手当だけでなく、睡眠薬、下剤、プロテイン、ニテロプ、弾薬、水銀、巨大化薬、小型化薬なども含まれる。
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 薬カテゴリの直接使用3回
|
||||
- **B: 治療系アイテムだけ** — 対象薬を明示的に限定
|
||||
- **C: 実際に有益な回復が発生した使用だけ**
|
||||
|
||||
### Q4. 「無計画都市」の「削除する操作を累計30回」
|
||||
|
||||
現行は**操作回数ではなく、30秒以内に削除された対象物1個につき1回**進む。範囲削除1回で対象物を10個消した場合、最大10進む。
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 削除した対象物数を数える
|
||||
- **B: 削除コマンド単位** — クリック/範囲削除の1実行を1回として数える
|
||||
- **C: 連続削除ジェスチャ単位** — 押している間の連続削除を1回として数える
|
||||
|
||||
### Q5. 「監視カメラ」の5分と「見るだけ」の意味
|
||||
|
||||
現行は**300ゲーム秒**。高速化すれば実時間5分より早く解除できる。今回、世界を変更するUndo/Redo・地面変更も介入として追加したが、UI操作、セーブ、速度変更、ポーズ、空クリックなど世界を直接変更しない操作はタイマーを止めない。
|
||||
|
||||
時間基準:
|
||||
|
||||
- **A: 現行どおり300ゲーム秒**
|
||||
- **B: 実時間5分**
|
||||
|
||||
「見るだけ」の入力基準:
|
||||
|
||||
- **1: 現行どおり世界への介入だけでリセット**
|
||||
- **2: ほぼすべてのユーザー操作でリセット**
|
||||
|
||||
例: `Q5 = A1` のように指定可能。
|
||||
|
||||
### Q6. 「抵抗器じゃない」のセーブ/Undo跨ぎ
|
||||
|
||||
現行の「同じ1本」はランタイム中の同一電線オブジェクト。セーブロードやUndo/RedoでWorldが再構築されると、1~6体までの途中人数は失われる。
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 7体すべてを同じランタイム中に感電させる
|
||||
- **B: 同じ保存上の電線なら継続** — セーブロード後も電線ごとの途中人数を保存する
|
||||
|
||||
### Q7. 「害虫駆除」の「アリを倒す」主体
|
||||
|
||||
現行は**アリが死亡した事実をすべて累計**する。プレイヤーの危険アイテムだけでなく、たりないとの戦闘、設備・環境など別要因で死亡しても1体として数える。
|
||||
|
||||
候補:
|
||||
|
||||
- **A: 現行どおり** — 死亡したアリをすべて数える
|
||||
- **B: プレイヤー由来の撃破だけ**
|
||||
- **C: プレイヤーまたはたりない陣営が倒した場合だけ**
|
||||
|
||||
---
|
||||
|
||||
## 5. 前回ユーザー確認済みの仕様 — 変更なし
|
||||
|
||||
以下は前回の確認により現行仕様を維持している。
|
||||
|
||||
- 戦闘ペア危険死: 第1死亡から第2死亡まで同一World内で明示的な時間制限なし。
|
||||
- 機械化産業: ロープ・棒・ばねの配置履歴と信号作動履歴のAND。現在同時に存在する必要なし。
|
||||
- 敵の敵は味方: 「無傷」は危険カテゴリ由来のたりないへのダメージをリセット対象とし、あらゆるダメージを禁止する意味ではない。
|
||||
|
||||
---
|
||||
|
||||
## 6. 検証結果
|
||||
|
||||
v39.16.78 に対して以下を実行し、すべて成功した。
|
||||
|
||||
- JavaScript全ファイル・監査スクリプトの `node --check`
|
||||
- 全72実績の定義・カテゴリUI・同期監査
|
||||
- プレイスタイル実績の境界値・リセット範囲・永続化・フック監査
|
||||
- 「あぁ^~ たまらねぇぜ」専用の生成直後除外 / 離脱 / 帰還 / 致死・非致死回帰テスト
|
||||
- サウナ開始時刻、実績リセット、監視介入、ミニマリスト保存情報、致死感電の追加回帰テスト
|
||||
- 実績イベント統合監査
|
||||
- サーバー状態・旧形式移行・並行同期監査
|
||||
- 全体回帰監査 `scripts/regression_check.py`
|
||||
- Python監査スクリプトのコンパイル確認
|
||||
- `achievement_api.php` のPHP構文確認
|
||||
|
||||
結果はすべて `[OK]`。今回の再監査で新たに確認した**確定不具合は5系統で、すべて修正済み**。上記Q1~Q7は仕様回答待ちのため、v39.16.78では現行挙動を維持している。
|
||||
|
||||
|
||||
## Confirmed specification decisions (39.16.78)
|
||||
- ずんちどれい / たりない王: birth-status appearances also qualify.
|
||||
- 末代までの恥 / 華麗なる王族: only a direct qualifying parent-child lineage counts.
|
||||
- 過保護: a treatment counts only when the direct use actually improves the health need.
|
||||
- 無計画都市: one deletion operation counts once, even if area deletion removes multiple objects.
|
||||
- 監視カメラ: requires 3 uninterrupted game days; gameplay operations are interventions.
|
||||
- 抵抗器じゃない: partial wire progress remains nonpersistent across save/load and Undo.
|
||||
- 害虫駆除: only player-caused ant deaths count.
|
||||
24
AI_MAP.md
24
AI_MAP.md
|
|
@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
|
|||
|
||||
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
|
||||
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
|
||||
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.16.18`.
|
||||
- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.78`.
|
||||
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
|
||||
- Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
|
||||
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
|
||||
|
|
@ -81,3 +81,25 @@ This section records current boundaries only. Version-by-version cleanup history
|
|||
- Effect-specific spatial indexing, fuller delta-history storage, and high-speed collision partitioning. These need new indexes or semantics beyond cleanup.
|
||||
- UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`.
|
||||
- Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs.
|
||||
|
||||
## Current Feature Routing
|
||||
|
||||
- Achievement system: `js/achievements.js`, `achievement_api.php`, `js/save_schema.js`, `js/save_codec.js`, `js/snapshot_system.js`, and achievement audit scripts. Current save schema is `49`; current app/cache version is `39.16.78`.
|
||||
- Achievement UI: `index.html` achievement dialog, `css/components.css` achievement rows/toast styles, and `js/achievements.js` DOM rendering.
|
||||
- Achievement spell payload: `TarinaiAchievements.exportSpellState()`, `snapshot.g.s`, `save_system.js` spell export inclusion, and `save_codec.js` achievement metadata readers/writers.
|
||||
- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles.
|
||||
- Shared play: `js/seesaw_system.js`, tarinai action/need modules, placement preview, and item/structure definitions.
|
||||
- Reset presets: `js/world_reset_presets.js`; UI reset selection is in `js/ui_layout_dialogs.js`; social archive reset/fallback routing touches `js/world_family_social.js`.
|
||||
- Physics tool icons and previews: `js/ui_tools.js`, `js/render.js`, `js/placement_preview_system.js`, `js/physics_shape_editor_system.js`, and `js/item_tool_metadata.js`.
|
||||
|
||||
## Cleanup Candidate Audit - resolved
|
||||
|
||||
The previous cleanup candidate queue has been applied or adjusted. Current state:
|
||||
|
||||
- Version/docs routing was aligned to `39.16.78`; `js/version.js` build identity now matches the app version, and `README.md` no longer points at an older cache-query script URL.
|
||||
- One-version and overlapping audit scripts from the candidate list were removed; the broad audit suite remains `scripts/achievement_audit.js`, `scripts/playstyle_achievement_audit.js`, `scripts/achievement_integration_audit.py`, `scripts/achievement_server_state_audit.py`, and `scripts/regression_check.py`.
|
||||
- Shared scalar/object/clone/hash/text helpers now live on `TarinaiCoreHelpers` in `js/deterministic_helpers.js`; action, behavior, structure, snapshot, save, geometry, physics, seesaw, placement-preview, circuit, and signal code route repeated helper logic through that shared surface or through `TarinaiGeometry`.
|
||||
- UI HTML escaping is owned by `js/ui_helpers.js`; consumers bind explicitly to `TarinaiUIHelpers.htmlEscape`, and `index.html` loads `ui_helpers.js` before the consumers that need it.
|
||||
- `js/achievements.js` uses `renderIfDialogOpen()` for progress refreshes instead of repeating the hidden-dialog check at each event recorder.
|
||||
- `js/world_placement_log.js` uses shared close/backdrop binding for editor dialogs; deeper editor extraction can still be done as feature work, but the repeated close process from the cleanup list is removed.
|
||||
- `FILES.json` now includes JavaScript audit scripts and newer core bootstrap files (`domain_ids`, deterministic helpers, item registries/definitions) in machine routing.
|
||||
|
|
|
|||
237
BUILD_NOTE.txt
237
BUILD_NOTE.txt
|
|
@ -5,8 +5,8 @@ tarinai_compact_save_stage4
|
|||
- The binary header contains one schema byte; its high bit signals whether a string table follows. Empty string tables and the duplicate snapshot version are omitted.
|
||||
- Deflate state is folded into the low bit of the frame-length varuint, eliminating the separate compression byte.
|
||||
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
|
||||
- Previous binary schemas are intentionally unsupported.
|
||||
- Version: 39.16.18
|
||||
- Binary schema 51 is the current save format; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and persistent statistician progress while v5 remains importable.
|
||||
- Version: 39.16.94
|
||||
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
|
||||
|
||||
- Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
|
||||
|
|
@ -27,3 +27,236 @@ tarinai_compact_save_stage4
|
|||
- v39.16.16: colony population chart records births/deaths, rotators accept negative motor speed, champion personality boosts inherit through descendants, champion-pair mating is slightly reduced, and one-way fence previews/pathfinding honor the passable direction.
|
||||
|
||||
- v39.16.16: one-way fences allow Tarinai forward crossing in swept collision; chart legend dots toggle series; wire tools attach to Tarinai/ants; energized living endpoints panic and take damage; selected data reports electrocution.
|
||||
|
||||
- v39.16.19: added direct food/medicine giving by placement or pinched drop, with a give overlay, stronger food-gift personality change, and heart bursts; removed unused collision reasons and duplicate constraint projection dirty checks.
|
||||
|
||||
- v39.16.20: localized the direct-feeding personality reason, normalized electrocution deaths to the Japanese electrocution label, added detector-controlled gate fences, made chart legend labels fully clickable, and escaped non-ASCII runtime source.
|
||||
|
||||
- v39.16.21: hid pinch overlays for rope/rod/spring/wire connection items and added detector time schedules with a dual-thumb range control, overnight ranges, and persistent copy/save state.
|
||||
|
||||
- v39.16.22: shortened detector copy, removed the time-range explanatory hint, cascaded connection-item removal when any endpoint disappears, and repaired detached plushies so they return to a living owner or disappear with a missing owner.
|
||||
|
||||
- v39.16.23: excluded transient splat effects from all connection endpoint selection and renamed the user-facing feeding need from the formal feeding term to the appetite label.
|
||||
- v39.16.25: added toilet-seeking before normal defecation; renamed rain shelter to parasol; added sunny +3.5C and unsheltered rain -3.5C felt-temperature corrections; parasols neutralize both and block sunbathing. Pet food, first aid, and sedative remain included.
|
||||
|
||||
- v39.16.29: replaced toilet-seeking with walkable toilet sand that immediately removes poop spawned on its surface; added a parasol ground shadow and explicit hot-sun/cold-rain temperature-seeking targets; fixed null candidate temperatures being interpreted as 0C.
|
||||
|
||||
- v39.16.29: aligned parasol placement overlay with its shade, made toilet sand absorb rain water, expanded Happy/Athletic/War presets, simplified Athletic rotators, and reserved their full rotation envelope from fences.
|
||||
|
||||
- v39.16.30: added a few bounce fences to War, reduced ordinary-fence restitution, restored ball collisions for visible sleeping Tarinai, moved nest-box tooltips closer, allowed movable-item placement overlap, and fixed delete-tool target lookup.
|
||||
|
||||
- v39.16.31: unified in-world tooltip spacing, added the Hair Trigger preset, and replaced physics-category line-like tool icons with distinct semantic silhouettes.
|
||||
|
||||
- v39.16.35: moved snap-mode switching to a dedicated operation control with Free/20px/40px/60px cycling, removed F/G from size badges, allowed valid placement against the field top edge, and standardized preset descriptions with Japanese full stops.
|
||||
|
||||
- v39.16.37: unified biological/environmental conditions into the detector, removed the standalone sensor and relay types with no save migration, folded temperature/limits, moved secondary controls into the header menu, preserved tool mode while browsing tarinai data, and added five-second mobile selection tips.
|
||||
|
||||
- v39.16.38: detector numeric sliders match the time-control presentation; colony settings are unified; display settings are inline; meter colors, snap control, range-delete icon, and ant chart defaults are revised.
|
||||
|
||||
- v39.16.39: restored the compact sticky operation palette, added exact-match panel search, reorganized colony settings/stats, and refreshed range-delete and mechanism icons.
|
||||
- v39.16.40: moved partial panel search to the header with previous/next navigation and tooltip text indexing; localized snap mode labels; simplified bounce-fence and reciprocator icons; matched expanded colony settings to the chart background.
|
||||
|
||||
39.16.41: Added local achievements and optional self-hosted shared unlock percentage API.
|
||||
|
||||
39.16.42: Expanded achievements to ten conditions, added local/shared reset, rapid-event windows, generation streaks, and special collision tracking.
|
||||
|
||||
39.16.44: Expanded achievements to fifteen conditions, changed rapid deaths to 50 in 10 seconds, moved conditions into game tooltips, added toast-to-button absorption, and added colony disease/happiness, cumulative hand feeding, ritual ignition, and laxative-starvation achievements.
|
||||
|
||||
39.16.44: Audited all achievements; fixed impossible slave generation tracking, persisted generation runs, queued simultaneous unlock toasts, hardened launched-zunchi stop detection, and preserved laxative-at-death timing.
|
||||
|
||||
39.16.45: Added seven play-style achievements for passive observation, fifty world placements, detector automation, rope/rod/spring coverage, a five-minute death-free 25-Tarinai colony, 25 live ants, and 30 cumulative ant kills; persisted world-scoped timers and placement progress.
|
||||
|
||||
39.16.46: Revised achievement naming and disclosure UI, replaced achievement tooltips with inline post-unlock descriptions and horizontal shared-rate bars, merged detector automation plus link coverage into 機械化産業, changed placement and ant-kill goals to persistent totals of 100, and made direct-feeding progress reset with the field.
|
||||
|
||||
39.16.47: Added 大いなる母 and 喧嘩両成敗, renamed five achievements, revised five revealed descriptions, and added a debug-only all-achievements button.
|
||||
|
||||
39.16.48: Corrected the zunchi-slave fight-record threshold text to 30%; fixed wire placement totals, danger-kill attribution for 喧嘩両成敗, partial legacy link migration, strict player-ID validation, post-unlock storage writes, and debug-only unlock isolation from shared statistics.
|
||||
|
||||
39.16.49: Added additive client-to-server full-state synchronization for automatic aggregate recovery after server-side achievement data loss.
|
||||
|
||||
39.16.51: Expanded achievements from 27 to 43 with history, cleanup, care, ecology, temperature, weather, medicine, combat, and ant-experiment conditions; persisted the required reset-scoped metadata in a new incompatible save schema; made nest-box and pipe occupants weather-protected; and added false-positive guards for time-zero markers and stale ant damage attribution.
|
||||
|
||||
39.16.52: Reorganized achievements into four collapsible categories, removed achievement tooltips, surfaced pre-unlock disclosures in the achievement UI, and replaced native browser confirmations with a shared in-game dialog.
|
||||
|
||||
|
||||
39.16.53: Renamed the four achievement categories to 生態/実験/建築/その他, moved unlocked achievements to the top of their own categories, disclosed four additional conditions before unlock, raised 大いなる母 to 3333 births, and moved achievement unlock toasts to the upper-left.
|
||||
|
||||
39.16.54: Added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足; disclosed 悠久の歴史 before unlock; reduced achievement overhead by skipping closed-dialog list rebuilds, caching category mappings, debouncing pending sync, and retaining periodic full recovery sync.
|
||||
|
||||
39.16.55: Added 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one uninterrupted happy in-game day from play day 6; repaired shared つつく targeting, damage, plushie fling, and hover overlay; corrected manual rotator/reciprocator push direction; tightened tool/limit typography; reduced periodic achievement scans; and changed server aggregate writes to compact v2 JSON with legacy-state migration.
|
||||
39.16.56: Added 混沌ヲ希求スル者 and 潔癖症; made fight progress persistent; revised ミニマリスト, おくすり手帳全埋め, and 清掃業者; and made achievement-rate bars visible on unlocked cards.
|
||||
|
||||
39.16.57: Compactly embeds unlocked achievements and persistent achievement progress in exported spell saves; spell loads merge achievements without erasing local progress and resync restored unlocks to the server.
|
||||
|
||||
39.16.59: Replaced JSON-string achievement metadata in spell/save binaries with a sparse binary schema; achievement unlock masks are raw bits, progress is bit-packed, timestamps use minute deltas, and old v39.16.57 spells remain readable.
|
||||
39.16.60: Added persistent sniper, megalopolis, and love-mochi fertility achievements; changed chaos progress to fight-mochi-influenced fights only; extended compact spell progress without migrating obsolete generic fight counts; omitted completed achievement counters from spell metadata; and tightened population-limit labels/layout.
|
||||
|
||||
39.16.62: Expanded achievements from 54 to 59 with single-digit FPS, 24-hour continuous play, five toilet-sand placements, park-ground editing, and four ground changes per second; revised safety, observation, and empty-click conditions; moved the two observation achievements to the bottom of その他; and expanded binary achievement masks to eight bytes while retaining schemas 41–43.
|
||||
|
||||
39.16.63: Fixed achievement progress typography and tab-switch observation continuity; added アリ観察 and 命のバトン; persisted sticky-bomb pass counts; aligned sauna/enemy-ant conditions; removed obsolete spell compatibility; current exports use schema 45 and achievement payload v4.
|
||||
|
||||
## v39.16.64
|
||||
- Added 毎日たりない観察 and 抵抗器じゃない.
|
||||
- Daily streak state is carried by achievement payload v5 and binary schema 46.
|
||||
- Achievement aggregate JSON migrates compact v2/verbose input to compact v3 automatically under the API file lock.
|
||||
|
||||
## v39.16.65
|
||||
- Re-audited the not-yet-deployed shared achievement server with generated randomized unlock data instead of a fixed production sample.
|
||||
- Shared summaries are read-only; only POST session/unlock/sync/reset calls register or update players, and GET mutations are rejected.
|
||||
- State writes now use a separate lock file plus atomic same-directory replacement, preserving the previous state across interrupted writes.
|
||||
- Invalid or structurally unknown JSON returns `storage_corrupt` without overwriting the source file.
|
||||
- `completionistEligible: false` now prevents `true_tarinai_observer` from being re-added by the same sync payload.
|
||||
- The randomized server audit covers 32 v2/verbose migrations and 72 concurrent player syncs with exact semantic comparison.
|
||||
|
||||
## v39.16.66
|
||||
- Added seesaw cooperative play and friendship growth.
|
||||
- Added circuit-board editor with positioned input/output terminals and AND/OR/NOT gates.
|
||||
- Added electrical control for fan, stove, dry ice, and duplicator.
|
||||
- Added connectable-item and circuit-terminal highlights while wiring.
|
||||
- Save snapshot/schema: 38/47.
|
||||
|
||||
## v39.16.67
|
||||
- Circuit board editor now uses a grid canvas with direct gate placement and cursor-drawn wiring.
|
||||
- Legacy circuit layouts are intentionally not converted.
|
||||
- Colony state evaluates every two in-game hours; growing populations cannot be classified as Weakened.
|
||||
- Seesaw action selection, fixed orientation, and placement preview were corrected.
|
||||
|
||||
## v39.16.68
|
||||
- Seesaw leisure can start at a lower boredom threshold, and a waiting rider raises the second rider's action priority.
|
||||
- Active paired play is shown as 「シーソーをしている。」 in selected Tarinai data.
|
||||
- Seesaws render in the low/back item layer instead of covering Tarinai sprites.
|
||||
- AND/OR gate input pins and gate outputs are snapped to the same 40px circuit-editor grid used by cursor wiring.
|
||||
|
||||
## v39.16.69
|
||||
- Circuit-board internal wiring now uses straight grid segments with explicit branch junctions and unlimited fan-out per terminal.
|
||||
- Removed OR and added a one-input/one-output rectifier; internal logic parts rotate in 90-degree steps.
|
||||
- Circuit-board external terminal counts and positions are editable; the board footprint and default terminal spacing were expanded.
|
||||
- Detector numeric conditions now use a dual-thumb minimum/maximum range plus direct numeric inputs.
|
||||
- Seesaw riders render slightly above the seat surface.
|
||||
- Save snapshot/schema: 39/48. Older saves are intentionally unsupported.
|
||||
|
||||
|
||||
## v39.16.70
|
||||
- The default circuit-board grid is now 10x6, one quarter of the previous editor area, with a correspondingly smaller world footprint.
|
||||
- Circuit-board width and height are editable; the board world size follows the grid dimensions.
|
||||
- Input/output terminal count is limited by board height, and every external terminal can be dragged independently onto the left, right, top, or bottom edge.
|
||||
- Branch junctions can be moved directly; all connected straight segments follow the junction.
|
||||
- NOT and rectifier bodies are visually smaller while their pins remain on grid intersections.
|
||||
- Existing external wires follow terminal positions after a board resize or terminal move.
|
||||
- Seesaw action reasons now use play-specific wording instead of inheriting unrelated fulfillment reasons such as bed demand.
|
||||
- Save snapshot/schema: 40/49. Older saves are intentionally unsupported.
|
||||
|
||||
|
||||
## v39.16.71
|
||||
- Circuit-board external terminals are now placed directly with IN/OUT terminal tools; the +/- terminal-count controls were removed.
|
||||
- Disconnected external terminals are dimmed in the circuit-board editor; connected terminals retain their signal colors and cannot be deleted until their world wire is removed.
|
||||
- Placement previews no longer use the generic ellipse fallback: circle items use circles, obstacle/mechanical items use their collision footprints, and special items use dedicated shapes.
|
||||
- Detector, wire, insulated wire, and circuit board moved from the Physics palette section to a new Electrical section.
|
||||
- Clicking a detector dual-range track outside either thumb teleports the nearer thumb to the clicked value.
|
||||
- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged.
|
||||
|
||||
|
||||
## v39.16.72
|
||||
- Circuit-board default grid is 10x10 and both dimensions can grow to 20x20; the world footprint scales proportionally in both axes.
|
||||
- AND gate body height is two grid cells. Compact one-input parts can be placed one grid row from the top/bottom edges when their rotated pins remain in bounds.
|
||||
- Placement previews prefer shared occupancy/collision footprints; the dedicated seesaw preview was removed and circuit boards use their rectangular board footprint.
|
||||
- Internal combinational feedback loops are explicitly detected and forced OFF; a NOT self-loop is unstable and does not provide power.
|
||||
- Save snapshot/schema remains 40/49; the persisted circuit-board v4 structure is unchanged.
|
||||
|
||||
|
||||
## v39.16.73
|
||||
- Circuit-board editing shows placement ghosts for pending internal parts and terminals.
|
||||
- Q/E rotates pending internal parts by 90 degrees before placement.
|
||||
- The ON constant source is documented in the editor.
|
||||
- Wire and insulated-wire palette silhouettes render correctly in the Electrical category.
|
||||
- Save snapshot/schema remains 40/49.
|
||||
|
||||
|
||||
## v39.16.76
|
||||
- Removed the circuit-board OFF constant source.
|
||||
- Added the achievement 「情報化産業」 for transmitting an active signal directly from one circuit board output to another circuit board input through wire or insulated wire.
|
||||
- Extended the 8-byte achievement mask to use its final bit, supporting all 64 current achievements.
|
||||
- Save snapshot/schema remains 40/49.
|
||||
|
||||
|
||||
## v39.16.78
|
||||
- Fixed 「あぁ^~ たまらねぇぜ」: burst zunchi no longer treat their spawn overlap with the owner as a collision.
|
||||
- A burst zunchi now becomes eligible only after it has cleared the owner once; a later fatal return collision unlocks the achievement, including routes that reflect and accelerate on a bounce fence.
|
||||
- Preserved the existing nonfatal, stopped, and player-held disqualification behavior.
|
||||
- Fixed ミニマリスト placement-origin metadata so it remains saveable after 無計画都市 has already unlocked.
|
||||
- Fixed サウナ水風呂 to measure 15 seconds from genuine entry into a hot state instead of refreshing the hot timestamp on every hot evaluation.
|
||||
- Fixed 抵抗器じゃない so a Tarinai killed by the qualifying shock still counts before death clears its live state.
|
||||
- Achievement reset now clears reset-scoped hidden per-Tarinai and per-item progress, including care counts, sauna state, held duration, fight-mochi metadata, sticky-bomb pass count, and quick-delete timestamps.
|
||||
- Undo/Redo and valid ground changes now reset 監視カメラ observation progress as world interventions.
|
||||
- Added route-level and secondary regression coverage for these achievement paths. Ambiguous achievement semantics identified by the renewed audit remain unchanged pending explicit specification.
|
||||
|
||||
39.16.78: Applied confirmed achievement semantics: birth-status slave/king appearances count; dynasty achievements require direct parent-child qualifying lineage; Overprotective treatment counts only actual beneficial health recovery; Unplanned City counts deletion operations rather than removed objects; Surveillance Camera requires 3 uninterrupted game days and resets on gameplay interventions; wire-shock partial progress remains nonpersistent; Pest Control counts only player-caused ant deaths.
|
||||
|
||||
|
||||
## v39.16.80
|
||||
- Optimized Tarinai social-neighbor scans with squared-distance filtering, nearest-N partial selection, pair-deduplicated symmetric work, cached personality profiles, and shared disease spread logic.
|
||||
- Added personality-derived crowd comfort bounds. Overcrowded Tarinai flee toward less crowded space with the specified behavior text; undercrowded Tarinai become stressed and approach the nearest living Tarinai with the specified loneliness text.
|
||||
|
||||
|
||||
## v39.16.85
|
||||
- Replaced Undo/Redo object snapshots and recursive patch history with the existing synchronous binary save codec payload.
|
||||
- Exposed binary snapshot encode/decode helpers from TarinaiSaveCodec for internal history reuse without Base32768 text conversion or asynchronous compression.
|
||||
- Replaced JSON.stringify-based duplicate detection with a compact dual 32-bit fingerprint over the encoded history bytes.
|
||||
- Unified Undo and Redo stack movement through a shared history transition path while preserving synchronous behavior and the 48-entry limit.
|
||||
- Added binary-history regression coverage for storage shape, duplicate suppression, Undo restoration, and Redo restoration.
|
||||
|
||||
|
||||
## v39.16.85
|
||||
- Render stack optimization: static visible stacks are cached by static spatial generation and camera region, while dynamic stacks reuse previous-frame order before insertion sorting and are merged linearly.
|
||||
- Link rendering now uses dedicated AABB spatial cells instead of per-frame full type-bucket fallback scans.
|
||||
- Carried plushies render at owner-relative positions without mutating simulation coordinates during rendering.
|
||||
- Fixed render culling radii are cached on eligible static entities.
|
||||
- Render-layer sort keys are computed once per stack insertion.
|
||||
|
||||
|
||||
## v39.16.85
|
||||
- Reduced fixed render cost by skipping the fallback full-screen gradient whenever the transformed field fully covers the viewport.
|
||||
- Cached full-screen fallback gradients and rebuilt atmospheric full-screen lighting at 8 Hz instead of constructing multiple full-screen gradients every frame.
|
||||
- Reused dynamic render-layer entries, sort maps, and previous-order ID arrays to reduce per-frame garbage collection as population grows.
|
||||
|
||||
|
||||
## v39.16.86
|
||||
- Removed social crowd-retreat selection and the overcrowded colony-state distance scan.
|
||||
- Removed the unconditional three-second full terrain-cache invalidation; cached terrain now refreshes only from real terrain changes, with splat fading invalidating local chunks.
|
||||
- Throttled structure dependency safety sweeps, staggered item/ant compaction, and stopped timer-only item bucket rebuilds.
|
||||
- Deferred periodic statistics UI refreshes and collapsed population statistics into a single pass.
|
||||
- Ordinary item additions/removals no longer invalidate the full terrain cache unless cached terrain items actually changed.
|
||||
|
||||
## v39.16.88
|
||||
- Large-save restore uses an adaptive non-damaging settling phase for dense loaded populations, preventing synthetic body-overlap and obstacle-correction impulses from cascading into impact deaths.
|
||||
- Duplicator loading takes priority over direct feeding when a Tarinai overlaps the machine.
|
||||
- Save schema 50 explicitly stores elapsed completed days; total simulation time is restored monotonically and seasons/climate derive from elapsed days. Schema 49 spells remain readable.
|
||||
|
||||
|
||||
## v39.16.89
|
||||
- Moved the ずんちどれい and たりない王 population-chart toggles to immediately after 死亡数.
|
||||
- Removed the 12-second periodic Tarinai runtime-cache full sweep that caused large-population performance regressions.
|
||||
- Runtime relationship/cache cleanup is now scheduled only after accumulated deaths reach max(20, floor(living population * 15%)), then runs once during the next Tarinai compaction and resets its counter.
|
||||
|
||||
|
||||
## v39.16.90
|
||||
- Added eight playstyle-survey achievements: 数こそ力, 少数精鋭, 踩屎感, 快適な寝床, 石に枕す, 季節を越えて, 推し, and 統計学者.
|
||||
- 数こそ力 unlocks at 100 living Tarinai. 少数精鋭 requires 1-25 living Tarinai continuously for five game days and discloses its condition/progress before unlock.
|
||||
- 統計学者 counts 30 statistics-tab or chart-series visibility button presses in persistent achievement state, so field resets do not clear its progress.
|
||||
- Achievement spell payload v6 and binary save schema 51 expand the unlock mask from 64 to 72 achievements while retaining v5/schema 49-50 read compatibility.
|
||||
- Updated the Item lifecycle pipeline regression to validate local bucket/spatial invalidation when passive serving-food decay actually changes its visual footprint.
|
||||
|
||||
|
||||
## v39.16.94
|
||||
- Removed previous-order render Maps and custom insertion sorting; dynamic visible lists now use native Array.sort after culling.
|
||||
- Removed the shared route cache while retaining actor-local route throttling.
|
||||
- Disabled detailed performance counters in normal play; diagnostics remain available only in explicit debug/perf mode.
|
||||
- Isolated obstacle-query cache invalidation to routing-obstacle generations instead of generic spatial movement.
|
||||
- Replaced per-frame full Tarinai spatial-grid rebuilds with cell-crossing incremental updates.
|
||||
- Added sustained hysteresis to automatic performance-profile transitions to prevent rapid quality oscillation.
|
||||
- Death-reference cleanup now executes in bounded per-frame chunks after the existing adaptive death threshold.
|
||||
|
||||
## v39.16.94
|
||||
- Fixed dense-population birth regression caused by incremental Tarinai spatial-grid bucket ordering interacting with an early 20-candidate contact cutoff. Contact interactions now consider the complete already-fetched local candidate set while retaining only the nearest bounded interaction list.
|
||||
- Moved the "季節を越えて" achievement from the ecology category to "その他".
|
||||
39.16.96: UI input/limits/zoom refinements, infection chance reduced to one-third, 75 achievements, grass spatial/hot-path optimization, summer flat temperature bonus removed.
|
||||
|
|
|
|||
26
FILES.json
26
FILES.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
||||
"version": "39.16.18",
|
||||
"version": "39.16.98",
|
||||
"entrypoints": {
|
||||
"app": "index.html",
|
||||
"runtime": "js/main.js",
|
||||
|
|
@ -37,7 +37,9 @@
|
|||
"AI_MAP.md",
|
||||
"GAME_FEATURES.md",
|
||||
"FILES.json",
|
||||
"service-worker.js"
|
||||
"service-worker.js",
|
||||
"achievement_api.php",
|
||||
".achievement_data/.htaccess"
|
||||
],
|
||||
"meaning": "Repo/app shell metadata, server/static cache config, docs, manifest, entrypoint."
|
||||
},
|
||||
|
|
@ -51,19 +53,28 @@
|
|||
{
|
||||
"id": "scripts",
|
||||
"patterns": [
|
||||
"scripts/*.js",
|
||||
"scripts/*.py",
|
||||
"scripts/*.ps1"
|
||||
],
|
||||
"meaning": "Generators, regression checks, and token-aware AI inventory tooling."
|
||||
"meaning": "Generators, regression checks, version-specific audits, and token-aware AI inventory tooling."
|
||||
},
|
||||
{
|
||||
"id": "core",
|
||||
"patterns": [
|
||||
"js/version.js",
|
||||
"js/event_bus.js",
|
||||
"js/domain_ids.js",
|
||||
"js/registry_base.js",
|
||||
"js/disease_registry.js",
|
||||
"js/sound_pack.js",
|
||||
"js/deterministic_helpers.js",
|
||||
"js/item_tool_metadata.js",
|
||||
"js/item_tool_definitions.js",
|
||||
"js/item_visual_definitions.js",
|
||||
"js/item_food_definitions.js",
|
||||
"js/item_effect_definitions.js",
|
||||
"js/item_registry.js",
|
||||
"js/data.js",
|
||||
"js/ground_types.js",
|
||||
"js/health.js",
|
||||
|
|
@ -121,7 +132,8 @@
|
|||
"js/item*.js",
|
||||
"js/items.js",
|
||||
"js/structures.js",
|
||||
"js/structure_lifecycle.js"
|
||||
"js/structure_lifecycle.js",
|
||||
"js/circuit_board_system.js"
|
||||
],
|
||||
"meaning": "Item/structure registry, lifecycle, dynamic behavior, rendering."
|
||||
},
|
||||
|
|
@ -129,7 +141,8 @@
|
|||
"id": "tarinai",
|
||||
"patterns": [
|
||||
"js/tarinai*.js",
|
||||
"js/ants.js"
|
||||
"js/ants.js",
|
||||
"js/seesaw_system.js"
|
||||
],
|
||||
"meaning": "Creature state/actions/needs/social life/item effects/disease/rendering plus ant actor model."
|
||||
},
|
||||
|
|
@ -149,7 +162,8 @@
|
|||
"js/ui*.js",
|
||||
"js/text_catalog.js",
|
||||
"js/command_dispatcher.js",
|
||||
"js/family_graph.js"
|
||||
"js/family_graph.js",
|
||||
"js/achievements.js"
|
||||
],
|
||||
"meaning": "DOM UI, input, tools, selected panel, logs, charts, family tree, text labels, command routing."
|
||||
},
|
||||
|
|
|
|||
346
GAME_FEATURES.md
346
GAME_FEATURES.md
|
|
@ -1,300 +1,100 @@
|
|||
## v39.16.17 mechanical one-way fence runtime fix
|
||||
|
||||
- Mechanical objects now query their canonical physics-body velocity when resolving one-way-fence contacts.
|
||||
- This removes the `bodyFor is not defined` runtime failure while preserving the existing directional fence behavior.
|
||||
|
||||
## v39.16.16 directional fences, chart controls, and living wire endpoints
|
||||
|
||||
- Tarinai can physically cross one-way fences in the arrow direction, including swept/high-speed movement.
|
||||
- Colony chart color dots toggle individual series.
|
||||
- Wire and insulated wire can attach to Tarinai and ants. Energized living endpoints take damage and panic regardless of insulation; only bare wire damages incidental contact.
|
||||
- Selected Tarinai data shows `感電している。` while shocked.
|
||||
|
||||
|
||||
## v39.16.16 colony lineage and directional routing
|
||||
|
||||
- Population history overlays interval birth and death counts.
|
||||
- Rotators preserve negative motor speed and rotate in reverse.
|
||||
- Tarinai Champion boosts to aggression, openness, and sociability pass to descendants with generational damping.
|
||||
- Champion-pair mating preference is slightly reduced.
|
||||
- One-way fence placement previews show the allowed direction.
|
||||
- Pathfinding treats one-way fences as passable only along their arrow direction.
|
||||
|
||||
## v39.16.16 colony graph, reverse rotator, and champion lineage
|
||||
|
||||
- コロニー統計の個体数グラフへ、2時間区間ごとの誕生数と死亡数を追加した。
|
||||
- 回転体のモーター速度は負値を保持し、負の角度方向へ逆回転できる。
|
||||
- たりない王の攻撃性・開放性・社交性補正は子孫の誕生時性格へ減衰継承される。
|
||||
- たりない王同士の繁殖優先倍率を5.0から4.0へ下げ、繁殖確率をやや抑えた。
|
||||
- 保存形式は変更しない。
|
||||
|
||||
# tarinai_ Game Features
|
||||
|
||||
## v39.16.16 link, bounce-fence, and spring fixes
|
||||
`tarinai_` is an observation sandbox about small creatures called tarinai. The player shapes a field with food, medicine, furniture, hazards, machines, weather, and ground choices, then watches how the colony reacts. The game is about cause and effect: small changes in the environment can alter movement, stress, disease, relationships, reproduction, accidents, cleanup, and survival.
|
||||
|
||||
- ヒモ、棒、バネ、電線、絶縁電線は、接続線が画面内を横切る場合、中点の空間セルが画面外でも描画される。
|
||||
- バウンス柵はたりないを外力インパルスで確実に吹き飛ばし、柵自身が作る急激な速度変化では衝撃ダメージを与えない。
|
||||
- バネは受動式のまま剛性を高め、減衰と強制位置補正を下げ、より大きく長く往復する。
|
||||
## Core Play
|
||||
|
||||
## v39.16.13 detector and fence changes
|
||||
The player starts by choosing a field and ground, then places objects or uses direct tools on the colony. Tarinai continue acting on their own while the player observes, edits the environment, removes problems, or creates new ones. The colony panel, event log, selected-tarinai data, and family tree help explain what is happening without taking control away from the simulation.
|
||||
|
||||
- 感圧スイッチを「検知器」へ改名し、道具UI上で通常の柵と位置を入れ替えた。
|
||||
- 検知器は軸平行の矩形範囲で対象を数える。専用UIから横幅・縦幅・対象・必要数を設定する。
|
||||
- 柵の配置境界判定を実際の細長い形状に合わせ、フィールド上下端近くへ配置できるようにした。
|
||||
- バウンス柵の反発係数、最低射出速度、最大速度を引き上げ、衝突直後に強く跳ねるようにした。
|
||||
- ワープゲートを道具、描画、更新、信号、保存形式から削除した。
|
||||
- 一方通行柵を追加した。矢印方向には通過でき、逆方向のみ衝突する。
|
||||
- バイナリスキーマ36、スナップショット30。旧形式との読み込み互換は持たない。
|
||||
The game supports quiet observation and chaotic experiments. A player can build a comfortable habitat with beds, food, grass, and cleaning tools, or create a dangerous machine field with rotators, fences, stones, pins, explosions, ants, and poison.
|
||||
|
||||
## v39.16.11 mechanism fixes and passive spring
|
||||
## Tarinai
|
||||
|
||||
- 検知器は矩形範囲内の「たりない」または「アイテム」を数え、設定個数以上で電線へ信号を送る。
|
||||
- 一方通行柵は矢印方向だけ通過でき、逆方向のたりない・アイテム・機構を遮る。
|
||||
- バウンス柵は衝突対象へ強い即時反発を与える。
|
||||
- バネは棒と同じ2点接続方式で設置する。自動伸縮はせず、外力による伸び縮みに対して受動的に反発し、弾性運動する。
|
||||
- 電線と絶縁電線はアイテム同士を接続し、複数本を経由して信号を伝える。通常電線は通電中に接触したたりないへダメージを与え、絶縁電線は与えない。
|
||||
- バイナリスキーマ34、スナップショット28。v39.16.10と保存形式は同じ。
|
||||
Tarinai are autonomous creatures with HP, needs, stress, personality, age, family links, diseases, item effects, and behavior states. They choose actions from their current condition and surroundings instead of following a fixed script. Hunger can make them seek grass or food, sleepiness can make them find a bed, fear can make them flee, and social state can make them follow, fight, avoid, or approach another tarinai.
|
||||
|
||||
## v39.16.9 load UI and item behavior fixes
|
||||
Each tarinai has individual tendencies. Aggressive tarinai fight more easily, social tarinai seek others more often, nervous tarinai react strongly to danger, and inherited traits can make children resemble their parents. Selecting a tarinai shows health, needs, stress, effects, relations, inventory, family information, and the reason for its current behavior.
|
||||
|
||||
- Replaced browser-native load confirmations with an in-game confirmation window.
|
||||
- Kept fans in the realtime scheduler lane so wind applies continuously instead of as periodic gusts.
|
||||
- Made the zunchi tool preview use the fresh, fully opaque stage.
|
||||
- Rendered carried plushies at the same depth position as their owner.
|
||||
- Disabled grass spawning, growth, placement capacity, and existing grass on the fluffy blanket ground.
|
||||
Tarinai can be damaged by hazards, pushed by physics objects, carried by ants, attached to links, blocked by fences, affected by medicine, frightened by accidents, comforted by habitat items, or preserved when outside the active field. Some objects can become emotionally important, so losing them may cause panic.
|
||||
|
||||
## v39.16.8 compact amount, relationship, seed, and framing save
|
||||
## Food And Consumables
|
||||
|
||||
- アイテム種類の標準amountは保存せず、標準値と異なる共通値または個別差分だけを保存する。共通値候補は実バイト数で選択する。
|
||||
- 個体関係の対象インデックスを昇順に並べ、前の対象との差分として保存する。
|
||||
- worldSeedを固定16進64bitへ統一し、バイナリ上では8バイト固定で保存する。
|
||||
- バイナリ先頭はスキーマ1バイトへ統合し、上位bitで文字列テーブルの有無を示す。空の文字列テーブルと重複したスナップショット版番号は保存しない。
|
||||
- 圧縮フラグをフレーム長の下位bitへ統合し、独立した1バイトを削除する。
|
||||
- 15bit文字化の32768文字テーブルと逆引きMapを廃止し、Unicodeコードポイントの算術変換へ変更する。
|
||||
- バイナリスキーマ33、スナップショット27。旧形式との読み込み互換は持たない。
|
||||
Grass is both food and habitat material. Tarinai can eat it, feel comfort near it, gather it for simple beds, and interact with it through ground and weather systems. Water can be drunk, can appear from rain, and can be generated or copied by tools.
|
||||
|
||||
## v39.16.7 column-oriented creature save and packed item flags
|
||||
Sweet food helps satisfy hunger and can support recovery in some cases. Love mochi makes reproduction behavior more likely for a while, while fight mochi pushes the colony toward conflict. Zunda juice clears item effects, making it useful after medicine or special consumables have changed a tarinai too much.
|
||||
|
||||
- 個体の固定情報を項目別の列指向ビットプレーンへ変更し、全個体で0の列は保存しない。
|
||||
- 睡眠状態、遺伝差分、戦績差分の存在は各ID・差分マスクから直接判定し、重複していた3bit/個体を削除する。
|
||||
- アイテムの共通追加状態、amount差分、追加状態差分のフラグを全ブロック共通の連続ビット列へ格納する。
|
||||
- 共通追加状態は候補ごとに専用バイナリを試算し、全件個別保存より実際に小さい場合だけ採用する。
|
||||
- バイナリスキーマ32、スナップショット26。旧形式との読み込み互換は持たない。
|
||||
Medicine changes the simulation more aggressively than normal food. Sleep drugs push tarinai toward sleep or sleep disease, laxatives create repeated zunchi and reduce food benefit, protein makes a stronger permanent mode with lower disease risk, and niteropu makes a weaker permanent mode with higher disease risk. Ammo creates temporary speed and impact danger. Mystery drugs choose from eligible effects unpredictably. Mercury changes lifespan, while giant and dwarf drugs reshape size, movement, power, and ongoing damage risk.
|
||||
|
||||
## v39.16.4 compact identity and normal-item binary save
|
||||
|
||||
- 個体のbirthSeed文字列を保存せず、出生番号と32bitハッシュへ分解する。`worldSeed + birthSerial`で再現できる個体はハッシュも省略する。
|
||||
- 新規出生のbirthSeedは親文字列を含めず、親子関係と遺伝情報は既存の専用フィールドで保持する。
|
||||
- 通常アイテムの追加情報を型別専用バイナリへ移行し、JSONテキストを保存しない。未知の追加配列も数値・文字列の型付きバイナリで保存する。
|
||||
- 同じ種類のアイテムはamountの最頻値をブロックに1回だけ保存し、同一追加状態が複数ある場合も共有する。個別アイテムには差分だけを保存する。
|
||||
- バイナリスキーマ30、スナップショット24。旧形式との読み込み互換は持たない。
|
||||
## Habitat And Cleanup
|
||||
|
||||
## v39.16.3 physics binary save and reciprocator cargo contacts
|
||||
|
||||
- 回転体・毒ブロック・往復体・ロープ・ロッドの追加情報を、JSON文字列ではなく専用バイナリで保存する。型名、キー名、デフォルト値、重複座標、標準輪郭、インデックス参照済み接続先の文字列情報を省略する。
|
||||
- バイナリスキーマは29。v39.16.2のスキーマ28は読み込み互換を維持する。
|
||||
- 往復体はボール、石、画鋲などの可動物理アイテムを押し出すが、それらとの接触では方向、レール位相、モーター速度を変更しない。
|
||||
|
||||
## v39.16.2 reciprocator collision and placement alignment
|
||||
|
||||
- 往復体は、進行方向にある機構、静的障害物、物理アイテムへ衝突した時点で方向転換する。接触分離によって重なりが解消されても反転判定を失わない。
|
||||
- 静的障害物との判定から往復体を除外しない。高速移動時は障害物付近でサブステップ判定を増やし、すり抜けを抑える。
|
||||
- 往復体の設置前オーバーレイと実配置は同じ角度初期化処理を使用し、物理ボディー角度とレール軸を同じ順序で確定する。
|
||||
- セーブ形式は変更しない。
|
||||
|
||||
## v39.16.1 debug save-size breakdown
|
||||
|
||||
- `?debug=1` でセーブまたは呪文出力を実行すると、デバッグオーバーレイにバイナリ内訳、文字数、エンコード時間が表示される。
|
||||
- 内訳は形式情報、文字列テーブル、ワールド、個体基本情報、個体シード、個体関係、アイテム索引、通常アイテム、物理アイテムに分けて実占有バイトを集計する。
|
||||
- 通常アイテム・物理アイテムの追加情報について、バイナリ内の占有量とJSON UTF-8元サイズを併記する。ロード後は直近のデコード時間も表示する。
|
||||
|
||||
This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map.
|
||||
|
||||
## v39.16.0 mechanical interaction and limit audit
|
||||
|
||||
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
|
||||
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。
|
||||
- コロニー上限は中央生成APIで常時適用され、通常実行時に上限を迂回するオプションは持たない。既存要素やセーブ復元要素は削除せず、その後の新規生成のみを抑止する。
|
||||
|
||||
## v39.15.165 behavior and colony controls
|
||||
|
||||
- たりない王は、繁殖可能な別のたりない王を広い範囲から優先して探し、通常より強い繁殖欲求と短い再繁殖待機時間を持つ。
|
||||
- 往復体の新規配置時の標準方向は90度。
|
||||
- 毒ブロックの新規配置時の標準輪郭は小型(半径44、輪郭104×40、太さ11)。
|
||||
- コロニーパネルで、たりない個体数と生存オブジェクト総数の上限を設定できる。空欄または0は上限なし。既存要素は削除せず、新規追加だけを抑止する。
|
||||
- 上限値はスナップショットとバイナリセーブに保存される。
|
||||
|
||||
## Game Overview
|
||||
|
||||
`tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records.
|
||||
|
||||
The core loop is:
|
||||
|
||||
1. Choose a field and observe the colony.
|
||||
2. Place tools or objects that change the environment.
|
||||
3. Select individual tarinai to inspect health, needs, personality, relations, diseases, records, and current behavior.
|
||||
4. Watch the event log, colony graphs, and family tree to understand long-term outcomes.
|
||||
|
||||
## Main UI
|
||||
|
||||
- **Canvas / field**: the main simulation area. Tarinai, items, effects, ants, weather, and terrain are drawn here.
|
||||
- **Top buttons**: pause/resume, speed control, ecology guide, sound settings, credits, save, display settings, and reset.
|
||||
- **Display settings**: manually adjust effects, shadows, and drawing detail. `High` keeps quality-preserving optimizations active, while lower tiers cut heavier visuals for crowded scenes without lowering object limits; simulation cadence remains quality-preserving.
|
||||
- **Mobile mode controls**: auto, camera, and tool modes for touch screens.
|
||||
- **Right panel tabs**: jump between main game, selected tarinai data, tools, colony, events, and family tree.
|
||||
- **Tarinai data panel**: appears when a tarinai is selected. Shows health, needs, personality, active effects, relations, inventory, behavior reason, and records.
|
||||
- **Tool palette**: grouped buttons for operation, food, medicine, habitat, physics, and danger tools.
|
||||
- **Colony panel**: shows population, deaths, colony mood, ground type, and charts for population, needs, and objects.
|
||||
- **Event panel**: logs births, deaths, accidents, fights, food events, weather, grass, and relation changes. Optional push-style notifications can be enabled by event type.
|
||||
- **Family tree**: shows parent-child generations, inherited stats, personality tags, family links, and archived lineage records.
|
||||
- **Ecology dialog**: in-game guide explaining tarinai needs, stress, food, sleep, personality, family, disease, accidents, tools, and colony reading.
|
||||
- **Sound menu**: master sound plus voice, notification, and operation categories.
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### Operation
|
||||
|
||||
- **解除 / clear**: cancels the selected tool and returns to normal observe mode.
|
||||
- **たりないデータ / observe**: select a tarinai or inspect compatible objects such as signboards.
|
||||
- **追加 / new**: calls a new tarinai into the field.
|
||||
- **つつく / poke**: pokes tarinai and can push balls.
|
||||
- **つまむ / pinch**: drags tarinai and some placed objects.
|
||||
- **コピー / copy**: copies a placed item and pastes a new instance with its settings.
|
||||
- **戻す / undo** and **進む / redo**: reverse or reapply placement/edit actions.
|
||||
- **削除 / delete**: removes placed tools, objects, and stains.
|
||||
- **範囲削除 / area delete**: removes many nearby objects or stains around the cursor.
|
||||
- **洗浄 / water hose**: drag to clean dirt, zunchi, corpses, and stains.
|
||||
|
||||
### Food
|
||||
|
||||
- **ずんだ餅 / sweet**: favored food; can cure some illness.
|
||||
- **へこ餅 / love mochi**: makes reproduction behavior more likely for a while.
|
||||
- **けんか餅 / fight mochi**: makes fighting behavior more likely for a while.
|
||||
- **草 / grass**: food and comfort source; can grow, be used for simple beds, and sometimes dampen nearby fire instead of immediately catching.
|
||||
- **水滴 / water**: drinkable water, also appears during rain and can be set in duplicators.
|
||||
- **ずんだ汁 / zunda juice**: clears item effects from a tarinai.
|
||||
- **複製機 / duplicator**: can be loaded with compatible food, medicine, grass, water, pushpins, or oshibyo and then produces or applies that content.
|
||||
Zunchi is waste, but it is also part of the ecology. It affects hygiene and disease risk and can fertilize grass. Beds and nest boxes give tarinai safer or better sleep options, reduce stress around resting areas, and help recovery. Balls give tarinai something to play with and can also become part of physics experiments.
|
||||
|
||||
### Medicine
|
||||
Robot cleaners are mobile cleanup items. They scan for selected target types, pursue matching waste or corpses, and remove them on contact. The water hose gives the player direct cleanup control by washing dirt, zunchi, corpses, stains, and similar field problems. Signboards are editable objects that can be inspected after placement, useful for labeling areas or experiments.
|
||||
|
||||
- **ねむり薬 / sleep drug**: causes sleepiness or sleep disease; damage can cure the disease.
|
||||
- **下剤 / laxative**: causes repeated zunchi and reduces food benefit.
|
||||
- **プロテイン / protein**: permanent power mode; more damage dealt, lower disease chance.
|
||||
- **ンイテロプ / niteropu**: permanent weak mode; less damage dealt, higher disease chance.
|
||||
- **弾薬 / ammo**: temporary high-speed movement and strong collision impact.
|
||||
- **怪しい薬 / mystery drug**: randomly applies an eligible item effect or disease.
|
||||
- **水銀 / mercury**: increases lifespan multiplier on each use.
|
||||
- **巨大薬 / giant drug**: greatly increases size, attack, and speed, but deals ongoing damage.
|
||||
- **矮小薬 / dwarf drug**: greatly decreases size, attack, and speed, and also deals ongoing damage.
|
||||
## Physics And Machines
|
||||
|
||||
### Habitat
|
||||
Physics items turn the field into a mechanical sandbox. Ropes connect two targets with a flexible link that can go slack but should not stretch freely. Rods create a rigid connection. Springs connect targets with elastic motion, allowing linked objects to pull back and oscillate.
|
||||
|
||||
- **ずんち / zunchi**: tarinai waste; affects disease and can fertilize grass.
|
||||
- **ロボ掃除機 / robot_cleaner**: moves through the field at fixed normal speed 60, or 120 in high-speed mode. It checks the nearest matching cleanup target every 2 seconds, keeps pursuing it between scans, and cleans every matching object it touches. The 死骸 target also covers blood traces; clicking the item opens its target settings.
|
||||
- **干草寝床 / bed**: placeable sleeping spot; helps HP recovery.
|
||||
- **巣箱 / nest box**: reduces stress nearby; improves sleep quality when used.
|
||||
- **ボール / ball**: toy that tarinai can play with and the player can poke.
|
||||
- **看板 / signboard**: editable text object; inspect with observe after placing.
|
||||
Fences block movement and can be rotated for layout control. Gates can be opened or closed after placement. One-way fences allow passage from one direction while blocking the opposite direction. Bounce fences launch colliding targets more strongly than normal fences, which makes them useful for traps or kinetic experiments.
|
||||
|
||||
### Physics
|
||||
|
||||
- **ヒモ / rope**: connect two targets with a flexible, inextensible link that may go slack but does not lengthen.
|
||||
- **棒 / rod**: connect two targets with a rigid link.
|
||||
- **柵 / fence**: blocks tarinai movement; Q/E rotate by 45 degrees, Shift+Q/E by 5 degrees.
|
||||
- **バウンス柵 / bounce fence**: high-restitution fence that strongly launches things that hit it.
|
||||
- **ゲート柵 / gate fence**: click to open or close.
|
||||
- **回転体 / rotator**: custom mechanical shape that rotates; click to edit shape and speed.
|
||||
- **毒ブロック / poison block**: custom hazard block; touching it damages skin; click to edit shape.
|
||||
- **往復体 / reciprocator**: custom mechanical shape that moves back and forth; click to edit shape and speed.
|
||||
|
||||
### Danger
|
||||
|
||||
- **石 / stone**: heavy object; dangerous when dropped or moved into others.
|
||||
- **げんこつ / genkotsu**: large fist impact on the ground.
|
||||
- **爆竹 / firecracker**: creates an explosion; flame variants scatter fire while respecting active-fire limits.
|
||||
- **画鋲 / pushpin**: rolls when dropped; can lodge in tarinai and cause panic plus continuous damage.
|
||||
- **おしり鋲 / oshibyo**: blocks zunchi when lodged; pulling it out releases stored zunchi.
|
||||
- **射撃 / shoot**: attacks with a gun effect.
|
||||
- **アリの巣 / ant nest**: spawns ants that search for tarinai and carry them to the nest.
|
||||
|
||||
## Tarinai Needs And Stats
|
||||
|
||||
Tarinai behavior is shaped by health, energy, needs, stress, personality, family, disease, and nearby objects.
|
||||
|
||||
Key needs include:
|
||||
|
||||
- **Food / hunger**: hungry tarinai seek grass, sweets, food, water-related options, or other edible targets.
|
||||
- **Sleep**: sleepy tarinai seek beds, nest boxes, grass beds, or eventually sleep anywhere.
|
||||
- **Safety**: danger, pain, hazards, ants, fights, and panic increase defensive behavior.
|
||||
- **Social / relations**: tarinai can seek friends, follow parents, avoid enemies, fight rivals, or approach mates.
|
||||
- **Fulfillment / comfort**: toys, ground, sun, shelter, and environment affect mood and stress.
|
||||
- **Health**: damage, disease, stress, and recovery decide whether tarinai live, weaken, or die.
|
||||
|
||||
Stress changes with fear, crowding, dirt, disease, fights, ground type, and environment. High stress makes fleeing, intimidation, sickness, and poor behavior more likely.
|
||||
|
||||
Personality and genetics affect tendencies such as aggression, openness, sociability, nervousness, body size, lifespan, damage, and speed. Some traits are inherited by children and shown in the family tree.
|
||||
|
||||
## Movement And Behavior
|
||||
|
||||
Tarinai are autonomous. Their movement is usually a result of current state, target choice, and local environment.
|
||||
|
||||
Common behavior states:
|
||||
|
||||
- **Idle / wander**: waits, watches, or moves lightly around the field.
|
||||
- **Seek food**: moves toward edible items when hungry or affected by disease.
|
||||
- **Eat / drink**: consumes food, grass, water, medicine, or special items.
|
||||
- **Seek bed / sleep**: finds a bed, nest box, grass bed, or sleeps in place.
|
||||
- **Play ball**: approaches and interacts with balls.
|
||||
- **Seek material / build**: gathers material and creates simple structures such as grass beds.
|
||||
- **Seek friend / follow parent**: moves toward social targets or parents.
|
||||
- **Fight / seek enemy**: approaches and attacks rivals, especially under fight effects or aggression.
|
||||
- **Panic / flee**: runs from danger, pain, destroyed owned objects, hazards, or fear triggers.
|
||||
- **Intimidate**: threatens another tarinai or ants instead of directly fleeing or attacking.
|
||||
- **Birth ritual**: pre-birth/reproduction interaction with a partner.
|
||||
- **Sunbath**: rests in good sun to reduce stress.
|
||||
- **Disease states**: zunchi sickness, sleep disease, explosion disease, and injury sickness can override normal priorities.
|
||||
- **Frozen**: outside-field preservation state.
|
||||
|
||||
Tarinai can be pushed by physics objects, carried or affected by ants, dragged by the player, attached with ropes/rods, blocked by fences, and damaged by hazards. Some objects become owned or emotionally important, so losing them can trigger panic.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Field types**: park, garden, and cage provide different field sizes.
|
||||
- **Ground types**: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style ground affect stress, grass, and movement.
|
||||
- **Weather**: rain and other weather influence water, mood, and environment effects.
|
||||
- **Grass ecology**: grass grows, feeds tarinai, comforts them, can become bed material, and has a small chance to reduce fire before burning.
|
||||
- **Dirt and corpses**: zunchi, splats, corpses, and ant corpses affect hygiene, disease, and observation results.
|
||||
- **Day/light conditions**: lighting and sun influence rendering and sunbath behavior.
|
||||
- **Season temperature**: automatic summer climate is roughly 5℃ hotter than the previous baseline; manual temperature control remains exact to the slider value.
|
||||
|
||||
## Crowded Scene Performance
|
||||
|
||||
Crowded fields are an intended sandbox outcome. Performance work should prefer cached, typed, spatial, dirty, or incremental lookups over lowering object limits. High display quality should preserve visible fidelity and simulation rules while avoiding repeated hidden scans in render, tooltip, family tree, collision, effect, fire, and maintenance paths.
|
||||
|
||||
## Disease And Recovery
|
||||
Rotators are editable mechanical shapes that spin and push nearby targets. Their shape and speed can create conveyors, crushers, traps, or chaotic collision fields. Reciprocators move back and forth along a line, so they can shove objects repeatedly. Poison blocks are custom hazard shapes that damage on contact.
|
||||
|
||||
Diseases and item effects can strongly redirect behavior.
|
||||
## Electrical And Automation
|
||||
|
||||
- **Zunchi disease**: changes food/water priorities and makes the tarinai feel unwell.
|
||||
- **Sleep disease**: causes sleep-focused behavior.
|
||||
- **Explosion disease**: creates dangerous explosive outcomes and water-seeking pressure.
|
||||
- **Fight disease / injury sickness**: causes unstable movement or fighting-related behavior.
|
||||
- **Item effects**: mochi, medicine, size drugs, mercury, ammo, and power modes create temporary or permanent modifiers.
|
||||
Detectors, wires, insulated wires, and circuit boards are grouped in the Electrical tool category. Normal energized wire can hurt living targets on contact, while insulated wire is safer for incidental contact. Detectors can sense target ranges or time windows and send signals; clicking either dual-range track moves the nearer thumb directly to the clicked value. Circuit-board external input and output terminals are placed directly on the board edge, can be moved around all four sides, and appear dim in the editor until a world wire is actually connected.
|
||||
|
||||
Recovery depends on the disease or effect. Water, rest, food, medicine, zunda juice, damage, environment cleanup, or time can all matter depending on the case.
|
||||
## Danger Tools
|
||||
|
||||
## Family, Records, And Colony Observation
|
||||
Danger tools create immediate or ongoing pressure. Stones are heavy objects that become dangerous when dropped, thrown, or moved by machines. Genkotsu creates a large impact on the ground. Firecrackers cause explosions, with flame variants adding fire while respecting active-fire limits.
|
||||
|
||||
- Tarinai can form parent-child records and partner links.
|
||||
- Children inherit parts of their parents' tendencies and are grouped by generation.
|
||||
- Family tree nodes show portraits, inherited stats, personality tags, and relationship structure.
|
||||
- Event logs record important simulation events such as birth, death, accidents, fights, weather, grass, and relation changes.
|
||||
- Colony charts summarize population, needs, and object counts over time.
|
||||
- Selected tarinai records help explain why an individual is acting a certain way.
|
||||
Pushpins can roll, lodge in tarinai, cause panic, and deal continuous damage. Oshibyo blocks zunchi while lodged and releases stored zunchi when removed. Shooting is a direct attack tool that can hit tarinai, ants, items, pins, balloons, or placed objects depending on the target under the aim point.
|
||||
|
||||
## Save, Reset, And Persistence
|
||||
Ant nests introduce an enemy system. Ants search for tarinai, carry captured targets back to the nest, and can be shot or killed. Worker ants and queen ants have separate HP values, and ant counts are shown through colony statistics.
|
||||
|
||||
- The app supports manual save and restore through browser storage.
|
||||
- Current saves are allowed to use compact/hash-style text. Old save text does not need to remain loadable when the save format is intentionally shortened or replaced, so load failure is acceptable after intentional save-format changes as long as the current save/load path stays internally consistent.
|
||||
- Reset opens field selection and starts a new field state.
|
||||
- Service worker caching allows the static app shell to remain available after load, depending on browser support.
|
||||
## Operation Tools
|
||||
|
||||
The observe tool selects tarinai or compatible objects and opens their data. The add tool introduces new tarinai. Poke applies a small interaction or push, while pinch lets the player drag tarinai and some objects. Copy duplicates placed items with their settings so setups can be repeated quickly. The operation palette also provides a dedicated snap control shared by placement and pinch movement; it cycles through Free, 20px, 40px, and 60px.
|
||||
|
||||
Undo and redo reverse or reapply placement and edit actions. Delete removes a specific object or stain, while area delete removes many nearby targets at once. These tools let the player repair a field after experiments without resetting the whole colony.
|
||||
|
||||
## Grounds And Environment
|
||||
|
||||
Fields define the playable space and boundary style. Grounds change the character of the colony: sand, dirt, concrete, blanket, foot massage, ice, and laboratory-style surfaces can affect movement, stress, comfort, and grass behavior. Ground choice matters because tarinai react not only to objects, but also to the surface they live on.
|
||||
|
||||
Weather changes the field over time. Rain can create water and affect comfort, shade can protect from weather or sun, and temperature changes how tarinai experience the environment. Grass, zunchi, splats, corpses, ant corpses, rain drops, and stains all feed into hygiene, disease, cleanup, rendering, and observation.
|
||||
|
||||
## Colony Statistics
|
||||
|
||||
The colony panel summarizes population, sick tarinai, juveniles, elders, deaths, mood, temperature, population limits, and object limits. Graph tabs show population, needs, and environment. Total ants are included in the environment statistics so active ant pressure can be read alongside other field signals.
|
||||
|
||||
The event panel records important simulation events such as birth, death, fights, accidents, weather, grass changes, and relation changes. The family tree shows parent-child structure, generations, inherited stats, personality tags, and family links.
|
||||
|
||||
## Save, Display, And Performance
|
||||
|
||||
Manual save and restore use browser storage. Reset returns to field selection and starts a new state. Service worker caching can keep the static app shell available after the first successful load when the browser supports it.
|
||||
|
||||
Display settings control effects, shadows, drawing detail, and quality choices for crowded scenes. Crowded fields are an intended part of the sandbox, so the game uses spatial lookup, scheduling, cached drawing, and incremental maintenance systems to keep large colonies and many objects playable.
|
||||
|
||||
|
||||
## Achievement system (v39.16.78)
|
||||
- Sixty-four achievements track reproduction, natural status changes, generation runs, rapid population events, special deaths, colony conditions, individual care, autonomous ecology, history usage, cleanup, rapid deletion, temperature transitions, weather shelter, medicine-category completion, combat reversals, circuit-board signaling, and ant-related play styles.
|
||||
- Locked rows reveal only the configured condition allowlist; all others show `???`. There are no achievement tooltips. 大いなる母, めっちゃたりない観察, 敵の敵は味方, 混沌ヲ希求スル者, and 豊穣ヲ希求スル者 include local progress percentages, and unlocked achievements are sorted first inside each category.
|
||||
- Each achievement row behaves as a horizontal bar chart. Its background fill visualizes the optional global completion percentage from 0–100%, and the global achiever count is displayed beside it.
|
||||
- `物でいっぱい` requires 100 cumulative player placements across field resets. `害虫駆除` requires 100 cumulative ant kills across field resets.
|
||||
- `餌やりじいさん` requires 33 direct feedings in one field; resetting the field resets this progress.
|
||||
- `機械化産業` replaces the former detector-automation and link-craftsman achievements and unlocks only when both requirements are met.
|
||||
- v39.16.56 adds 混沌ヲ希求スル者 and 潔癖症, raises 清掃業者 to 200, starts ミニマリスト from day 6, excludes chain/crown from おくすり手帳全埋め, and keeps shared-rate bars visible after unlock.
|
||||
- v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく. ベビーブーム now requires 30 births in one minute; 管理された楽園 starts tracking from play day 6 and requires one uninterrupted happy in-game day.
|
||||
- Local unlocks and persistent totals live in browser storage. Field-scoped direct-feeding, cleaner, self-sufficiency, enemy-ant, generation, observation, safety, and per-entity/item progress follow current-format saves where applicable. Nest-box and pipe occupants block rain/weather effects and count as sheltered for 雨宿り完了.
|
||||
- Achievement evaluation is primarily event-driven; periodic world scans skip completed or irrelevant conditions. The server writes compact v3 achievement aggregate JSON while migrating older aggregate formats on read.
|
||||
- v39.16.78 fixes achievement edge paths found by a renewed 64-condition audit: Minimalist placement metadata survives unrelated unlocks, Sauna/Cold-Plunge uses true hot-entry timing, lethal qualifying wire shocks count before death cleanup, reset clears hidden reset-scoped progress, and Undo/Redo/ground changes interrupt passive observation. Ambiguous semantics are intentionally unchanged pending specification.
|
||||
|
||||
### Achievement spell persistence (v39.16.78)
|
||||
- Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress.
|
||||
- Loading a spell merges achievements and keeps the greater progress instead of erasing local achievement data.
|
||||
- Restored unlocks are queued for the existing server recovery synchronization. Save slots remain world-only.
|
||||
|
||||
- Spell achievement metadata uses a sparse binary encoding: raw unlock bits, bit-packed persistent progress, minute-delta unlock timestamps, and sparse per-entity state. Binary save schema 51 is current; schemas 49 and 50 remain readable. Achievement payload v6 stores 72 unlock bits and statistician progress, while legacy payload v5 remains importable.
|
||||
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.
|
||||
- v39.16.62: Added five achievements for low FPS, uninterrupted 24-hour play, toilet-sand placement, park-ground editing, and rapid ground changes. 寿命 deaths are excluded from 安全第一 failures, empty-field clicks replace pause-button clicks for 何やってるの?, and the two long observation achievements are last in その他.
|
||||
|
||||
- v39.16.63: Added アリ観察 and 命のバトン; stabilized progress-row typography; continuous-play observation no longer resets on tab changes; corrected sauna wording and enemy-ant streak reset behavior; obsolete spell formats are rejected.
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -13,8 +13,19 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
|||
|
||||
## Runtime Shape
|
||||
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.18`.
|
||||
- v39.16.18 changes: redundant physics normalization, dirty-marking, compaction, pickup, and collision bookkeeping were removed; one-way-fence and bounce corrections now use shared canonical helpers.
|
||||
- v39.16.37 changes: biological and environmental conditions are unified into the detector; the standalone sensor and relay are removed; temperature and limits are folded; ecology, sound, display settings, and credits are in the header menu; reset remains outside in red; tarinai-data navigation preserves the active tool; mobile tool descriptions appear at the top for five seconds. Snap mode remains Free/20px/40px/60px, top-edge placement is fixed, and preset descriptions end with punctuation.
|
||||
- v39.16.38 changes: detector numeric controls use value-labelled sliders like the time control; colony temperature and limits share one Colony settings fold; display settings use an inline menu matching sound settings; HP/stress meter colors are fixed; the snap control follows Clear with a faint grid icon; area-delete has a new icon; the Ant chart series is renamed and hidden initially.
|
||||
- v39.16.55 changes: expanded the achievement set to 49 with 町のお医者さん and 割とふわふわ; changed ベビーブーム to 30 births per minute and 管理された楽園 to one happy in-game day from play day 6; restored shared poke targeting/highlights and plushie fling; corrected manual rotator/reciprocator push direction; tightened tool/limit UI; reduced periodic achievement scans; and introduced compact achievement aggregate storage.
|
||||
- v39.16.52 changes: reorganized achievements into collapsible categories, removed achievement tooltips, and moved confirmation flows into the game UI. Previous v39.16.51 changes: expanded the achievement set to 43 with sixteen play-style and special-action conditions; added persistent Undo/Redo and quick-delete counters, field-local cleaner/automation-free ecology progress, individual care/temperature/fight tracking, complete medicine-category collection, protected rain shelter in nest boxes and pipes, and stricter ant-kill attribution.
|
||||
- v39.16.49 changes: added additive full-state synchronization so shared achievement aggregates can be rebuilt as players return after server data loss.
|
||||
- v39.16.48 changes: corrected the zunchi-slave threshold text to 30%; repaired wire placement counting, danger-kill attribution, partial legacy link migration, UUID validation, and debug-only achievement isolation.
|
||||
- v39.16.47 changes: Two achievements are added (大いなる母 and 喧嘩両成敗); five names and five revealed descriptions are revised; `?debug=1` exposes an all-achievements button.
|
||||
- v39.16.46 changes: achievement names and presentation are revised; conditions are revealed only after unlock; shared completion rates render as horizontal background bars; automation and link coverage merge into 機械化産業; placement and ant-kill targets are persistent totals of 100; direct feeding resets with the field.
|
||||
- v39.16.45 changes: seven play-style achievements add passive observation, cumulative placement, detector automation, link-tool coverage, safe large-colony operation, ant population, and ant elimination measurements. World-scoped timers and placement counts persist in current-format saves.
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.16.78`.
|
||||
- v39.16.31 changes: all in-world tooltips use the closer offset; Hair Trigger adds a central bounce-fence ball enclosure with Tarinai and pet-food duplicators outside; physics tools use distinct icons.
|
||||
- v39.16.29 changes: parasol placement preview now uses the exact shade center and footprint; toilet sand absorbs rain-created water drops. Happy adds parasols, water balloons, and toilet sand beneath each duplicator; Athletic uses fence-safe single-bar rotators and water balloons; War adds rotating fans.
|
||||
- v39.16.19 changes: food and medicine can be given directly by placement or pinched drop, with a dedicated overlay, food heart bursts, and stronger personality effects; unused collision reasons and duplicate constraint projection dirty checks were removed.
|
||||
- Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots.
|
||||
- Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases.
|
||||
- UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.
|
||||
|
|
@ -56,3 +67,46 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
|||
|
||||
### v39.16.16
|
||||
Colony birth/death chart lines, reverse rotators, champion-trait inheritance, reduced champion-pair mating, and directional one-way-fence routing.
|
||||
|
||||
|
||||
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
|
||||
|
||||
|
||||
## Achievement system (v39.16.78)
|
||||
- Sixty-four local achievements cover biological events, special deaths, colony conditions, individual care, passive ecology, Undo/Redo behavior, rapid deletion, cleaner use, temperature transitions, medicine-category completion, weather shelter, combat reversals, circuit-board signaling, and ant-related play styles.
|
||||
- Locked entries show the configured pre-unlock disclosure directly in the row; all other locked entries show `???`. 大いなる母, めっちゃたりない観察, and 敵の敵は味方 show local progress percentages. Unlocked entries appear first within each category.
|
||||
- Each entry is a horizontal completion-rate bar whose background fill reflects the optional shared global unlock percentage; the shared achiever count appears beside the percentage.
|
||||
- v39.16.64 adds 毎日たりない観察(7 calendar days) and 抵抗器じゃない(7 distinct tarinai shocked by one energized bare wire). v39.16.54 added 真・たりない観察, 悠久の歴史, 五体満足, and 零体満足. v39.16.55 added 町のお医者さん for 50 effective first-aid recoveries and 割とふわふわ for flinging a plushie with つつく; both are event-driven and 町のお医者さん is disclosed before unlock. v39.16.56 added two achievements, persistent fight progress, stricter robot-only evaluation, revised thresholds, and unlocked-card rate bars. v39.16.60 adds three achievements and restricts the chaos counter to fight-mochi-influenced fights.
|
||||
- Persistent totals include 物でいっぱい, 害虫駆除, 大いなる母(3333体), 無計画都市, やっぱなし, and やっぱあり. Field-local progress includes 餌やりじいさん, 清掃業者, 自給自足, and 敵の敵は味方; applicable field-local metadata follows current-format saves.
|
||||
- `自動化の第一歩` and `リンク職人` are retired and replaced by `機械化産業`, which requires both detector-driven activation and rope/rod/spring coverage. Existing players with both retired achievements migrate automatically.
|
||||
- Optional same-origin PHP API aggregates anonymous player counts and global unlock percentages. Raw aggregate data is stored under `.achievement_data/state.json` in compact v3 positional arrays. Compact v2 and the prior verbose object are accepted only as migration inputs and are automatically rewritten under a separate server lock with atomic file replacement on the next API read. UUIDs are binary/base64url encoded, achievement IDs are indexed, timestamps are delta-coded, and repeated game versions use a dictionary.
|
||||
- On startup, shared-stat refresh, achievement-screen opening, and every five minutes, the client sends its complete non-debug unlock set with original local timestamps. Ordinary unlock bursts send only unsynchronized records after a short debounce. The server adds only missing records, so a lost `state.json` is rebuilt incrementally as players return without overwriting surviving records.
|
||||
|
||||
### Shared achievement percentage deployment
|
||||
- Upload `achievement_api.php` and the hidden `.achievement_data` directory with the game.
|
||||
- PHP must be enabled, and `.achievement_data` must be writable by the PHP process.
|
||||
- On a static-only host, local unlocks still work; the global percentage is shown as unavailable.
|
||||
- Set `window.TARINAI_ACHIEVEMENT_API` before `achievements.js` to use a different same-origin endpoint.
|
||||
- The current release tracks sixty-four achievement IDs, supports per-player reset, and accepts additive bulk synchronization for automatic recovery after aggregate-data loss. A separate backup remains necessary to recover players who never return.
|
||||
- `python3 scripts/achievement_server_state_audit.py` creates deterministic randomized achievement histories at runtime; it does not require or modify a production `state.json`.
|
||||
- v39.16.60 changes: achievement metadata in spells is now encoded as sparse binary data instead of embedded JSON; unlock masks, progress, timestamps, per-entity counters, and placement metadata are compactly packed and spell data is accepted only from the current format.
|
||||
- v39.16.60: Added 狙撃手, メガロポリス, and 豊穣ヲ希求スル者; restricted 混沌ヲ希求スル者 to fight-mochi-influenced fights; extended compact achievement spell progress and omitted counters for already-unlocked achievements.
|
||||
- v39.16.62: Added スペックがたりない, 寝ろ, 砂場, そこ公共空間だよ?, and 破格の工事費用; 寿命 deaths no longer reset 安全第一; 何やってるの? now uses empty-field clicks; and save schema 44 expands the achievement mask while decoding schemas 41–43.
|
||||
|
||||
- v39.16.63: Fixed achievement progress text weight and continuous-play tab switching; added アリ観察 and 命のバトン; corrected サウナ水風呂 and 敵の敵は味方 condition mismatches; save/spell data accepts only schema 45 and achievement payload v4.
|
||||
- v39.16.64: Added 毎日たりない観察 and 抵抗器じゃない; introduced calendar-day streak persistence; and migrated server aggregate JSON to compact v3 positional data automatically on read.
|
||||
- v39.16.65: Hardened pre-deployment server migration with read-only summaries, POST-only mutations, corruption-safe decoding, atomic writes under a separate lock, completionist eligibility filtering, and seeded randomized/concurrent migration audits.
|
||||
|
||||
- v39.16.66: Added two-user seesaw play, an editable AND/OR/NOT circuit board with positioned terminals, signal control for fan/stove/dry ice/duplicator, and wire-target highlighting.
|
||||
|
||||
- v39.16.67: Replaced the circuit-board dropdown editor with direct grid placement and cursor-drawn wiring; changed colony-state evaluation to every two in-game hours and excluded growing populations from Weakened; fixed seesaw action selection, fixed its orientation, and matched the placement overlay to its shape.
|
||||
- v39.16.69: Reworked circuit-board wiring into straight grid segments with branches and multi-connect terminals; removed OR and added a rectifier; made internal parts rotatable and external terminal count/positions editable; expanded board terminal spacing; changed detector numeric conditions to min/max dual sliders with number inputs; and raised seesaw riders slightly above the seats.
|
||||
- v39.16.68: Fixed seesaw participation so idle/bored Tarinai can initiate play and a waiting rider strongly recruits a partner; selected data reports active play as 「シーソーをしている。」; moved seesaws behind sprite layers; and aligned all two-input circuit gate pins to the 40px wiring grid.
|
||||
- v39.16.70: Reduced the default circuit-board footprint to one quarter of the previous area; made grid width/height editable; made external terminals movable on all four edges with a height-dependent count limit; made branch junctions movable; reduced NOT/rectifier body sizes; and corrected seesaw action-reason text.
|
||||
|
||||
- v39.16.71: Replaced circuit-board terminal +/- controls with direct IN/OUT terminal placement and erasing, dimmed terminals without external world wires, removed generic elliptical placement previews, added an Electrical tool category for detector/wires/circuit board, and made detector range-track clicks move the nearest thumb.
|
||||
|
||||
- v39.16.72: Circuit boards now default to 10x10 with a 20x20 maximum, AND uses a two-cell height, compact gates can sit closer to top/bottom edges, placement previews follow occupancy footprints more directly, and unstable internal logic loops are explicitly forced OFF.
|
||||
- v39.16.73: Circuit-board editing now shows placement ghosts for internal parts and terminals, Q/E rotates the pending internal part by 90 degrees, ON constant sources are explained in the editor, and wire/insulated-wire palette silhouettes render correctly after the Electrical category move.
|
||||
- v39.16.78: Fixed 「あぁ^~ たまらねぇぜ」 so burst zunchi ignore their spawn overlap with the owner, arm only after clearing the owner once, and unlock on a later fatal return collision including bounce-fence reflections. The renewed achievement audit also fixes ミニマリスト save metadata, サウナ水風呂 hot-entry timing, lethal wire-shock counting, reset-scoped hidden progress cleanup, and 監視カメラ intervention gaps for Undo/Redo and ground changes.
|
||||
- v39.16.76: Removed the circuit-board OFF constant part and added the 「情報化産業」 achievement for carrying an active signal directly from one circuit board to another over wire or insulated wire.
|
||||
|
|
|
|||
513
achievement_api.php
Normal file
513
achievement_api.php
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
const ACHIEVEMENT_IDS = [
|
||||
'first_birth',
|
||||
'natural_zunchi_slave',
|
||||
'natural_tarinai_king',
|
||||
'self_zunchi_death',
|
||||
'natural_zunchi_slave_5_generations',
|
||||
'natural_tarinai_king_3_generations',
|
||||
'death_50_in_10_seconds',
|
||||
'birth_50_in_60_seconds',
|
||||
'great_mother_1000_births',
|
||||
'lifespan_completed',
|
||||
'soccer_ball_death',
|
||||
'fight_pair_danger_kill',
|
||||
'all_non_sleep_diseased_25',
|
||||
'colony_happy',
|
||||
'direct_feed_33',
|
||||
'ignite_during_birth_ritual',
|
||||
'laxative_starvation',
|
||||
'idle_observer_5_minutes',
|
||||
'placed_objects_100',
|
||||
'mechanized_industry',
|
||||
'safe_colony_25_5_minutes',
|
||||
'ants_alive_25',
|
||||
'ants_killed_100',
|
||||
'secret_collection_9_slots',
|
||||
'pause_spam_4_in_1_second',
|
||||
'continuous_play_1_hour',
|
||||
'below_absolute_zero_item',
|
||||
'undo_mass_revival',
|
||||
'robot_cleaner_100',
|
||||
'held_30_seconds',
|
||||
'minimalist_happy',
|
||||
'overprotective',
|
||||
'self_sufficient',
|
||||
'unplanned_city_30',
|
||||
'sauna_cold_plunge',
|
||||
'rain_shelter_all',
|
||||
'medicine_ledger_all',
|
||||
'mercury_lifespan',
|
||||
'enemy_enemy_friend',
|
||||
'revolution',
|
||||
'fuel_to_fire',
|
||||
'undo_20',
|
||||
'redo_20',
|
||||
'eternal_history_generation_10',
|
||||
'king_full_satisfaction',
|
||||
'slave_zero_satisfaction',
|
||||
'town_doctor_50',
|
||||
'poke_plushie_fling',
|
||||
'chaos_seeker_666_fights',
|
||||
'clean_freak_robot_only',
|
||||
'true_tarinai_observer',
|
||||
'sniper_333_shots',
|
||||
'megalopolis',
|
||||
'fertility_seeker_721_love_births',
|
||||
'low_fps_single_digit',
|
||||
'continuous_play_24_hours',
|
||||
'sandbox_five_toilets',
|
||||
'park_ground_changed',
|
||||
'ground_change_4_in_1_second',
|
||||
'ant_nest_without_tarinai',
|
||||
'sticky_bomb_15_passes',
|
||||
'daily_play_7_days',
|
||||
'wire_shock_7_tarinai',
|
||||
'information_industry',
|
||||
'strength_in_numbers',
|
||||
'elite_few',
|
||||
'zunchi_overflow',
|
||||
'comfortable_beds',
|
||||
'stone_pillow',
|
||||
'across_seasons',
|
||||
'favorite_one',
|
||||
'statistician',
|
||||
'well_informed',
|
||||
'lively_making',
|
||||
'memento_mori',
|
||||
];
|
||||
const DATA_DIR_NAME = '.achievement_data';
|
||||
const DATA_FILE_NAME = 'state.json';
|
||||
const LOCK_FILE_NAME = 'state.lock';
|
||||
const STATE_SCHEMA_VERSION = 3;
|
||||
const MAX_REQUEST_BYTES = 131072;
|
||||
|
||||
function respond(array $payload, int $status = 200): never {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function valid_player_id(string $value): bool {
|
||||
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $value) === 1;
|
||||
}
|
||||
|
||||
function empty_state(): array {
|
||||
return ['players' => [], 'unlocks' => []];
|
||||
}
|
||||
|
||||
function compact_player_id(string $value): string {
|
||||
$hex = str_replace('-', '', strtolower($value));
|
||||
if (strlen($hex) !== 32 || !ctype_xdigit($hex)) return '';
|
||||
$binary = hex2bin($hex);
|
||||
if ($binary === false) return '';
|
||||
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function expand_player_id(string $value): string {
|
||||
if (strlen($value) !== 22 || preg_match('/^[A-Za-z0-9_-]{22}$/', $value) !== 1) return '';
|
||||
$binary = base64_decode(strtr($value, '-_', '+/') . '==', true);
|
||||
if ($binary === false || strlen($binary) !== 16) return '';
|
||||
$hex = bin2hex($binary);
|
||||
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4) . '-' . substr($hex, 16, 4) . '-' . substr($hex, 20, 12);
|
||||
}
|
||||
|
||||
function detect_state_format(mixed $value): string {
|
||||
if (!is_array($value)) return 'invalid';
|
||||
if ($value === []) return 'empty';
|
||||
if (array_is_list($value)) {
|
||||
return (($value[0] ?? null) === STATE_SCHEMA_VERSION
|
||||
&& isset($value[1], $value[2], $value[3], $value[4])
|
||||
&& is_array($value[2]) && is_array($value[3]) && is_array($value[4]))
|
||||
? 'v3' : 'invalid';
|
||||
}
|
||||
if (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) {
|
||||
return 'v2';
|
||||
}
|
||||
if (array_key_exists('players', $value) || array_key_exists('unlocks', $value)) return 'verbose';
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
function normalize_state(mixed $value): array {
|
||||
if (!is_array($value)) return empty_state();
|
||||
$players = [];
|
||||
$unlocks = [];
|
||||
|
||||
// v3 positional form: [version, baseTime, versions, players, unlocks].
|
||||
if (($value[0] ?? null) === STATE_SCHEMA_VERSION && isset($value[1], $value[2], $value[3], $value[4])
|
||||
&& is_array($value[2]) && is_array($value[3]) && is_array($value[4])) {
|
||||
$baseTime = max(1, (int)$value[1]);
|
||||
$versions = array_map(static fn($entry): string => substr((string)$entry, 0, 32), $value[2]);
|
||||
$playerIds = [];
|
||||
foreach ($value[3] as $row) {
|
||||
if (!is_array($row) || !isset($row[0]) || !is_string($row[0])) continue;
|
||||
$id = expand_player_id($row[0]);
|
||||
if (!valid_player_id($id)) continue;
|
||||
$firstSeen = $baseTime + max(0, (int)($row[1] ?? 0));
|
||||
$lastSeen = $firstSeen + max(0, (int)($row[2] ?? 0));
|
||||
$versionIndex = max(0, (int)($row[3] ?? 0));
|
||||
$playerIds[] = $id;
|
||||
$players[$id] = [
|
||||
'firstSeen' => max(1, $firstSeen),
|
||||
'lastSeen' => max(1, $lastSeen),
|
||||
'gameVersion' => $versions[$versionIndex] ?? '',
|
||||
];
|
||||
}
|
||||
foreach ($value[4] as $row) {
|
||||
if (!is_array($row) || count($row) < 3) continue;
|
||||
$achievementIndex = (int)$row[0];
|
||||
$achievementId = ACHIEVEMENT_IDS[$achievementIndex] ?? '';
|
||||
if ($achievementId === '') continue;
|
||||
$records = [];
|
||||
$count = count($row);
|
||||
for ($i = 1; $i + 1 < $count; $i += 2) {
|
||||
$playerIndex = (int)$row[$i];
|
||||
if (!isset($playerIds[$playerIndex])) continue;
|
||||
$records[$playerIds[$playerIndex]] = $baseTime + max(0, (int)$row[$i + 1]);
|
||||
}
|
||||
if ($records) $unlocks[$achievementId] = $records;
|
||||
}
|
||||
// v2 is an on-read migration source and is always rewritten as v3.
|
||||
} elseif (($value['v'] ?? null) === 2 && isset($value['p'], $value['u']) && is_array($value['p']) && is_array($value['u'])) {
|
||||
$playerIds = [];
|
||||
foreach ($value['p'] as $row) {
|
||||
if (!is_array($row) || !isset($row[0]) || !is_string($row[0]) || !valid_player_id($row[0])) continue;
|
||||
$id = strtolower($row[0]);
|
||||
$playerIds[] = $id;
|
||||
$players[$id] = [
|
||||
'firstSeen' => max(1, (int)($row[1] ?? 1)),
|
||||
'lastSeen' => max(1, (int)($row[2] ?? ($row[1] ?? 1))),
|
||||
'gameVersion' => substr((string)($row[3] ?? ''), 0, 32),
|
||||
];
|
||||
}
|
||||
foreach ($value['u'] as $achievementId => $flat) {
|
||||
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($flat)) continue;
|
||||
$records = [];
|
||||
$count = count($flat);
|
||||
for ($i = 0; $i + 1 < $count; $i += 2) {
|
||||
$playerIndex = (int)$flat[$i];
|
||||
if (!isset($playerIds[$playerIndex])) continue;
|
||||
$records[$playerIds[$playerIndex]] = max(1, (int)$flat[$i + 1]);
|
||||
}
|
||||
if ($records) $unlocks[$achievementId] = $records;
|
||||
}
|
||||
} else {
|
||||
$rawPlayers = isset($value['players']) && is_array($value['players']) ? $value['players'] : [];
|
||||
foreach ($rawPlayers as $id => $player) {
|
||||
if (!is_string($id) || !valid_player_id($id) || !is_array($player)) continue;
|
||||
$players[strtolower($id)] = [
|
||||
'firstSeen' => max(1, (int)($player['firstSeen'] ?? 1)),
|
||||
'lastSeen' => max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1))),
|
||||
'gameVersion' => substr((string)($player['gameVersion'] ?? ''), 0, 32),
|
||||
];
|
||||
}
|
||||
$rawUnlocks = isset($value['unlocks']) && is_array($value['unlocks']) ? $value['unlocks'] : [];
|
||||
foreach ($rawUnlocks as $achievementId => $records) {
|
||||
if (!is_string($achievementId) || (!in_array($achievementId, ACHIEVEMENT_IDS, true) && !in_array($achievementId, ['signal_automation_first', 'link_craftsman'], true)) || !is_array($records)) continue;
|
||||
foreach ($records as $id => $timestamp) {
|
||||
$normalizedId = strtolower((string)$id);
|
||||
if (!isset($players[$normalizedId])) continue;
|
||||
$unlocks[$achievementId][$normalizedId] = max(1, (int)$timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$legacySignal = isset($unlocks['signal_automation_first']) && is_array($unlocks['signal_automation_first'])
|
||||
? $unlocks['signal_automation_first'] : [];
|
||||
$legacyLinks = isset($unlocks['link_craftsman']) && is_array($unlocks['link_craftsman'])
|
||||
? $unlocks['link_craftsman'] : [];
|
||||
if ($legacySignal && $legacyLinks) {
|
||||
if (!isset($unlocks['mechanized_industry']) || !is_array($unlocks['mechanized_industry'])) {
|
||||
$unlocks['mechanized_industry'] = [];
|
||||
}
|
||||
foreach ($legacySignal as $playerId => $signalTime) {
|
||||
if (!isset($legacyLinks[$playerId])) continue;
|
||||
$unlocks['mechanized_industry'][$playerId] = max((int)$signalTime, (int)$legacyLinks[$playerId]);
|
||||
}
|
||||
}
|
||||
unset($unlocks['signal_automation_first'], $unlocks['link_craftsman']);
|
||||
|
||||
return ['players' => $players, 'unlocks' => $unlocks];
|
||||
}
|
||||
|
||||
function compact_state(array $state): array {
|
||||
$playerIds = array_keys($state['players']);
|
||||
sort($playerIds, SORT_STRING);
|
||||
$playerIndex = [];
|
||||
$timestamps = [];
|
||||
$versionCounts = [];
|
||||
foreach ($playerIds as $id) {
|
||||
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
||||
$timestamps[] = max(1, (int)($player['firstSeen'] ?? 1));
|
||||
$timestamps[] = max(1, (int)($player['lastSeen'] ?? ($player['firstSeen'] ?? 1)));
|
||||
$version = substr((string)($player['gameVersion'] ?? ''), 0, 32);
|
||||
$versionCounts[$version] = ($versionCounts[$version] ?? 0) + 1;
|
||||
}
|
||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
||||
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true) || !is_array($records)) continue;
|
||||
foreach ($records as $timestamp) $timestamps[] = max(1, (int)$timestamp);
|
||||
}
|
||||
$baseTime = $timestamps ? min($timestamps) : 1;
|
||||
uksort($versionCounts, static function (string $a, string $b) use ($versionCounts): int {
|
||||
$countOrder = ($versionCounts[$b] ?? 0) <=> ($versionCounts[$a] ?? 0);
|
||||
return $countOrder !== 0 ? $countOrder : strcmp($a, $b);
|
||||
});
|
||||
$versions = array_keys($versionCounts);
|
||||
if (!$versions) $versions = [''];
|
||||
$versionIndex = array_flip($versions);
|
||||
|
||||
$players = [];
|
||||
foreach ($playerIds as $index => $id) {
|
||||
$playerIndex[$id] = $index;
|
||||
$player = is_array($state['players'][$id] ?? null) ? $state['players'][$id] : [];
|
||||
$firstSeen = max(1, (int)($player['firstSeen'] ?? 1));
|
||||
$lastSeen = max($firstSeen, (int)($player['lastSeen'] ?? $firstSeen));
|
||||
$version = substr((string)($player['gameVersion'] ?? ''), 0, 32);
|
||||
$row = [compact_player_id($id), $firstSeen - $baseTime, $lastSeen - $firstSeen];
|
||||
$indexValue = (int)($versionIndex[$version] ?? 0);
|
||||
if ($indexValue !== 0) $row[] = $indexValue;
|
||||
$players[] = $row;
|
||||
}
|
||||
|
||||
$unlocks = [];
|
||||
foreach (ACHIEVEMENT_IDS as $achievementIndex => $achievementId) {
|
||||
$records = $state['unlocks'][$achievementId] ?? null;
|
||||
if (!is_array($records) || !$records) continue;
|
||||
$pairs = [];
|
||||
foreach ($records as $id => $timestamp) {
|
||||
if (!isset($playerIndex[$id])) continue;
|
||||
$pairs[] = [$playerIndex[$id], max(1, (int)$timestamp) - $baseTime];
|
||||
}
|
||||
usort($pairs, static fn(array $a, array $b): int => $a[0] <=> $b[0]);
|
||||
if (!$pairs) continue;
|
||||
$row = [$achievementIndex];
|
||||
foreach ($pairs as [$indexValue, $timestampDelta]) {
|
||||
$row[] = $indexValue;
|
||||
$row[] = max(0, $timestampDelta);
|
||||
}
|
||||
$unlocks[] = $row;
|
||||
}
|
||||
return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];
|
||||
}
|
||||
|
||||
function encode_compact_state(array $state): string {
|
||||
try {
|
||||
return json_encode(compact_state($state), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $error) {
|
||||
throw new RuntimeException('storage_encode_failed', 0, $error);
|
||||
}
|
||||
}
|
||||
|
||||
function decode_stored_state(string $contents): array {
|
||||
if (trim($contents) === '') return [empty_state(), 'empty'];
|
||||
try {
|
||||
$decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $error) {
|
||||
throw new RuntimeException('storage_corrupt', 0, $error);
|
||||
}
|
||||
$format = detect_state_format($decoded);
|
||||
if ($format === 'invalid') throw new RuntimeException('storage_corrupt');
|
||||
return [normalize_state($decoded), $format];
|
||||
}
|
||||
|
||||
function atomic_write_state(string $dataPath, string $encoded): void {
|
||||
$dataDir = dirname($dataPath);
|
||||
$tempPath = tempnam($dataDir, DATA_FILE_NAME . '.tmp.');
|
||||
if ($tempPath === false) throw new RuntimeException('storage_unavailable');
|
||||
$handle = @fopen($tempPath, 'wb');
|
||||
if ($handle === false) {
|
||||
@unlink($tempPath);
|
||||
throw new RuntimeException('storage_unavailable');
|
||||
}
|
||||
try {
|
||||
$length = strlen($encoded);
|
||||
$offset = 0;
|
||||
while ($offset < $length) {
|
||||
$written = fwrite($handle, substr($encoded, $offset));
|
||||
if ($written === false || $written === 0) throw new RuntimeException('storage_write_failed');
|
||||
$offset += $written;
|
||||
}
|
||||
if (!fflush($handle)) throw new RuntimeException('storage_write_failed');
|
||||
if (function_exists('fsync') && !fsync($handle)) throw new RuntimeException('storage_write_failed');
|
||||
} catch (Throwable $error) {
|
||||
fclose($handle);
|
||||
@unlink($tempPath);
|
||||
throw $error;
|
||||
}
|
||||
fclose($handle);
|
||||
@chmod($tempPath, 0664);
|
||||
if (!@rename($tempPath, $dataPath)) {
|
||||
@unlink($tempPath);
|
||||
throw new RuntimeException('storage_write_failed');
|
||||
}
|
||||
}
|
||||
|
||||
function build_summary(array $state): array {
|
||||
$total = count($state['players']);
|
||||
$achievements = [];
|
||||
foreach (ACHIEVEMENT_IDS as $id) {
|
||||
$unlocked = isset($state['unlocks'][$id]) && is_array($state['unlocks'][$id])
|
||||
? count($state['unlocks'][$id])
|
||||
: 0;
|
||||
$percentage = $total > 0 ? round(($unlocked / $total) * 100, 1) : 0.0;
|
||||
$achievements[$id] = [
|
||||
'unlockedPlayers' => $unlocked,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'totalPlayers' => $total,
|
||||
'achievements' => $achievements,
|
||||
];
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
if (!in_array($method, ['GET', 'POST'], true)) respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
|
||||
$payload = [];
|
||||
if ($method === 'POST') {
|
||||
$declaredLength = max(0, (int)($_SERVER['CONTENT_LENGTH'] ?? 0));
|
||||
if ($declaredLength > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413);
|
||||
$raw = file_get_contents('php://input', false, null, 0, MAX_REQUEST_BYTES + 1);
|
||||
if (!is_string($raw) || strlen($raw) > MAX_REQUEST_BYTES) respond(['ok' => false, 'error' => 'request_too_large'], 413);
|
||||
try {
|
||||
$decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
if (!is_array($decoded)) respond(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
$payload = $decoded;
|
||||
}
|
||||
|
||||
$action = $method === 'POST'
|
||||
? (string)($payload['action'] ?? '')
|
||||
: (string)($_GET['action'] ?? 'summary');
|
||||
$playerId = $method === 'POST'
|
||||
? (string)($payload['playerId'] ?? '')
|
||||
: (string)($_GET['playerId'] ?? '');
|
||||
|
||||
if (!in_array($action, ['session', 'unlock', 'sync', 'reset', 'summary'], true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_action'], 400);
|
||||
}
|
||||
if ($method === 'GET' && $action !== 'summary') {
|
||||
respond(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
if (!valid_player_id($playerId)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_player'], 400);
|
||||
}
|
||||
$playerId = strtolower($playerId);
|
||||
$syncUnlocks = [];
|
||||
$completionistEligible = null;
|
||||
if ($action === 'unlock') {
|
||||
$achievementId = (string)($payload['achievementId'] ?? '');
|
||||
if (!in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
||||
}
|
||||
} elseif ($action === 'sync') {
|
||||
$submittedUnlocks = $payload['unlocked'] ?? null;
|
||||
if (!is_array($submittedUnlocks) || count($submittedUnlocks) > count(ACHIEVEMENT_IDS)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_unlocks'], 400);
|
||||
}
|
||||
if (array_key_exists('completionistEligible', $payload)) {
|
||||
if (!is_bool($payload['completionistEligible'])) {
|
||||
respond(['ok' => false, 'error' => 'invalid_completionist_state'], 400);
|
||||
}
|
||||
$completionistEligible = $payload['completionistEligible'];
|
||||
}
|
||||
foreach ($submittedUnlocks as $achievementId => $timestamp) {
|
||||
if (!is_string($achievementId) || !in_array($achievementId, ACHIEVEMENT_IDS, true)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_achievement'], 400);
|
||||
}
|
||||
if (!is_int($timestamp) && !is_float($timestamp) && !is_string($timestamp)) {
|
||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||
}
|
||||
$numericTimestamp = filter_var($timestamp, FILTER_VALIDATE_FLOAT);
|
||||
if ($numericTimestamp === false || !is_finite((float)$numericTimestamp) || $numericTimestamp <= 0) {
|
||||
respond(['ok' => false, 'error' => 'invalid_timestamp'], 400);
|
||||
}
|
||||
$syncUnlocks[$achievementId] = (float)$numericTimestamp;
|
||||
}
|
||||
if ($completionistEligible === false) unset($syncUnlocks['true_tarinai_observer']);
|
||||
}
|
||||
|
||||
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . DATA_DIR_NAME;
|
||||
if (!is_dir($dataDir) && !mkdir($dataDir, 0775, true) && !is_dir($dataDir)) {
|
||||
respond(['ok' => false, 'error' => 'storage_unavailable'], 503);
|
||||
}
|
||||
$dataPath = $dataDir . DIRECTORY_SEPARATOR . DATA_FILE_NAME;
|
||||
$lockPath = $dataDir . DIRECTORY_SEPARATOR . LOCK_FILE_NAME;
|
||||
$lockHandle = @fopen($lockPath, 'c');
|
||||
if ($lockHandle === false || !flock($lockHandle, LOCK_EX)) {
|
||||
if (is_resource($lockHandle)) fclose($lockHandle);
|
||||
respond(['ok' => false, 'error' => 'storage_locked'], 503);
|
||||
}
|
||||
|
||||
$summary = null;
|
||||
$storageError = null;
|
||||
try {
|
||||
$contents = is_file($dataPath) ? file_get_contents($dataPath) : '';
|
||||
if ($contents === false) throw new RuntimeException('storage_unavailable');
|
||||
[$state, $sourceFormat] = decode_stored_state($contents);
|
||||
$now = time();
|
||||
$mutatesPlayer = $action !== 'summary';
|
||||
|
||||
if ($mutatesPlayer) {
|
||||
$player = isset($state['players'][$playerId]) && is_array($state['players'][$playerId])
|
||||
? $state['players'][$playerId]
|
||||
: ['firstSeen' => $now];
|
||||
$player['lastSeen'] = $now;
|
||||
$submittedGameVersion = substr((string)($payload['gameVersion'] ?? ''), 0, 32);
|
||||
if ($submittedGameVersion !== '' || !isset($player['gameVersion'])) $player['gameVersion'] = $submittedGameVersion;
|
||||
$state['players'][$playerId] = $player;
|
||||
}
|
||||
|
||||
if ($action === 'unlock') {
|
||||
$achievementId = (string)$payload['achievementId'];
|
||||
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
||||
$state['unlocks'][$achievementId] = [];
|
||||
}
|
||||
if (!isset($state['unlocks'][$achievementId][$playerId])) {
|
||||
$state['unlocks'][$achievementId][$playerId] = $now;
|
||||
}
|
||||
} elseif ($action === 'sync') {
|
||||
if ($completionistEligible === false && isset($state['unlocks']['true_tarinai_observer'][$playerId])) {
|
||||
unset($state['unlocks']['true_tarinai_observer'][$playerId]);
|
||||
}
|
||||
foreach ($syncUnlocks as $achievementId => $submittedTimestamp) {
|
||||
if (!isset($state['unlocks'][$achievementId]) || !is_array($state['unlocks'][$achievementId])) {
|
||||
$state['unlocks'][$achievementId] = [];
|
||||
}
|
||||
if (isset($state['unlocks'][$achievementId][$playerId])) continue;
|
||||
$timestampSeconds = $submittedTimestamp >= 100000000000
|
||||
? (int)floor($submittedTimestamp / 1000)
|
||||
: (int)floor($submittedTimestamp);
|
||||
$state['unlocks'][$achievementId][$playerId] = max(1, min($now, $timestampSeconds));
|
||||
}
|
||||
} elseif ($action === 'reset') {
|
||||
foreach ($state['unlocks'] as $achievementId => $records) {
|
||||
if (is_array($records)) unset($state['unlocks'][$achievementId][$playerId]);
|
||||
}
|
||||
}
|
||||
|
||||
$encoded = encode_compact_state($state);
|
||||
if (trim($contents) !== $encoded || $sourceFormat !== 'v3' || $mutatesPlayer) {
|
||||
atomic_write_state($dataPath, $encoded);
|
||||
}
|
||||
$summary = build_summary($state);
|
||||
} catch (RuntimeException $error) {
|
||||
$known = ['storage_corrupt', 'storage_unavailable', 'storage_write_failed', 'storage_encode_failed'];
|
||||
$storageError = in_array($error->getMessage(), $known, true) ? $error->getMessage() : 'storage_unavailable';
|
||||
} finally {
|
||||
flock($lockHandle, LOCK_UN);
|
||||
fclose($lockHandle);
|
||||
}
|
||||
|
||||
if ($storageError !== null) respond(['ok' => false, 'error' => $storageError], 503);
|
||||
respond(is_array($summary) ? $summary : ['ok' => false, 'error' => 'storage_unavailable'], is_array($summary) ? 200 : 503);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "39.16.18",
|
||||
"version": "39.16.98",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
"js/constraint_system.js",
|
||||
"js/physics_world_system.js",
|
||||
"js/physics_projection_system.js",
|
||||
"js/circuit_board_system.js",
|
||||
"js/signal_system.js",
|
||||
"js/assets.js",
|
||||
"js/audio.js",
|
||||
|
|
@ -88,9 +89,11 @@
|
|||
"js/tarinai_consumable_behavior.js",
|
||||
"js/tarinai_social_action_runtime.js",
|
||||
"js/tarinai_building_behavior.js",
|
||||
"js/seesaw_system.js",
|
||||
"js/tarinai_action_definitions.js",
|
||||
"js/tarinai_needs_items.js",
|
||||
"js/tarinai_food_prototype_mixin.js",
|
||||
"js/tarinai_direct_feeding_system.js",
|
||||
"js/tarinai_need_planner_system.js",
|
||||
"js/tarinai_item_interaction_context.js",
|
||||
"js/tarinai_food_interaction_system.js",
|
||||
|
|
@ -144,8 +147,10 @@
|
|||
"js/command_dispatcher.js",
|
||||
"js/text_catalog.js",
|
||||
"js/ui.js",
|
||||
"js/ui_log.js",
|
||||
"js/game_dialogs.js",
|
||||
"js/achievements.js",
|
||||
"js/ui_helpers.js",
|
||||
"js/ui_log.js",
|
||||
"js/ui_selected.js",
|
||||
"js/save_schema.js",
|
||||
"js/snapshot_system.js",
|
||||
|
|
|
|||
297
css/base.css
297
css/base.css
|
|
@ -325,13 +325,13 @@ h1 {
|
|||
--tool-watermark-size: 0px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 29px minmax(0,1fr);
|
||||
grid-template-columns: 31px minmax(0,1fr);
|
||||
align-items: center;
|
||||
justify-items: stretch;
|
||||
gap: 3px;
|
||||
padding: 4px 4px 4px 2px;
|
||||
gap: 2px;
|
||||
padding: 3px 3px 3px 2px;
|
||||
min-height: 47px;
|
||||
font-size: 10.2px;
|
||||
font-size: 11.4px;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.015em;
|
||||
text-align: left;
|
||||
|
|
@ -374,16 +374,16 @@ h1 {
|
|||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 10.2px;
|
||||
line-height: 1.10;
|
||||
font-size: 11.4px;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
max-width: 5.05em;
|
||||
max-width: 5.35em;
|
||||
word-break: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 29px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
|
||||
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-columns: 31px minmax(0,1fr); min-height: 47px; padding-top: 4px; }
|
||||
|
||||
.tool.tool-has-canvas-icon::before,
|
||||
.tool.tool-has-canvas-icon::after { background: none !important; }
|
||||
|
|
@ -401,6 +401,7 @@ h1 {
|
|||
.tool[data-tool="giant_drug"] .tool-sprite-preview { width: 39px; height: 39px; left: -4px; }
|
||||
.tool[data-tool="dwarf_drug"] .tool-sprite-preview,
|
||||
.tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; }
|
||||
.tool.tool-has-physics-icon .tool-physics-preview { width: 31px; height: 31px; left: 0; filter: drop-shadow(0 1px 2px rgba(52,40,26,0.16)); }
|
||||
.tool[data-tool="poke"], .tool[data-tool="water_hose"] { --tool-icon-zoom: 0.92; }
|
||||
|
||||
.signboard-editor {
|
||||
|
|
@ -620,6 +621,164 @@ h1 {
|
|||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
|
||||
.pressure-numeric-control[hidden] { display: none; }
|
||||
.pressure-numeric-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 8px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(84,68,48,0.13);
|
||||
background: rgba(255,255,255,0.58);
|
||||
}
|
||||
.pressure-value-slider {
|
||||
width: calc(100% - 14px);
|
||||
height: 34px;
|
||||
margin: 0 7px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
.pressure-value-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(67,158,218,0.78);
|
||||
box-shadow: inset 0 1px 2px rgba(43,55,66,0.16);
|
||||
}
|
||||
.pressure-value-slider::-webkit-slider-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -7px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-value-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(67,158,218,0.78);
|
||||
box-shadow: inset 0 1px 2px rgba(43,55,66,0.16);
|
||||
}
|
||||
.pressure-value-slider::-moz-range-thumb {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-value-slider:focus-visible::-webkit-slider-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pressure-value-slider:focus-visible::-moz-range-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pressure-time-control[hidden] { display: none; }
|
||||
.pressure-spatial-control[hidden] { display: none; }
|
||||
.pressure-time-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 8px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(84,68,48,0.13);
|
||||
background: rgba(255,255,255,0.58);
|
||||
}
|
||||
.pressure-time-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pressure-time-summary output {
|
||||
display: inline-block;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pressure-time-range {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
margin: 0 7px;
|
||||
}
|
||||
.pressure-time-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(94,112,128,0.22);
|
||||
box-shadow: inset 0 1px 2px rgba(43,55,66,0.16);
|
||||
}
|
||||
.pressure-time-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pressure-time-thumb::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.pressure-time-thumb::-webkit-slider-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -7px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-time-thumb::-moz-range-track {
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.pressure-time-thumb::-moz-range-thumb {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(42,121,172,0.88);
|
||||
background: #fafdff;
|
||||
box-shadow: 0 2px 6px rgba(42,78,104,0.24);
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
.pressure-time-thumb:focus-visible::-webkit-slider-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pressure-time-thumb:focus-visible::-moz-range-thumb {
|
||||
outline: 3px solid rgba(73,157,216,0.30);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pressure-time-thumb-start { z-index: 2; }
|
||||
.pressure-time-thumb-end { z-index: 3; }
|
||||
.pressure-time-range[data-wraps="1"] .pressure-time-thumb-start { z-index: 4; }
|
||||
.signal-channel-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
|
|
@ -758,3 +917,125 @@ h1 {
|
|||
.save-confirm-dialog { z-index: 36; }
|
||||
.save-confirm-panel { width: min(390px, 100%); }
|
||||
.save-confirm-message { margin-bottom: 8px; color: var(--ink); font-size: 14px; }
|
||||
|
||||
/* Logic-board editor */
|
||||
.circuit-board-editor{position:fixed;inset:0;z-index:1200;display:grid;place-items:center;padding:18px;background:rgba(18,22,27,.58);backdrop-filter:blur(3px)}
|
||||
.circuit-board-editor.hidden{display:none}
|
||||
.circuit-board-panel{width:min(980px,96vw);max-height:92vh;overflow:hidden;background:rgba(250,249,244,.98);color:#2e3531;border:1px solid rgba(44,74,57,.35);border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.35);display:flex;flex-direction:column}
|
||||
.circuit-board-titlebar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:linear-gradient(135deg,#235b3a,#347b50);color:white}
|
||||
.circuit-board-titlebar h2{margin:0;font-size:1.18rem}
|
||||
.circuit-board-titlebar button{border:0;background:transparent;color:white;font-size:1.7rem;line-height:1;cursor:pointer}
|
||||
.circuit-board-help{margin:12px 18px 6px;font-size:.88rem;color:#536059}
|
||||
.circuit-board-layout{display:grid;grid-template-columns:minmax(280px,42%) minmax(360px,58%);gap:14px;padding:10px 18px;min-height:0;flex:1}
|
||||
.circuit-board-preview{align-self:start;background:#17231c;border-radius:14px;padding:10px;min-height:250px;position:sticky;top:0}
|
||||
.circuit-board-preview svg{width:100%;height:auto;display:block}
|
||||
.circuit-preview-lines path{fill:none;stroke:#d7b84d;stroke-width:2.4}
|
||||
.circuit-preview-gates rect{fill:#26352d;stroke:#d9e9dd;stroke-width:1.3}.circuit-preview-gates text{fill:#f4f7f2;text-anchor:middle;font:700 11px sans-serif}
|
||||
.circuit-preview-pins circle{fill:#73c8ff;stroke:#f4f1d6;stroke-width:1.2}.circuit-preview-pins circle:nth-of-type(n+4){fill:#ff9a60}.circuit-preview-pins text{fill:#eef4ef;font:700 11px sans-serif}
|
||||
.circuit-board-scroll{overflow:auto;padding-right:4px}.circuit-board-scroll section{margin:0 0 14px;padding:12px;border:1px solid rgba(45,83,60,.18);border-radius:12px;background:white}.circuit-board-scroll h3{margin:0 0 10px;font-size:.94rem}
|
||||
.circuit-port-row,.circuit-gate-row,.circuit-output-row{display:grid;grid-template-columns:minmax(72px,1fr) auto auto auto;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid rgba(0,0,0,.07)}
|
||||
.circuit-port-row:last-child,.circuit-gate-row:last-child,.circuit-output-row:last-child{border-bottom:0}.circuit-port-row label,.circuit-gate-row label{display:flex;align-items:center;gap:4px;font-size:.8rem}
|
||||
.circuit-board-panel select,.circuit-board-panel button{font:inherit}.circuit-board-panel select{padding:5px 7px;border:1px solid rgba(37,77,53,.27);border-radius:7px;background:white}
|
||||
.circuit-gate-actions{display:flex;gap:7px;margin-bottom:8px}.circuit-gate-actions button,.circuit-gate-row button,.circuit-board-actions button{padding:7px 12px;border:1px solid rgba(38,82,54,.26);border-radius:9px;background:#f1f5f1;cursor:pointer}.circuit-gate-row button{padding:4px 9px;color:#a13f3f}
|
||||
.circuit-empty{margin:6px 0;color:#7a837d;font-size:.84rem}.circuit-output-row{grid-template-columns:50px 1fr}.circuit-board-actions{display:flex;justify-content:flex-end;gap:9px;padding:12px 18px 16px;border-top:1px solid rgba(0,0,0,.09)}.circuit-board-actions .primary{background:#2e754b;color:white;border-color:#245f3d}
|
||||
@media(max-width:760px){.circuit-board-layout{grid-template-columns:1fr}.circuit-board-preview{position:relative;min-height:180px}.circuit-port-row,.circuit-gate-row{grid-template-columns:1fr 1fr}.circuit-gate-row button{justify-self:end}.circuit-board-panel{max-height:96vh}}
|
||||
|
||||
/* Grid circuit editor reuses the mechanical editor shell and toolbar. */
|
||||
.circuit-grid-panel { width: min(930px, calc(100vw - 28px)); }
|
||||
.circuit-grid-tools { max-height: none; overflow: visible; }
|
||||
.circuit-grid-canvas-wrap {
|
||||
max-width: 100%;
|
||||
max-height: min(62vh, 560px);
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border-radius: 12px;
|
||||
background: rgba(20,35,27,0.35);
|
||||
}
|
||||
#circuitGridCanvas {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(84,68,48,0.20);
|
||||
background: #14231b;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.circuit-grid-hint { line-height: 1.55; }
|
||||
@media (max-width: 560px) {
|
||||
.circuit-grid-panel { width: calc(100vw - 16px); }
|
||||
.circuit-grid-tools { max-height: 20vh; overflow-y: auto; }
|
||||
.circuit-grid-canvas-wrap { max-height: min(48vh, 360px); }
|
||||
#circuitGridCanvas { max-width: none; }
|
||||
}
|
||||
|
||||
/* v39.16.69: circuit terminal count controls and detector numeric range inputs. */
|
||||
.circuit-port-count-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.circuit-port-count-controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(84,68,48,0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.56);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.circuit-port-count-controls button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(38,82,54,.26);
|
||||
border-radius: 8px;
|
||||
background: #f1f5f1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.circuit-port-count-controls input {
|
||||
width: 54px;
|
||||
min-height: 30px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid rgba(37,77,53,.27);
|
||||
border-radius: 7px;
|
||||
background: white;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pressure-number-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.pressure-number-inputs label {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(72px, 1fr);
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pressure-number-inputs input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(42,121,172,0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.circuit-port-count-controls { display: grid; grid-template-columns: 1fr; }
|
||||
.circuit-port-count-controls label { justify-content: space-between; }
|
||||
}
|
||||
|
||||
/* v39.16.70: resizable circuit board and movable junctions. */
|
||||
.circuit-port-count-controls label:nth-child(-n+2){background:rgba(236,246,239,.82)}
|
||||
|
|
|
|||
|
|
@ -643,129 +643,145 @@
|
|||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.visual-settings-dialog-panel {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
/* v39.16.40: compact header menu and folded colony settings. */
|
||||
.top-menu { position: relative; display: inline-block; }
|
||||
.top-menu-btn {
|
||||
min-width: 40px;
|
||||
padding-inline: 11px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.visual-setting-row {
|
||||
.top-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 14000;
|
||||
width: min(250px, calc(100vw - 20px));
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(84,68,48,.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,252,246,.98);
|
||||
box-shadow: 0 16px 42px rgba(44,34,25,.20);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(115, 91, 62, 0.16);
|
||||
}
|
||||
.visual-setting-row strong,
|
||||
.visual-setting-row span {
|
||||
display: block;
|
||||
}
|
||||
.visual-setting-row strong {
|
||||
color: #4f3928;
|
||||
font-size: 15px;
|
||||
}
|
||||
.visual-setting-row span {
|
||||
margin-top: 3px;
|
||||
color: #6d5a46;
|
||||
font-size: 12px;
|
||||
}
|
||||
.visual-setting-options {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(115, 91, 62, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.50);
|
||||
}
|
||||
.visual-setting-options button {
|
||||
min-width: 46px;
|
||||
min-height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
.top-menu-panel.hidden { display: none; }
|
||||
.top-menu-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: #5d4938;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.visual-setting-options button.active,
|
||||
.visual-setting-options button[aria-pressed="true"] {
|
||||
border-color: rgba(127, 94, 54, 0.28);
|
||||
background: #fff4d8;
|
||||
color: #3f3023;
|
||||
box-shadow: 0 2px 8px rgba(96, 70, 40, 0.12);
|
||||
}
|
||||
.visual-setting-options button:focus-visible {
|
||||
outline: 2px solid rgba(184, 128, 54, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.save-slot-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.save-slot-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.visual-setting-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.visual-setting-options {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.visual-setting-options button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.top-menu-item:hover,
|
||||
.top-menu-item:focus-visible { background: rgba(238,245,225,.88); outline: none; }
|
||||
.top-menu-sound { display: block; width: 100%; }
|
||||
.top-menu-sound .sound-menu-btn { display: block; border: 0; box-shadow: none; }
|
||||
.top-menu-sound .sound-panel {
|
||||
position: static;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 3px 3px 5px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
background: rgba(245,240,229,.78);
|
||||
}
|
||||
|
||||
.ground-stat {
|
||||
padding: 7px 8px;
|
||||
.top-menu-visual { display: block; width: 100%; }
|
||||
.sound-panel-subtitle {
|
||||
padding: 2px 0 1px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.ground-type-btn {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
border: 1px solid rgba(92, 142, 74, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.58);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
|
||||
}
|
||||
.ground-type-btn:hover {
|
||||
background: rgba(255,255,255,0.82);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.ground-type-btn strong {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ground-stat[data-ground-toggle] {
|
||||
.top-menu-visual .sound-panel label { min-height: 26px; }
|
||||
|
||||
.reset-top-btn {
|
||||
color: #8e2424 !important;
|
||||
border-color: rgba(182,69,69,.48) !important;
|
||||
background: linear-gradient(180deg, rgba(255,245,245,.96), rgba(255,224,224,.92)) !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
.reset-top-btn:hover { background: linear-gradient(180deg, #fff4f4, #ffd6d6) !important; }
|
||||
|
||||
|
||||
.colony-settings-body { display: grid; gap: 0; }
|
||||
.colony-setting-section { padding: 10px; }
|
||||
.colony-setting-section + .colony-setting-section { border-top: 1px solid rgba(118,92,61,.12); }
|
||||
.colony-setting-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 7px; color: var(--ink); font-size: 12px; font-weight: 800; }
|
||||
.colony-setting-section-head strong { font-size: 15px; }
|
||||
.colony-setting-section-head small { margin-left: auto; color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
|
||||
.colony-setting-fold {
|
||||
margin: 8px 0 4px;
|
||||
border: 1px solid rgba(118,92,61,.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,251,244,.56);
|
||||
overflow: hidden;
|
||||
}
|
||||
.colony-setting-fold > summary {
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
color: rgba(68,55,41,.84);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ground-stat[data-ground-toggle]:focus-visible,
|
||||
.ground-type-btn:focus-visible {
|
||||
outline: 2px solid rgba(103,178,44,0.58);
|
||||
outline-offset: 2px;
|
||||
.colony-setting-fold > summary::-webkit-details-marker { display: none; }
|
||||
.colony-setting-fold > summary::before { content: "\25b8"; color: var(--muted); width: 12px; }
|
||||
.colony-setting-fold[open] > summary::before { content: "\25be"; }
|
||||
.colony-setting-fold > summary strong { margin-left: auto; font-size: 15px; }
|
||||
.colony-setting-fold > summary small { margin-left: auto; color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.colony-setting-fold[open] > summary { border-bottom: 1px solid rgba(118,92,61,.12); }
|
||||
.colony-setting-fold .temperature-control,
|
||||
.colony-setting-fold .colony-limit-control {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
/* v36: pin only the operation tool category under the quick-scroll tabs. */
|
||||
.colony-setting-fold .temperature-control { display: grid; gap: 5px; }
|
||||
.colony-setting-fold .temperature-auto { margin-left: 0; justify-self: end; }
|
||||
|
||||
/* v39.16.40: one unified colony settings fold. */
|
||||
.colony-settings-fold .temperature-control,
|
||||
.colony-settings-fold .colony-limit-control { padding: 0; border: 0; border-radius: 0; background: transparent; }
|
||||
|
||||
|
||||
/* v39.16.40: restore the compact sticky operation palette from the earlier UI. */
|
||||
.tool-category[data-tool-category="operate"]:not(.collapsed) {
|
||||
position: sticky;
|
||||
top: 58px;
|
||||
z-index: 11;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98));
|
||||
border: 3px solid rgba(52,112,188,0.72);
|
||||
box-shadow: 0 9px 22px rgba(58, 82, 112, 0.18), inset 0 0 0 1px rgba(255,255,255,0.86);
|
||||
background: linear-gradient(180deg, rgba(238,247,255,.98), rgba(221,237,255,.98));
|
||||
border: 3px solid rgba(52,112,188,.72);
|
||||
box-shadow: 0 9px 22px rgba(58,82,112,.18), inset 0 0 0 1px rgba(255,255,255,.86);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-toggle {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
|
||||
border-bottom-color: rgba(82,128,190,0.30);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(231,243,255,.98));
|
||||
border-bottom-color: rgba(82,128,190,.30);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-body {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0,1fr));
|
||||
gap: 5px;
|
||||
background: rgba(238, 247, 255, 0.98);
|
||||
background: rgba(238,247,255,.98);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -774,112 +790,454 @@
|
|||
justify-items: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.96);
|
||||
border-color: rgba(82,128,190,0.28);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-label {
|
||||
display: none;
|
||||
background: rgba(255,255,255,.96);
|
||||
border-color: rgba(82,128,190,.28);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-label { display: none; }
|
||||
.tool-category[data-tool-category="operate"] .tool::before {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1));
|
||||
left: 50%; top: 50%; transform: translate(-50%,-50%) scale(var(--tool-icon-zoom,1));
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-sprite-preview {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
left: 50%; top: 50%; transform: translate(-50%,-50%);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-shortcut {
|
||||
position: absolute; right: 3px; bottom: 2px; min-width: 13px; height: 13px;
|
||||
box-sizing: border-box; padding: 0 3px; border: 1px solid rgba(82,128,190,.26);
|
||||
border-radius: 5px; background: rgba(255,255,255,.88); color: rgba(48,74,112,.86);
|
||||
font: 700 9px/12px system-ui,sans-serif; text-align: center; pointer-events: none;
|
||||
}
|
||||
|
||||
/* A trash can inside a selection square for range delete. */
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2.5px solid #a8444d;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,.40);
|
||||
filter: none;
|
||||
}
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 2px;
|
||||
min-width: 13px;
|
||||
left: 50%;
|
||||
top: 52%;
|
||||
width: 11px;
|
||||
height: 13px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 3px;
|
||||
border: 1px solid rgba(82,128,190,0.26);
|
||||
border-radius: 5px;
|
||||
background: rgba(255,255,255,0.88);
|
||||
color: rgba(48,74,112,0.86);
|
||||
font: 700 9px/12px system-ui, sans-serif;
|
||||
text-align: center;
|
||||
transform: translate(-50%,-50%);
|
||||
border: 2px solid #a8444d;
|
||||
border-top-width: 3px;
|
||||
border-radius: 1px 1px 3px 3px;
|
||||
background: repeating-linear-gradient(90deg, transparent 0 2px, rgba(168,68,77,.75) 2px 3px, transparent 3px 5px);
|
||||
box-shadow: 0 -5px 0 -3px #a8444d;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Collapsed event card must shrink to its header.
|
||||
layout.css gives .log-card a min-height, which left a large blank panel when folded. */
|
||||
.log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; }
|
||||
.log-card.collapsed .panel-card-body { display: none !important; }
|
||||
|
||||
|
||||
.temperature-control {
|
||||
margin: 10px 0 2px;
|
||||
padding: 10px 11px 11px;
|
||||
border: 1px solid rgba(118, 92, 61, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.56);
|
||||
.tool-category[data-tool-category="operate"] .tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
top: 50% !important;
|
||||
transform: translate(-50%,-50%) !important;
|
||||
}
|
||||
.temperature-control-head {
|
||||
|
||||
/* Colony settings use the colony card's own button palette. */
|
||||
.colony-card .colony-setting-fold {
|
||||
border-color: var(--ui-theme-mid);
|
||||
background: var(--ui-theme-soft);
|
||||
}
|
||||
.colony-card .colony-setting-fold > summary {
|
||||
border-color: var(--ui-theme-mid);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.78), var(--ui-theme-soft-2));
|
||||
color: var(--ink);
|
||||
}
|
||||
.colony-card .colony-setting-fold[open] > summary { border-bottom-color: var(--ui-theme-mid); }
|
||||
.colony-card .colony-setting-section + .colony-setting-section { border-top-color: rgba(var(--ui-theme-rgb),.22); }
|
||||
.colony-stats { grid-template-columns: 1fr; }
|
||||
.ground-type-btn.colony-setting-action {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(68, 55, 41, 0.84);
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--ui-theme-mid);
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.76), var(--ui-theme-soft-2));
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.temperature-control-head strong {
|
||||
margin-left: auto;
|
||||
font-size: 15px;
|
||||
.ground-type-btn.colony-setting-action:hover { background: rgba(var(--ui-theme-rgb),.16); }
|
||||
.ground-type-btn.colony-setting-action strong { font-size: 15px; }
|
||||
.ground-type-btn.colony-setting-action span { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* Shared panel-search primitives. */
|
||||
.panel-search-control {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0,1fr) auto 32px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(82,128,190,.20);
|
||||
}
|
||||
.temperature-auto {
|
||||
display: inline-flex;
|
||||
.panel-search-input {
|
||||
width: 100%; min-width: 0; height: 32px; box-sizing: border-box;
|
||||
padding: 6px 8px; border: 1px solid rgba(82,128,190,.34); border-radius: 9px;
|
||||
background: rgba(255,255,255,.94); color: var(--ink); font: 12px/1.2 var(--font,sans-serif);
|
||||
}
|
||||
.panel-search-input:focus { outline: 2px solid rgba(40,112,180,.28); border-color: rgba(40,112,180,.58); }
|
||||
.panel-search-status { min-width: 23px; color: rgba(48,74,112,.86); font: 700 10px/1 system-ui,sans-serif; text-align: center; }
|
||||
.panel-search-next {
|
||||
width: 32px; height: 32px; padding: 0; border: 1px solid rgba(82,128,190,.34); border-radius: 9px;
|
||||
background: rgba(255,255,255,.94); color: #315f91; font: 800 17px/1 system-ui,sans-serif; cursor: pointer;
|
||||
}
|
||||
.panel-search-next:disabled { opacity: .36; cursor: default; }
|
||||
.panel-search-match {
|
||||
position: relative;
|
||||
outline: 3px solid rgba(255,190,38,.72) !important;
|
||||
outline-offset: 1px;
|
||||
background-color: rgba(255,235,135,.62) !important;
|
||||
scroll-margin-block: 150px 80px;
|
||||
}
|
||||
.panel-search-current {
|
||||
outline-color: rgba(225,91,38,.92) !important;
|
||||
box-shadow: 0 0 0 4px rgba(255,160,70,.22) !important;
|
||||
}
|
||||
|
||||
/* v39.16.40: header search uses partial matching and bidirectional navigation. */
|
||||
.top-panel-search {
|
||||
flex: 0 1 270px;
|
||||
width: clamp(190px, 21vw, 270px);
|
||||
min-width: 190px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 1fr) auto 30px 30px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
margin: 0 2px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.top-panel-search .panel-search-input { height: 36px; }
|
||||
.panel-search-prev,
|
||||
.panel-search-next {
|
||||
width: 30px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(82,128,190,.34);
|
||||
border-radius: 9px;
|
||||
background: rgba(255,255,255,.94);
|
||||
color: #315f91;
|
||||
font: 800 16px/1 system-ui,sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-search-prev:disabled,
|
||||
.panel-search-next:disabled { opacity: .36; cursor: default; }
|
||||
|
||||
/* Match the expanded colony settings surface to the colony graph surface. */
|
||||
.colony-card .colony-setting-fold[open] {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54));
|
||||
}
|
||||
|
||||
|
||||
/* Shared in-game confirmations */
|
||||
.game-confirm-dialog { z-index: 16000; }
|
||||
.game-confirm-panel { width: min(460px, 100%); }
|
||||
.game-confirm-message { white-space: pre-line; }
|
||||
.game-confirm-actions { justify-content: flex-end; }
|
||||
.game-confirm-actions .btn { width: auto; }
|
||||
|
||||
/* Achievements */
|
||||
.achievement-top-btn {
|
||||
border-color: rgba(177, 132, 38, .38);
|
||||
color: #72521a;
|
||||
background: linear-gradient(180deg, rgba(255, 251, 224, .96), rgba(247, 229, 153, .88));
|
||||
font-weight: 800;
|
||||
}
|
||||
.achievement-top-btn.complete {
|
||||
border-color: rgba(105, 158, 63, .44);
|
||||
color: #456729;
|
||||
background: linear-gradient(180deg, rgba(245, 255, 229, .98), rgba(213, 239, 173, .92));
|
||||
}
|
||||
.achievement-dialog-panel { width: min(720px, 100%); }
|
||||
.achievement-dialog-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
.achievement-dialog-head h2 { margin-bottom: 5px; }
|
||||
.achievement-dialog-kicker {
|
||||
margin-bottom: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.achievement-close-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(84, 68, 48, .16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .76);
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.achievement-shared-status {
|
||||
margin: 10px 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(104, 86, 61, .12);
|
||||
border-radius: 10px;
|
||||
background: rgba(241, 235, 222, .54);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.achievement-list { display: grid; gap: 10px; }
|
||||
.achievement-group {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(105, 87, 60, .15);
|
||||
border-radius: 13px;
|
||||
background: rgba(247, 243, 234, .72);
|
||||
}
|
||||
.achievement-group-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
color: #554b3e;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.temperature-slider {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
.achievement-group-summary::-webkit-details-marker { display: none; }
|
||||
.achievement-group-summary::before {
|
||||
content: "\25b6";
|
||||
flex: 0 0 auto;
|
||||
color: #8b7657;
|
||||
font-size: 10px;
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
.temperature-slider:disabled {
|
||||
opacity: 0.52;
|
||||
.achievement-group[open] > .achievement-group-summary::before { transform: rotate(90deg); }
|
||||
.achievement-group-summary strong { margin-right: auto; font-size: 13px; }
|
||||
.achievement-group-count {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.colony-limit-control {
|
||||
margin: 8px 0 4px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid rgba(118, 92, 61, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.56);
|
||||
.achievement-group-body {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
padding: 0 9px 9px;
|
||||
}
|
||||
.colony-limit-head {
|
||||
.achievement-group-empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .58);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.achievement-entry {
|
||||
--achievement-rate: 0%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 62px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(105, 87, 60, .15);
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .88), rgba(244, 238, 226, .78));
|
||||
cursor: default;
|
||||
}
|
||||
.achievement-entry:focus-visible {
|
||||
outline: 2px solid rgba(85, 119, 149, .58);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.achievement-entry::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0 auto 0 0;
|
||||
pointer-events: none;
|
||||
width: var(--achievement-rate);
|
||||
background: linear-gradient(90deg, rgba(129, 158, 188, .27), rgba(156, 184, 207, .17));
|
||||
border-right: 1px solid rgba(85, 119, 149, .20);
|
||||
transition: width .32s ease;
|
||||
}
|
||||
.achievement-entry > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.achievement-entry.unlocked {
|
||||
border-color: rgba(173, 129, 35, .38);
|
||||
background: linear-gradient(180deg, rgba(255, 253, 235, .96), rgba(248, 231, 163, .70));
|
||||
}
|
||||
.achievement-entry.unlocked::before {
|
||||
background: linear-gradient(90deg, rgba(229, 179, 51, .34), rgba(244, 211, 102, .22));
|
||||
border-right-color: rgba(173, 129, 35, .28);
|
||||
}
|
||||
.achievement-entry-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: rgba(106, 91, 68, .09);
|
||||
color: #8b7657;
|
||||
font-size: 22px;
|
||||
}
|
||||
.achievement-entry.unlocked .achievement-entry-icon {
|
||||
background: rgba(242, 196, 74, .22);
|
||||
color: #b67b13;
|
||||
}
|
||||
.achievement-entry-copy { min-width: 0; }
|
||||
.achievement-entry-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.achievement-entry-copy strong {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.achievement-entry-description {
|
||||
min-width: 0;
|
||||
color: #665d50;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.achievement-entry.locked .achievement-entry-description {
|
||||
color: #8b8378;
|
||||
font-style: italic;
|
||||
}
|
||||
.achievement-entry-progress {
|
||||
margin-top: 4px;
|
||||
color: #526b82;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.achievement-entry-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: rgba(68, 55, 41, 0.84);
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.colony-limit-head small { color: var(--muted); font-size: 10px; }
|
||||
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 8px; }
|
||||
.colony-limit-row { display: grid; grid-template-columns: minmax(0, 1fr) 54px; gap: 5px 7px; align-items: center; min-width: 0; font-size: 11px; color: var(--muted); }
|
||||
.colony-limit-row span { grid-column: 1 / -1; min-width: 0; }
|
||||
.colony-limit-slider { width: 100%; min-width: 0; accent-color: rgba(84, 138, 72, 0.92); }
|
||||
.colony-limit-number { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 5px 6px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; text-align: right; }
|
||||
.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }
|
||||
.achievement-entry-percentage {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: #5d6570;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.achievement-entry-heading { display: block; }
|
||||
.achievement-entry-description { display: block; margin-top: 2px; }
|
||||
}
|
||||
.achievement-dialog-actions { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.achievement-dialog-actions .btn { width: auto; }
|
||||
.achievement-dialog-actions .achievement-reset-btn { margin-right: auto; }
|
||||
.achievement-toast {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
right: auto;
|
||||
z-index: 14000;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid rgba(184, 137, 37, .42);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 249, 220, .97);
|
||||
color: #57401d;
|
||||
box-shadow: 0 16px 44px rgba(45, 34, 20, .24);
|
||||
transform-origin: center;
|
||||
transition: opacity .18s ease, transform .18s ease, filter .18s ease, visibility .18s ease;
|
||||
}
|
||||
.achievement-toast.absorbing {
|
||||
opacity: 0;
|
||||
transform: translate(var(--achievement-toast-dx, 0px), var(--achievement-toast-dy, 0px)) scale(.08);
|
||||
filter: blur(2px) saturate(1.4);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
transition: opacity .72s ease-in, transform .72s cubic-bezier(.55, .02, .86, .3), filter .72s ease-in, border-radius .72s ease-in;
|
||||
}
|
||||
.achievement-top-btn.achievement-absorb-target {
|
||||
animation: achievement-target-pulse .22s ease-out .54s both;
|
||||
}
|
||||
@keyframes achievement-target-pulse {
|
||||
0% { transform: scale(1); }
|
||||
45% { transform: scale(1.09); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.achievement-toast.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.achievement-toast-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: #efc34c;
|
||||
color: #fff8d8;
|
||||
font-size: 24px;
|
||||
}
|
||||
.achievement-toast-copy { min-width: 0; }
|
||||
.achievement-toast-copy small { display: block; margin-bottom: 2px; color: #8a6b2c; font-size: 10px; font-weight: 800; }
|
||||
.achievement-toast-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
||||
.achievement-toast-condition { display: block; margin-top: 3px; max-width: 360px; color: #6f6248; font-size: 11px; line-height: 1.35; white-space: normal; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.achievement-toast {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 10px);
|
||||
left: 12px;
|
||||
right: auto;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
}
|
||||
.achievement-dialog-actions { flex-direction: column-reverse; }
|
||||
.achievement-dialog-actions .btn { width: 100%; }
|
||||
}
|
||||
|
||||
/* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */
|
||||
.tool-card.collapsed {
|
||||
min-height: 64px !important;
|
||||
height: auto !important;
|
||||
flex: 0 0 auto !important;
|
||||
flex-basis: auto !important;
|
||||
overflow: visible !important;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.achievement-toast.absorbing { transition-duration: .12s; }
|
||||
.achievement-top-btn.achievement-absorb-target { animation: none; }
|
||||
}
|
||||
.tool-card.collapsed .panel-card-toggle {
|
||||
min-height: 46px;
|
||||
}
|
||||
.tool-card.collapsed .panel-card-body {
|
||||
display: none !important;
|
||||
|
||||
/* v39.16.56: keep the upper-limit heading on one line in the compact panel. */
|
||||
.colony-limit-section .colony-setting-section-head > span { font-size: 10.8px; white-space: nowrap; }
|
||||
.colony-limit-section .colony-setting-section-head > small { font-size: 9px; white-space: nowrap; }
|
||||
|
||||
/* v39.16.60: align both upper-limit sliders independently of label length. */
|
||||
.colony-limit-grid { display: grid; gap: 7px; }
|
||||
.colony-limit-row { display: grid; grid-template-columns: 82px minmax(0, 1fr) 58px; align-items: center; gap: 8px; min-width: 0; }
|
||||
.colony-limit-row > span { min-width: 0; font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.colony-limit-slider { width: 100%; min-width: 0; margin: 0; }
|
||||
.colony-limit-number { width: 58px; min-width: 0; box-sizing: border-box; }
|
||||
@media (max-width: 520px) {
|
||||
.colony-limit-row { grid-template-columns: 72px minmax(0, 1fr) 54px; gap: 6px; }
|
||||
.colony-limit-number { width: 54px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@
|
|||
.tool[data-tool="undo"].tool-emoji-icon::before,
|
||||
.tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); }
|
||||
.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; }
|
||||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.20; }
|
||||
.tool[data-tool="area_delete"] { --tool-icon-zoom: 1; }
|
||||
.tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; }
|
||||
.tool[data-tool="area_delete"].tool-emoji-icon::before {
|
||||
font-size: 31px;
|
||||
line-height: 1;
|
||||
color: #5d7fb9;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16);
|
||||
color: #b14f58;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.75), 0 2px 5px rgba(146,54,62,0.14);
|
||||
filter: none;
|
||||
transform: translateY(-54%) scale(var(--tool-icon-zoom, 1));
|
||||
}
|
||||
|
|
@ -114,16 +114,17 @@
|
|||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 3px;
|
||||
min-width: 16px;
|
||||
min-width: 22px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(42,36,29,0.72);
|
||||
color: #fff;
|
||||
font-size: 8.5px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
|
||||
pointer-events: none;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chart-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 3px 0 8px; }
|
||||
.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; }
|
||||
|
|
@ -307,3 +308,42 @@ meter {
|
|||
@media (min-width: 921px) {
|
||||
.mobile-mode-controls { display: none !important; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tool-pick-mode-control.tool-emoji-icon::before {
|
||||
opacity: 0.18;
|
||||
color: #3b78a0;
|
||||
font-size: 29px;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.tool-pick-mode-control {
|
||||
--tool-icon-zoom: 1;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(235,246,255,0.88));
|
||||
border-color: rgba(48,126,176,0.34);
|
||||
}
|
||||
.tool-pick-mode-control .tool-pick-mode-value {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 5px;
|
||||
transform: translateX(-50%);
|
||||
min-width: 38px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
z-index: 4;
|
||||
background: rgba(25,85,128,0.94);
|
||||
color: #fff;
|
||||
font-size: 8.5px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(25,85,128,0.18);
|
||||
}
|
||||
.tool-pick-mode-control[data-pick-mode="grid20"],
|
||||
.tool-pick-mode-control[data-pick-mode="grid40"],
|
||||
.tool-pick-mode-control[data-pick-mode="grid60"] {
|
||||
background: linear-gradient(180deg, #ffffff, #eaf7ff);
|
||||
border-color: rgba(38,132,190,0.56);
|
||||
box-shadow: inset 0 0 0 1px rgba(84,176,226,0.20), 0 8px 20px rgba(59,133,177,0.10);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,3 +397,35 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
|
|||
right: max(10px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* v39.16.40: selected tool descriptions appear at the top on touch devices. */
|
||||
body.touch-mobile-ui .tool-tip-popover.mobile-selection-tip {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px) !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%);
|
||||
width: min(680px, calc(100vw - 18px));
|
||||
max-width: calc(100vw - 18px);
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(46,38,30,.90);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.top-actions { overflow: visible; }
|
||||
.top-menu { flex: 0 0 auto; }
|
||||
.top-menu-panel { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 58px); right: max(10px, env(safe-area-inset-right, 0px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.top-panel-search {
|
||||
flex: 0 0 220px;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.top-panel-search .panel-search-input { height: 34px; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.top-actions { overflow-x: auto; overflow-y: hidden; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,23 +344,35 @@ body::before {
|
|||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.colony-chart-legend span {
|
||||
.colony-chart-legend .chart-series-toggle {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.colony-chart-legend i {
|
||||
.colony-chart-legend .chart-series-toggle:hover { background: rgba(255,255,255,0.56); }
|
||||
.colony-chart-legend .chart-series-toggle:focus-visible { outline: 2px solid rgba(103,178,44,0.58); outline-offset: 1px; }
|
||||
.colony-chart-legend .chart-series-toggle:active { transform: translateY(1px); }
|
||||
.colony-chart-legend .chart-series-toggle i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 10px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10);
|
||||
cursor: pointer;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.colony-chart-legend i:hover,
|
||||
.colony-chart-legend i:focus-visible { transform: scale(1.18); outline: none; }
|
||||
.colony-chart-legend .series-hidden { opacity: 0.42; text-decoration: line-through; }
|
||||
.colony-chart-legend .series-hidden i { background: transparent !important; box-shadow: inset 0 0 0 2px currentColor; }
|
||||
|
||||
|
|
|
|||
515
index.html
515
index.html
|
|
@ -4,14 +4,14 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>たりない観察</title>
|
||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.18" />
|
||||
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.18" />
|
||||
<link rel="shortcut icon" href="favicon.ico?v=39.16.18" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.16.18" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.16.18" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.16.18" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.16.18" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.18" />
|
||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.98" />
|
||||
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.16.98" />
|
||||
<link rel="shortcut icon" href="favicon.ico?v=39.16.98" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.16.98" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.16.98" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.16.98" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.16.98" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.98" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||
|
|
@ -33,23 +33,46 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<div id="panelSearchControl" class="panel-search-control top-panel-search" role="search" aria-label="右メニュー検索">
|
||||
<input id="panelSearchInput" class="panel-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="検索" aria-label="右メニューを検索">
|
||||
<span class="panel-search-status" aria-live="polite">0</span>
|
||||
<button class="panel-search-prev" type="button" disabled aria-label="前の該当位置へ移動">↑</button>
|
||||
<button class="panel-search-next" type="button" disabled aria-label="次の該当位置へ移動">↓</button>
|
||||
</div>
|
||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||
<button id="speedBtn" class="btn">速度 x1</button>
|
||||
<button id="ecologyBtn" class="btn">たりないの生態</button>
|
||||
<div id="soundMenu" class="sound-menu">
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="音">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="achievementsBtn" class="btn achievement-top-btn" type="button" aria-haspopup="dialog" aria-controls="achievementsDialog">実績 <span id="achievementButtonCount">0/75</span></button>
|
||||
<button id="resetBtn" class="btn danger reset-top-btn">リセット</button>
|
||||
<div id="topMenu" class="top-menu">
|
||||
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="メニュー">☰</button>
|
||||
<div id="topMenuPanel" class="top-menu-panel hidden" role="menu" aria-label="メニュー">
|
||||
<button id="ecologyBtn" class="top-menu-item" type="button" role="menuitem">生態</button>
|
||||
<div id="soundMenu" class="sound-menu top-menu-sound">
|
||||
<button id="soundBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="音">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="visualSettingsMenu" class="sound-menu top-menu-sound top-menu-visual">
|
||||
<button id="visualSettingsBtn" class="top-menu-item sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">表示設定</button>
|
||||
<div id="visualSettingsPanel" class="sound-panel hidden" role="menu" aria-label="表示設定">
|
||||
<label><input id="visualEffectsToggle" type="checkbox" data-visual-setting="effects" data-visual-on="high" data-visual-off="off"><span>エフェクト</span></label>
|
||||
<label><input id="visualShadowsToggle" type="checkbox" data-visual-setting="shadows" data-visual-on="high" data-visual-off="off"><span>影</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<div class="sound-panel-subtitle">描画</div>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="auto"><span>自動</span></label>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="low"><span>低</span></label>
|
||||
<label><input type="radio" name="visualDetails" data-visual-setting="details" data-visual-value="high"><span>高</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="creditsBtn" class="top-menu-item" type="button" role="menuitem">クレジット</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="creditsBtn" class="btn">クレジット</button>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="visualSettingsBtn" class="btn" type="button">表示設定</button>
|
||||
<button id="resetBtn" class="btn danger">リセット</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -98,24 +121,35 @@
|
|||
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">コロニー</span><span class="panel-card-state">折り畳み</span></button>
|
||||
<div class="panel-card-body">
|
||||
<div class="stats colony-stats">
|
||||
<div class="stat"><span>総数</span><strong id="statPop">0</strong></div>
|
||||
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
|
||||
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
|
||||
<div class="stat ground-stat" data-ground-toggle role="button" tabindex="0"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong></button></div>
|
||||
</div>
|
||||
<div class="temperature-control" aria-label="気温調整">
|
||||
<div class="temperature-control-head"><span>気温</span><strong id="temperatureValue">10℃</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> 自動</label></div>
|
||||
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
|
||||
</div>
|
||||
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
||||
<div class="colony-limit-head"><span>上限設定</span><small>0は上限なし</small></div>
|
||||
<div class="colony-limit-grid">
|
||||
<label class="colony-limit-row"><span>たりない個体数</span><input id="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="0" max="300" step="1" value="0"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="0" max="999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
<label class="colony-limit-row"><span>オブジェクト総数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="0" max="1200" step="1" value="0"><input id="objectLimitInput" class="colony-limit-number" type="number" min="0" max="9999" step="1" inputmode="numeric" placeholder="0"></label>
|
||||
<details class="colony-setting-fold colony-settings-fold">
|
||||
<summary><span>コロニー設定</span></summary>
|
||||
<div class="colony-settings-body">
|
||||
<section class="colony-setting-section ground-setting-section">
|
||||
<div class="colony-setting-section-head"><span>地面</span></div>
|
||||
<button id="groundTypeBtn" class="ground-type-btn colony-setting-action" type="button" data-ground-toggle aria-label="地面をきりかえる"><strong id="statGroundType">砂</strong><span>切り替え</span></button>
|
||||
</section>
|
||||
<section class="colony-setting-section temperature-section">
|
||||
<div class="colony-setting-section-head"><span>気温</span><strong id="temperatureValue">10℃</strong></div>
|
||||
<div class="temperature-control" aria-label="気温調整">
|
||||
<label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> 自動</label>
|
||||
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
|
||||
</div>
|
||||
</section>
|
||||
<section class="colony-setting-section colony-limit-section">
|
||||
<div class="colony-setting-section-head"><span>上限設定</span><small>300の次は∞</small></div>
|
||||
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
||||
<div class="colony-limit-grid">
|
||||
<label class="colony-limit-row"><span>たりないの数</span><input id="tarinaiPopulationLimitSlider" class="colony-limit-slider" type="range" min="1" max="301" step="1" value="301"><input id="tarinaiPopulationLimitInput" class="colony-limit-number" type="number" min="1" max="301" step="1" inputmode="numeric" placeholder="∞"></label>
|
||||
<label class="colony-limit-row"><span>物体数</span><input id="objectLimitSlider" class="colony-limit-slider" type="range" min="1" max="301" step="1" value="301"><input id="objectLimitInput" class="colony-limit-number" type="number" min="1" max="301" step="1" inputmode="numeric" placeholder="∞"></label>
|
||||
</div>
|
||||
<div id="colonyLimitStatus" class="colony-limit-status">たりないの数 0 / ∞ 物体数 0 / ∞</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
|
||||
</div>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</button><button data-chart="objects">環境</button></div>
|
||||
</details>
|
||||
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</button><button data-chart="objects">環境</button></div>
|
||||
<div id="colonyChartLegend" class="colony-chart-legend"></div>
|
||||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
</div>
|
||||
|
|
@ -162,6 +196,37 @@
|
|||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="achievementsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="achievementsDialogTitle">
|
||||
<div class="field-dialog-panel achievement-dialog-panel">
|
||||
<div class="achievement-dialog-head">
|
||||
<div>
|
||||
<div class="achievement-dialog-kicker">たりない観察</div>
|
||||
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 75</span></h2>
|
||||
</div>
|
||||
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
||||
</div>
|
||||
<p class="field-dialog-lead">一部の条件は未達成でも公開されます。実績はこの端末に保存され、背景の棒グラフは全体達成率を示します。</p>
|
||||
<div id="achievementSharedStatus" class="achievement-shared-status" aria-live="polite">共有達成率を確認中…</div>
|
||||
<div id="achievementList" class="achievement-list"></div>
|
||||
<div class="field-dialog-actions achievement-dialog-actions">
|
||||
<button id="achievementResetBtn" class="btn danger achievement-reset-btn" type="button">実績をリセット</button>
|
||||
<button id="achievementUnlockAllBtn" class="btn" type="button" hidden>全実績を獲得</button>
|
||||
<button id="achievementRefreshBtn" class="btn" type="button">共有達成率を更新</button>
|
||||
<button id="achievementsCloseBtn" class="btn primary" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="achievementToast" class="achievement-toast hidden" role="status" aria-live="polite">
|
||||
<div class="achievement-toast-icon" aria-hidden="true">★</div>
|
||||
<div class="achievement-toast-copy">
|
||||
<small>実績を解除しました</small>
|
||||
<strong id="achievementToastTitle"></strong>
|
||||
<span id="achievementToastCondition" class="achievement-toast-condition"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fieldDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="fieldDialogTitle">
|
||||
<div class="field-dialog-panel reset-dialog-panel">
|
||||
<h2 id="fieldDialogTitle">リセット設定</h2>
|
||||
|
|
@ -174,13 +239,14 @@
|
|||
</div>
|
||||
<div class="field-dialog-section-title">プリセット</div>
|
||||
<div class="field-choice-grid reset-preset-choice-grid">
|
||||
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い</span></button>
|
||||
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成</span></button>
|
||||
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール</span></button>
|
||||
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い。</span></button>
|
||||
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成。</span></button>
|
||||
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール。</span></button>
|
||||
<button class="field-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>過密</strong><span>大量のたりない。</span></button>
|
||||
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅</span></button>
|
||||
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵</span></button>
|
||||
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱</span></button>
|
||||
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅。</span></button>
|
||||
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵。</span></button>
|
||||
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱。</span></button>
|
||||
<button class="field-choice" data-reset-preset="hair_trigger" type="button" aria-pressed="false"><strong>一触即発</strong><span>中央のボール柵。</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
|
|
@ -191,40 +257,6 @@
|
|||
|
||||
|
||||
|
||||
<div id="visualSettingsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="visualSettingsDialogTitle">
|
||||
<div class="field-dialog-panel visual-settings-dialog-panel">
|
||||
<h2 id="visualSettingsDialogTitle">表示設定</h2>
|
||||
<p class="hint">自動ではFPSに応じて3段階の負荷制御を行います。高はシミュレーション間引きなし、低は常時軽量化です。</p>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>エフェクト</strong><span>破片、雨、光の演出</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="エフェクト">
|
||||
<button type="button" data-visual-setting="effects" data-visual-value="off">OFF</button>
|
||||
<button type="button" data-visual-setting="effects" data-visual-value="high">ON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>影</strong><span>地面の落ち影とスプライトの影</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="影">
|
||||
<button type="button" data-visual-setting="shadows" data-visual-value="off">OFF</button>
|
||||
<button type="button" data-visual-setting="shadows" data-visual-value="high">ON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visual-setting-row">
|
||||
<div><strong>描画</strong><span>装飾と解像度の設定</span></div>
|
||||
<div class="visual-setting-options" role="group" aria-label="描画">
|
||||
<button type="button" data-visual-setting="details" data-visual-value="auto">自動</button>
|
||||
<button type="button" data-visual-setting="details" data-visual-value="low">低</button>
|
||||
<button type="button" data-visual-setting="details" data-visual-value="high">高</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="visualSettingsCloseBtn" class="btn" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="creditsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="creditsDialogTitle">
|
||||
<div class="field-dialog-panel">
|
||||
<h2 id="creditsDialogTitle">クレジット</h2>
|
||||
|
|
@ -261,168 +293,173 @@
|
|||
</div>
|
||||
|
||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="js/version.js?v=39.16.18" defer></script>
|
||||
<script src="js/event_bus.js?v=39.16.18" defer></script>
|
||||
<script src="js/domain_ids.js?v=39.16.18" defer></script>
|
||||
<script src="js/registry_base.js?v=39.16.18" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.16.18" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.16.18" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_tool_metadata.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_registry.js?v=39.16.18" defer></script>
|
||||
<script src="js/data.js?v=39.16.18" defer></script>
|
||||
<script src="js/ground_types.js?v=39.16.18" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.16.18" defer></script>
|
||||
<script src="js/math.js?v=39.16.18" defer></script>
|
||||
<script src="js/geometry_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.16.18" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/signal_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/assets.js?v=39.16.18" defer></script>
|
||||
<script src="js/audio.js?v=39.16.18" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.16.18" defer></script>
|
||||
<script src="js/display_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/render.js?v=39.16.18" defer></script>
|
||||
<script src="js/sim_core.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.16.18" defer></script>
|
||||
<script src="js/structures.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_type_catalog.js?v=39.16.18" defer></script>
|
||||
<script src="js/items.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.16.18" defer></script>
|
||||
<script src="js/fire_runtime_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/robot_cleaner_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.16.18" defer></script>
|
||||
<script src="js/update_step_pipeline_runner.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.16.18" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_render_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.16.18" defer></script>
|
||||
<script src="js/burn_motion_util.js?v=39.16.18" defer></script>
|
||||
<script src="js/ants.js?v=39.16.18" defer></script>
|
||||
<script src="js/health.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.16.18" defer></script>
|
||||
<script src="js/world.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_view.js?v=39.16.18" defer></script>
|
||||
<script src="js/family_graph.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_reset_presets.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.16.18" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_environment.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_temperature_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_pathfinding_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_grass_placement_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/weather_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.16.18" defer></script>
|
||||
<script src="js/system_order.js?v=39.16.18" defer></script>
|
||||
<script src="js/simulation.js?v=39.16.18" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_update.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.16.18" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.16.18" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.16.18" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_log.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.16.18" defer></script>
|
||||
<script src="js/save_schema.js?v=39.16.18" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.16.18" defer></script>
|
||||
<script src="js/history_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/save_codec.js?v=39.16.18" defer></script>
|
||||
<script src="js/save_storage.js?v=39.16.18" defer></script>
|
||||
<script src="js/save_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.16.18" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.16.18" defer></script>
|
||||
<script src="js/main.js?v=39.16.18" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.16.18" defer></script>
|
||||
<script src="js/version.js?v=39.16.98" defer></script>
|
||||
<script src="js/event_bus.js?v=39.16.98" defer></script>
|
||||
<script src="js/domain_ids.js?v=39.16.98" defer></script>
|
||||
<script src="js/registry_base.js?v=39.16.98" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.16.98" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.16.98" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_tool_metadata.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_registry.js?v=39.16.98" defer></script>
|
||||
<script src="js/data.js?v=39.16.98" defer></script>
|
||||
<script src="js/ground_types.js?v=39.16.98" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.16.98" defer></script>
|
||||
<script src="js/math.js?v=39.16.98" defer></script>
|
||||
<script src="js/geometry_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.16.98" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/circuit_board_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/signal_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/assets.js?v=39.16.98" defer></script>
|
||||
<script src="js/audio.js?v=39.16.98" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.16.98" defer></script>
|
||||
<script src="js/display_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/render.js?v=39.16.98" defer></script>
|
||||
<script src="js/sim_core.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.16.98" defer></script>
|
||||
<script src="js/structures.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_type_catalog.js?v=39.16.98" defer></script>
|
||||
<script src="js/items.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.16.98" defer></script>
|
||||
<script src="js/fire_runtime_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/robot_cleaner_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.16.98" defer></script>
|
||||
<script src="js/update_step_pipeline_runner.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.16.98" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_render_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.16.98" defer></script>
|
||||
<script src="js/burn_motion_util.js?v=39.16.98" defer></script>
|
||||
<script src="js/ants.js?v=39.16.98" defer></script>
|
||||
<script src="js/health.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.16.98" defer></script>
|
||||
<script src="js/seesaw_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_direct_feeding_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.16.98" defer></script>
|
||||
<script src="js/world.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_view.js?v=39.16.98" defer></script>
|
||||
<script src="js/family_graph.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_reset_presets.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.16.98" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_environment.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_temperature_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_pathfinding_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_grass_placement_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/weather_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.16.98" defer></script>
|
||||
<script src="js/system_order.js?v=39.16.98" defer></script>
|
||||
<script src="js/simulation.js?v=39.16.98" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_update.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.16.98" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.16.98" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.16.98" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui.js?v=39.16.98" defer></script>
|
||||
<script src="js/game_dialogs.js?v=39.16.98" defer></script>
|
||||
<script src="js/achievements.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_log.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.16.98" defer></script>
|
||||
<script src="js/save_schema.js?v=39.16.98" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.16.98" defer></script>
|
||||
<script src="js/history_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/save_codec.js?v=39.16.98" defer></script>
|
||||
<script src="js/save_storage.js?v=39.16.98" defer></script>
|
||||
<script src="js/save_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.16.98" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.16.98" defer></script>
|
||||
<script src="js/main.js?v=39.16.98" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.16.98" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
2242
js/achievements.js
Normal file
2242
js/achievements.js
Normal file
File diff suppressed because it is too large
Load diff
14
js/ants.js
14
js/ants.js
|
|
@ -1,10 +1,10 @@
|
|||
"use strict";
|
||||
|
||||
const ANT_NEST_START_COUNT = 6;
|
||||
const ANT_NEST_START_COUNT = 3;
|
||||
const ANT_NEST_MAX_COUNT = 10;
|
||||
const ANT_NEST_MAX_OUTSIDE = 3;
|
||||
const ANT_WORKER_HP = 48;
|
||||
const ANT_QUEEN_HP = 270;
|
||||
const ANT_WORKER_HP = 24;
|
||||
const ANT_QUEEN_HP = 135;
|
||||
const ANT_WORKER_RADIUS = 4.2;
|
||||
const ANT_QUEEN_RADIUS = 9.5;
|
||||
const ANT_WORKER_SEARCH_SPEED = 12.9375;
|
||||
|
|
@ -91,6 +91,7 @@ class AntActor {
|
|||
|
||||
igniteFire(source = null) {
|
||||
if (this.dead) return false;
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, source, { world: this.world, reason: "fire" });
|
||||
this.burning = true;
|
||||
this.burnTimer = Math.max(this.burnTimer || 0, 4.2 + rand(0, 2.4));
|
||||
this.state = "panic";
|
||||
|
|
@ -152,8 +153,8 @@ class AntActor {
|
|||
if (!t || t.dead) continue;
|
||||
if (distXY(this.x, this.y, t.x, t.y) <= contactRadius + Math.max(t.radius || 18, 12) * 0.7) t.igniteFire?.(this, false);
|
||||
}
|
||||
for (const it of [...(this.world?.nearbyItems?.(this.x, this.y, contactRadius + 28, true) || [])]) {
|
||||
if (!it || it.dead || it.type !== "grass") continue;
|
||||
for (const it of [...(this.world?.nearbyGrass?.(this.x, this.y, contactRadius + 28, true) || [])]) {
|
||||
if (!it || it.dead) continue;
|
||||
if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt);
|
||||
}
|
||||
if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) {
|
||||
|
|
@ -412,6 +413,7 @@ class AntActor {
|
|||
const struggle = stillSleeping ? 0 : (aggressiveAttack
|
||||
? (1.25 + aggression * 1.6 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.35) / Math.max(1, haulers * 0.9)
|
||||
: (0.42 + (typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(target) : clamp((target.energy || 0) / Math.max(1, target.maxEnergy || 100), 0, 1)) * 0.18) / Math.max(1, haulers));
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(this, target, { world: this.world, reason: "tarinai_struggle" });
|
||||
this.hp -= dt * struggle;
|
||||
|
||||
if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) {
|
||||
|
|
@ -424,6 +426,7 @@ class AntActor {
|
|||
dieAsCorpse(home = null) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
globalThis.TarinaiAchievements?.recordAntKilled?.({ world: this.world, ant: this, home });
|
||||
audio.antDie?.();
|
||||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
|
|
@ -573,4 +576,3 @@ class AntActor {
|
|||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
js/assets.js
21
js/assets.js
|
|
@ -70,7 +70,9 @@ const scaledSpriteCache = new Map();
|
|||
const stainOverlayCache = new Map();
|
||||
const SPRITE_CACHE_LIMIT = 128;
|
||||
const STAIN_CACHE_LIMIT = 64;
|
||||
const IMAGE_LOAD_TIMEOUT_MS = 9000;
|
||||
const IMAGE_KEEP_IDS = new Set(["smile"]);
|
||||
let imageLoadTimeoutsTotal = 0;
|
||||
|
||||
const SUNBATH_IMAGE_IDS = new Set(SPRITES
|
||||
.map(asset => asset?.id || "")
|
||||
|
|
@ -228,9 +230,22 @@ function loadImageAsset(asset, priority = "auto") {
|
|||
img.alphaBounds = ALPHA_BOUNDS[asset.id] || null;
|
||||
images.set(asset.id, img);
|
||||
let promise;
|
||||
let settled = false;
|
||||
promise = new Promise((resolve) => {
|
||||
img.onload = () => { markImageLoaded(asset, img); touchImage(asset.id); resolve(img); };
|
||||
img.onerror = () => { resolve(null); };
|
||||
const finish = (value, timedOut = false) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timedOut) imageLoadTimeoutsTotal += 1;
|
||||
resolve(value);
|
||||
};
|
||||
const timer = window.setTimeout ? window.setTimeout(() => finish(null, true), IMAGE_LOAD_TIMEOUT_MS) : null;
|
||||
img.onload = () => {
|
||||
markImageLoaded(asset, img);
|
||||
touchImage(asset.id);
|
||||
finish(img);
|
||||
};
|
||||
img.onerror = () => { finish(null); };
|
||||
}).finally(() => {
|
||||
if (imagePromises.get(asset.id) === promise) imagePromises.delete(asset.id);
|
||||
});
|
||||
|
|
@ -369,7 +384,7 @@ if (typeof window !== "undefined") {
|
|||
trim: trimImageMemory,
|
||||
unload: unloadImageAsset,
|
||||
stats() {
|
||||
return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size };
|
||||
return { loaded: images.size, pending: imagePromises.size, spriteCache: scaledSpriteCache.size, stainCache: stainOverlayCache.size, timeouts: imageLoadTimeoutsTotal };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
1117
js/circuit_board_system.js
Normal file
1117
js/circuit_board_system.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -59,6 +59,12 @@
|
|||
const solid = worldRef?.solidObstacleRects?.(item) || [];
|
||||
if (solid.length) return solid;
|
||||
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
|
||||
if (item?.type === "circuit_board") {
|
||||
const circuit = global.TarinaiCircuitBoardSystem;
|
||||
const halfW = Number(circuit?.boardHalfWidth?.(item) || 32) || 32;
|
||||
const halfH = Number(circuit?.boardHalfHeight?.(item) || 32) || 32;
|
||||
return [{ oriented: true, cx: num(item.x), cy: num(item.y), halfW, halfH, angle: num(item.angle), type: item.type, item }];
|
||||
}
|
||||
return solid;
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +106,8 @@
|
|||
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) {
|
||||
if (!other || other === item || other.dead) continue;
|
||||
if (isSoftPlacementIgnoredType(other.type)) continue;
|
||||
const freeStacking = global.TarinaiItemToolMetadata?.ITEM_TRAITS?.freeStackingMovable;
|
||||
if (freeStacking?.has(item.type) && freeStacking.has(other.type)) continue;
|
||||
if (!important.has(other.type)) continue;
|
||||
const otherRects = placementRectsFor(worldRef, other);
|
||||
const otherRadius = itemFootprintRadius(worldRef, other, otherRects);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
return (worldRef.items || []).filter(it => it && !it.dead && isBallLikeType(it.type));
|
||||
}
|
||||
function ballLikeLabel(item) { return item?.type === "balloon" ? "水風船" : "ボール"; }
|
||||
function ballLikeLabel(item) { return item?.type === "balloon" ? "\u6c34\u98a8\u8239" : "\u30dc\u30fc\u30eb"; }
|
||||
|
||||
|
||||
function resolveBallBallCollisions(worldRef, dt) {
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
const avy = a.vy || 0;
|
||||
const aSpeed = Math.hypot(avx, avy);
|
||||
const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52;
|
||||
for (const b of worldRef.nearbyItems(a.x, a.y, range) || []) {
|
||||
for (const b of worldRef.nearbyNonGrassItems?.(a.x, a.y, range) || worldRef.nearbyItems?.(a.x, a.y, range) || []) {
|
||||
if (!b || b === a || b.dead || !isBallLikeType(b.type)) continue;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
if (now - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue;
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0));
|
||||
a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38);
|
||||
b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38);
|
||||
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" }));
|
||||
worldRef.spawnEffect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" });
|
||||
}
|
||||
}
|
||||
return solved;
|
||||
|
|
@ -130,7 +130,8 @@
|
|||
const sy = ball.y - py;
|
||||
const segLenSq = sx * sx + sy * sy;
|
||||
for (const t of worldRef.nearbyTarinai(ball.x, ball.y, range)) {
|
||||
if (!t || t.dead || t.state === "sleep" || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
const sleepingContact = t.state === "sleep" || t.sleeping;
|
||||
let hitX = ball.x;
|
||||
let hitY = ball.y;
|
||||
if (segLenSq > 1) {
|
||||
|
|
@ -165,7 +166,7 @@
|
|||
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
ball.lastKickedAt = now;
|
||||
ball.lastKickerId = t.id;
|
||||
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(18, ball.r * 1.05), life: 0.20, color: "rgba(196,65,72,0.46)" }));
|
||||
worldRef.spawnEffect("ring", hitX, hitY, { size: Math.max(18, ball.r * 1.05), life: 0.20, color: "rgba(196,65,72,0.46)" });
|
||||
if (worldRef.relationNotice(t.id, ball.id || "ball", "ball-pin-transfer", 1.2)) worldRef.log(`${t.name}\u306B\u30DC\u30FC\u30EB\u304B\u3089\u92F2\u304C\u79FB\u3063\u305F\u3002`, "accident", { participants: [t] });
|
||||
continue;
|
||||
}
|
||||
|
|
@ -215,10 +216,13 @@
|
|||
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
});
|
||||
worldRef.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball });
|
||||
if (t.dead && ball.type === "ball") {
|
||||
globalThis.TarinaiAchievements?.recordSoccerBallDeath?.({ world: worldRef, tarinai: t, ball, speed: ballSpeed, damage });
|
||||
}
|
||||
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
|
||||
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
|
||||
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
|
||||
if (t.enterPanic) t.enterPanic({ threat: ball, target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
|
||||
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
|
||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
||||
|
|
@ -228,13 +232,42 @@
|
|||
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
|
||||
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
|
||||
worldRef.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8));
|
||||
worldRef.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
|
||||
worldRef.spawnEffect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" });
|
||||
if (worldRef.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) worldRef.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] });
|
||||
ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
|
||||
ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sleepingContact) {
|
||||
const toward = (ball.vx || 0) * nx + (ball.vy || 0) * ny;
|
||||
const bodyImpulse = clamp(10 + Math.max(0, incomingSpeed) * 0.34 + overlapPush * 1.8, 10, 96);
|
||||
t.vx = clamp((t.vx || 0) - nx * bodyImpulse, -220, 220);
|
||||
t.vy = clamp((t.vy || 0) - ny * bodyImpulse, -220, 220);
|
||||
if (toward < 0) {
|
||||
const restitution = isBalloon ? 0.34 : 0.56;
|
||||
ball.vx = (ball.vx || 0) - (1 + restitution) * toward * nx;
|
||||
ball.vy = (ball.vy || 0) - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
ball.vx = (ball.vx || 0) + nx * Math.max(8, bodyImpulse * 0.28);
|
||||
ball.vy = (ball.vy || 0) + ny * Math.max(8, bodyImpulse * 0.28);
|
||||
}
|
||||
const separation = Math.max(0, overlapPush + 1.5);
|
||||
if (separation > 0) {
|
||||
ball.x = clamp((ball.x || 0) + nx * separation * 0.72, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
ball.y = clamp((ball.y || 0) + ny * separation * 0.72, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
t.x = clamp((t.x || 0) - nx * separation * 0.28, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
|
||||
t.y = clamp((t.y || 0) - ny * separation * 0.28, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||
}
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 4.2, -38, 38);
|
||||
ball.lastKickedAt = now;
|
||||
ball.lastKickerId = t.id;
|
||||
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(t, frameDt, "ball-contact");
|
||||
worldRef.markSpatialDirty?.("sleeping-ball-contact");
|
||||
worldRef.spawnEffect("ring", hitX, hitY, { size: Math.max(12, ball.r * 0.82), life: 0.18, color: isBalloon ? "rgba(248,122,166,0.38)" : "rgba(180,205,120,0.44)" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const relVx = (t.vx || 0) - (ball.vx || 0);
|
||||
const relVy = (t.vy || 0) - (ball.vy || 0);
|
||||
|
|
@ -270,8 +303,8 @@
|
|||
t.nextPlayBubbleAt = now + deterministicRange(worldRef, "ball-play-bubble-delay", 2.0, 3.8, ball, t);
|
||||
worldRef.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
|
||||
}
|
||||
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}は${objectLabel}を追いかけて弾いた。`, "observe", { participants: [t] });
|
||||
worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: isBalloon ? "rgba(248,122,166,0.42)" : "rgba(170,210,95,0.50)" }));
|
||||
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}\u306f${objectLabel}\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
|
||||
worldRef.spawnEffect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: isBalloon ? "rgba(248,122,166,0.42)" : "rgba(170,210,95,0.50)" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,11 +322,17 @@
|
|||
if (!worldRef.tarinaiCollisionMemo) worldRef.tarinaiCollisionMemo = new Map();
|
||||
const providedFastSources = Array.isArray(opts.fastSources);
|
||||
const liveSource = providedFastSources ? opts.fastSources : (worldRef.tarinai || []);
|
||||
const maxSources = Math.max(1, Math.floor(Number(opts.maxSources || Infinity) || Infinity));
|
||||
const fastSources = [];
|
||||
for (const t of liveSource) {
|
||||
const sourceLen = liveSource?.length || 0;
|
||||
const cursor = sourceLen > maxSources ? (Math.max(0, Math.floor(worldRef._tarinaiHighSpeedCollisionCursor || 0)) % sourceLen) : 0;
|
||||
for (let n = 0; n < sourceLen; n += 1) {
|
||||
if (fastSources.length >= maxSources) break;
|
||||
const t = liveSource[(cursor + n) % sourceLen];
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
|
||||
if (Math.hypot(t.vx || 0, t.vy || 0) >= threshold) fastSources.push(t);
|
||||
}
|
||||
if (sourceLen > maxSources) worldRef._tarinaiHighSpeedCollisionCursor = (cursor + Math.max(1, maxSources)) % sourceLen;
|
||||
if (!fastSources.length) return;
|
||||
const live = providedFastSources ? null : [];
|
||||
if (!providedFastSources) {
|
||||
|
|
@ -359,7 +398,7 @@
|
|||
if (candidates.length >= maxCandidates) break;
|
||||
}
|
||||
candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key)));
|
||||
const maxImpacts = Math.max(8, Math.min(64, Number(worldRef.maxTarinaiCollisionImpacts || 32) || 32));
|
||||
const maxImpacts = Math.max(1, Math.min(64, Number(opts.maxImpacts || worldRef.maxTarinaiCollisionImpacts || 32) || 32));
|
||||
let solved = 0;
|
||||
for (const c of candidates) {
|
||||
if (solved >= maxImpacts) break;
|
||||
|
|
@ -411,14 +450,14 @@
|
|||
for (const t of [target, impactor]) {
|
||||
if (!t || t.dead) continue;
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
|
||||
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
|
||||
else { t.lastPanicThreat = impactor; t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true }); }
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
t.fallDir = nx >= 0 ? 1 : -1;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
|
||||
}
|
||||
worldRef.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
|
||||
worldRef.spawnEffect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" });
|
||||
if (worldRef.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) worldRef.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] });
|
||||
}
|
||||
if (solved && worldRef.spatialDirty) worldRef.rebuildSpatial?.(true, "post-tarinai-highspeed-collision");
|
||||
|
|
|
|||
|
|
@ -5,22 +5,6 @@
|
|||
(function (global) {
|
||||
function avgOf(list, fn) { return list.length ? list.reduce((sum, t) => sum + (Number(fn(t)) || 0), 0) / list.length : 0; }
|
||||
function countOf(list, fn) { return list.reduce((sum, t) => sum + (fn(t) ? 1 : 0), 0); }
|
||||
function nearestAverage(alive) {
|
||||
if (alive.length < 2) return Infinity;
|
||||
let total = 0;
|
||||
for (const a of alive) {
|
||||
let best = Infinity;
|
||||
for (const b of alive) {
|
||||
if (a === b) continue;
|
||||
const dx = (a.x || 0) - (b.x || 0);
|
||||
const dy = (a.y || 0) - (b.y || 0);
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < best) best = d2;
|
||||
}
|
||||
if (Number.isFinite(best)) total += Math.sqrt(best);
|
||||
}
|
||||
return total / alive.length;
|
||||
}
|
||||
function collect(worldRef) {
|
||||
const now = worldRef?.time || 0;
|
||||
const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead);
|
||||
|
|
@ -29,16 +13,18 @@
|
|||
const panicCount = countOf(alive, t => t.state === "panic" || (t.fearTimer || 0) > 0.2 || (t.panicTargetUntil || 0) > now);
|
||||
const safetyHighCount = countOf(alive, t => (t.needs?.safety || 0) >= 52 || (t.fearTimer || 0) > 0.35);
|
||||
const weakCount = countOf(alive, t => (t.energy || 0) < 45 || (t.hunger || 0) > 88 || (t.needs?.health || 0) > 58);
|
||||
const criticalCount = countOf(alive, t => (t.energy || 0) < 25 || (t.hunger || 0) > 94 || (t.needs?.health || 0) > 75);
|
||||
const diseasedCount = countOf(alive, t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease);
|
||||
const breedingCount = countOf(alive, t => (t.loveMochiTimer || 0) > 0.1 || t.state === "birth_ritual" || (t.birthRitualTimer || 0) > 0.1);
|
||||
const recentDeaths = (Array.isArray(worldRef?.recentDeathTimes) ? worldRef.recentDeathTimes : []).filter(t => now - (Number(t) || -999) <= 30).length;
|
||||
const nearestAvg = nearestAverage(alive);
|
||||
const fieldId = String(worldRef?.fieldType || "garden");
|
||||
const overcrowdLimit = fieldId === "cage" ? 20 : (fieldId === "park" ? 100 : 50);
|
||||
const previousPopulation = Number(worldRef?.lastColonyMoodPopulation);
|
||||
const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation;
|
||||
return {
|
||||
now,
|
||||
alive,
|
||||
n,
|
||||
previousPopulation,
|
||||
populationIncreasing,
|
||||
avgEnergy: avgOf(alive, t => t.energy || 0),
|
||||
avgStress: avgOf(alive, t => t.stress || 0),
|
||||
avgHunger: avgOf(alive, t => t.hunger || 0),
|
||||
|
|
@ -46,35 +32,36 @@
|
|||
panicCount,
|
||||
safetyHighCount,
|
||||
weakCount,
|
||||
criticalCount,
|
||||
diseasedCount,
|
||||
breedingCount,
|
||||
recentDeaths,
|
||||
nearestAvg,
|
||||
recentShock: (now - (worldRef?.lastShootAt || -999) <= 8) || (now - (worldRef?.lastGenkotsuAt || -999) <= 8),
|
||||
panicRatio: ratio(panicCount),
|
||||
safetyRatio: ratio(safetyHighCount),
|
||||
weakRatio: ratio(weakCount),
|
||||
criticalRatio: ratio(criticalCount),
|
||||
diseaseRatio: ratio(diseasedCount),
|
||||
breedingRatio: ratio(breedingCount),
|
||||
overcrowdLimit,
|
||||
denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 84 && n >= Math.max(8, Math.floor(overcrowdLimit * 0.55)),
|
||||
};
|
||||
}
|
||||
function choose(worldRef, m) {
|
||||
const candidates = [];
|
||||
const add = (id, priority, ok) => { if (ok) candidates.push({ id, priority }); };
|
||||
const devastationSignal = m.recentDeaths >= 3 || (m.n <= 2 && (worldRef?.deadCount || 0) > 0 && (m.recentDeaths > 0 || m.weakRatio >= 0.35 || m.panicRatio >= 0.20));
|
||||
const crisisExhaustionSignal = m.avgEnergy < 30 && m.weakRatio >= 0.48;
|
||||
const crisisDeathThreshold = Math.max(5, Math.ceil(m.n * 0.10));
|
||||
const crisisMortalitySignal = m.recentDeaths >= crisisDeathThreshold;
|
||||
const crisisSystemicStress = m.weakRatio >= 0.50 && m.criticalRatio >= 0.35;
|
||||
const crisisCollapseSignal = m.weakRatio >= 0.70 && m.criticalRatio >= 0.60;
|
||||
add("devastation", 120, m.n <= 10 && devastationSignal);
|
||||
add("crisis", 110, m.recentDeaths >= 2 || crisisExhaustionSignal || (m.avgHunger > 92 && m.weakRatio >= 0.48));
|
||||
add("crisis", 110, !m.populationIncreasing && ((crisisMortalitySignal && crisisSystemicStress) || crisisCollapseSignal));
|
||||
add("confusion", 100, (m.recentShock && (m.panicRatio >= 0.10 || m.safetyRatio >= 0.18)) || m.panicRatio >= 0.25);
|
||||
add("fearful", 90, (m.avgSafety >= 50 && m.safetyRatio >= 0.28) || (m.recentShock && m.safetyRatio >= 0.18));
|
||||
add("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16));
|
||||
add("weakened", 74, (m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34);
|
||||
add("weakened", 74, !m.populationIncreasing && ((m.avgEnergy < 54 && m.weakRatio >= 0.18) || m.avgHunger > 82 || m.weakRatio >= 0.34));
|
||||
add("breeding", 66, m.breedingRatio >= 0.10 || m.breedingCount >= 2 || m.now - (worldRef?.lastBirthAt || -999) <= 45);
|
||||
add("happy", 46, m.avgStress < 28 && m.panicRatio < 0.08 && m.diseaseRatio < 0.12 && m.weakRatio < 0.18);
|
||||
add("overcrowded", 34, m.n >= m.overcrowdLimit || m.denseCrowd);
|
||||
add("isolated", 28, m.n <= 1 || (m.n <= 3 && m.nearestAvg > Math.max(180, Math.min(worldRef?.w || 900, worldRef?.h || 600) * 0.32)));
|
||||
add("isolated", 28, m.n <= 1);
|
||||
if (!candidates.length) return "relaxed";
|
||||
candidates.sort((a, b) => b.priority - a.priority);
|
||||
return candidates[0].id;
|
||||
|
|
@ -87,7 +74,10 @@
|
|||
const id = choose(worldRef, metrics);
|
||||
const next = worldRef.colonyMoodDefinition?.(id) || { id, label: id, effects: { personality: {} } };
|
||||
worldRef.colonyMood = next;
|
||||
worldRef.nextColonyMoodEvalAt = now + 1.25;
|
||||
worldRef.lastColonyMoodPopulation = metrics.n;
|
||||
const dayLength = Math.max(1, Number(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120);
|
||||
worldRef.nextColonyMoodEvalAt = now + dayLength / 12;
|
||||
global.TarinaiAchievements?.evaluateWorld?.(worldRef, next);
|
||||
return next;
|
||||
}
|
||||
global.TarinaiColonySituationSystem = Object.freeze({ evaluate });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
(function (global) {
|
||||
const DEFAULT_SPEEDS = Object.freeze([1, 2, 4]);
|
||||
const DEFAULT_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
|
||||
const DEFAULT_PICK_MODE_ORDER = Object.freeze(["free", "grid20", "grid40", "grid60"]);
|
||||
|
||||
function emit(worldRef, type, payload) {
|
||||
worldRef?.emit?.(type, payload);
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
if (!worldRef) return fail("missing_world");
|
||||
if (!target || target.dead) return fail("missing_target");
|
||||
target.favorite = !target.favorite;
|
||||
if (target.favorite) global.TarinaiAchievements?.recordFavoriteTarinai?.({ world: worldRef, tarinai: target, source: "favorite-toggle" });
|
||||
return ok({ selected: target, favorite: Boolean(target.favorite) });
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +100,30 @@
|
|||
return setToolSize(worldRef, id, next);
|
||||
}
|
||||
|
||||
function normalizeToolPickMode(mode = "free") {
|
||||
const value = String(mode || "free").toLowerCase();
|
||||
if (value === "grid") return "grid40"; // compatibility with v39.16.34
|
||||
return DEFAULT_PICK_MODE_ORDER.includes(value) ? value : "free";
|
||||
}
|
||||
|
||||
function setToolPickMode(worldRef, mode = "free") {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
const next = normalizeToolPickMode(mode);
|
||||
worldRef.toolPickMode = next;
|
||||
return ok({ mode: next });
|
||||
}
|
||||
|
||||
function cycleToolPickMode(worldRef, order = DEFAULT_PICK_MODE_ORDER) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
const modes = Array.isArray(order) && order.length
|
||||
? order.map(normalizeToolPickMode).filter((value, index, list) => list.indexOf(value) === index)
|
||||
: Array.from(DEFAULT_PICK_MODE_ORDER);
|
||||
const current = normalizeToolPickMode(worldRef.toolPickMode);
|
||||
const index = modes.indexOf(current);
|
||||
const next = modes[(index + 1 + modes.length) % modes.length] || "free";
|
||||
return setToolPickMode(worldRef, next);
|
||||
}
|
||||
|
||||
function togglePaused(worldRef) {
|
||||
if (!worldRef) return fail("missing_world");
|
||||
worldRef.paused = !worldRef.paused;
|
||||
|
|
@ -130,6 +156,11 @@
|
|||
return selectTool(worldRef, command.toolId);
|
||||
case "tool.size.cycle":
|
||||
return cycleToolSize(worldRef, command.toolId, command.order);
|
||||
case "tool.pick.toggle":
|
||||
case "tool.pick.cycle":
|
||||
return cycleToolPickMode(worldRef, command.order);
|
||||
case "tool.pick.set":
|
||||
return setToolPickMode(worldRef, command.mode);
|
||||
case "selection.clear":
|
||||
return clearSelection(worldRef);
|
||||
case "selection.focus":
|
||||
|
|
@ -143,12 +174,18 @@
|
|||
case "pointer.primary":
|
||||
return primaryPointer(worldRef, command.x, command.y);
|
||||
case "history.undo": {
|
||||
const deadBefore = Math.max(0, Number(worldRef?.deadCount || 0) || 0);
|
||||
const result = global.TarinaiHistory.undo(worldRef);
|
||||
return result.ok ? ok({ action: "undo" }) : fail(result.reason || "undo_failed");
|
||||
if (!result.ok) return fail(result.reason || "undo_failed");
|
||||
const deadAfter = Math.max(0, Number(worldRef?.deadCount || 0) || 0);
|
||||
global.TarinaiAchievements?.recordHistoryAction?.("undo", { world: worldRef, deadRestored: Math.max(0, deadBefore - deadAfter), label: result.label || "" });
|
||||
return ok({ action: "undo" });
|
||||
}
|
||||
case "history.redo": {
|
||||
const result = global.TarinaiHistory.redo(worldRef);
|
||||
return result.ok ? ok({ action: "redo" }) : fail(result.reason || "redo_failed");
|
||||
if (!result.ok) return fail(result.reason || "redo_failed");
|
||||
global.TarinaiAchievements?.recordHistoryAction?.("redo", { world: worldRef, label: result.label || "" });
|
||||
return ok({ action: "redo" });
|
||||
}
|
||||
default:
|
||||
return fail("unknown_command", { type: command.type || "" });
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@
|
|||
if (found && (found.dead || (Number.isFinite(Number(found.amount)) && Number(found.amount) <= 0))) found = null;
|
||||
if (!found && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id && !(Number.isFinite(Number(it.amount)) && Number(it.amount) <= 0));
|
||||
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
|
||||
const source = worldRef.nearbyItems?.(endpoint.x, endpoint.y, 120, true) || worldRef.items || [];
|
||||
const source = worldRef.nearbyNonGrassItems?.(endpoint.x, endpoint.y, 120, true) || worldRef.items || [];
|
||||
found = source.find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8));
|
||||
if (found) {
|
||||
endpoint.id = found.id;
|
||||
|
|
@ -783,14 +783,10 @@
|
|||
return { a, b, x: Number(item.x || 0) || 0, y: Number(item.y || 0) || 0, r: Number(item.r || item.radius || 0) || 0 };
|
||||
}
|
||||
|
||||
function finishLinkSpatialUpdate(item, worldRef, before, reason, threshold, minInterval) {
|
||||
if (!worldRef || !before) return false;
|
||||
function finishLinkSpatialUpdate(item, worldRef, reason, threshold, minInterval) {
|
||||
if (!worldRef || !item) return false;
|
||||
worldRef.drawListDirty = true;
|
||||
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - before.x, (Number(item.y || 0) || 0) - before.y) > threshold
|
||||
|| Math.abs((Number(item.r || item.radius || 0) || 0) - before.r) > threshold;
|
||||
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval);
|
||||
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
||||
return movedForSpatial;
|
||||
return markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval);
|
||||
}
|
||||
|
||||
function updateFlexibleLink(item, dt, worldRef) {
|
||||
|
|
@ -809,7 +805,7 @@
|
|||
}
|
||||
length = primeLinkState(item, aa, bb);
|
||||
changed = solveRopeParticleChain(item, aa, bb, dt, length, worldRef) || changed;
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rope-tension", 0.38, 0.085);
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, "rope-tension", 0.38, 0.085);
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
|
@ -842,7 +838,7 @@
|
|||
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
|
||||
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
|
||||
}
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rod-constraint", 0.30, 0.070);
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, "rod-constraint", 0.30, 0.070);
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
|
@ -955,7 +951,7 @@
|
|||
const residual = Math.abs(distXY(fa.x, fa.y, fb.x, fb.y) - rest);
|
||||
if (changed || residual > 0.8) lset(item, "awakeUntil", Number(worldRef?.time || 0) + 0.36);
|
||||
}
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "spring-constraint", 0.22, 0.035);
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, "spring-constraint", 0.22, 0.035);
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
|
@ -1056,7 +1052,8 @@
|
|||
try {
|
||||
worldRef.ensureItemBuckets?.("constraint-world");
|
||||
const maxLinks = Math.max(8, Number(opts.maxLinks || 160) || 160);
|
||||
const links = [];
|
||||
const links = worldRef._constraintLinksScratch || (worldRef._constraintLinksScratch = []);
|
||||
links.length = 0;
|
||||
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item);
|
||||
for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item);
|
||||
for (const item of worldRef.itemsOfType("spring") || []) if (item && !item.dead) links.push(item);
|
||||
|
|
@ -1067,15 +1064,16 @@
|
|||
// Rotate the starting point each frame so a large rope/rod set does not
|
||||
// starve low-score links that are always beyond the per-frame budget.
|
||||
const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0));
|
||||
const ordered = links.slice(cursor).concat(links.slice(0, cursor));
|
||||
let ran = 0;
|
||||
let visited = 0;
|
||||
let skipped = 0;
|
||||
let changed = 0;
|
||||
let rodStretchMax = 0;
|
||||
let rodCorrections = 0;
|
||||
const pending = [];
|
||||
for (const item of ordered) {
|
||||
const pending = worldRef._constraintPendingScratch || (worldRef._constraintPendingScratch = []);
|
||||
pending.length = 0;
|
||||
for (let n = 0; n < links.length; n += 1) {
|
||||
const item = links[(cursor + n) % links.length];
|
||||
if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false });
|
||||
else applyConstraintState(item);
|
||||
visited += 1;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const CONFIG = {
|
|||
reproductionCooldown: 18,
|
||||
dayLength: 120,
|
||||
standardTemperature: 15,
|
||||
summerTemperatureBoost: 5,
|
||||
summerTemperatureBoost: 0,
|
||||
climateTemperatureMin: -20,
|
||||
climateTemperatureMax: 50,
|
||||
temperatureMin: -Infinity,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@
|
|||
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
|
||||
...saveStatsLines(),
|
||||
`visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}/${effectRender.budget ?? metrics["render.effectDrawBudget"] ?? "?"} skip:${effectRender.skipped ?? metrics["render.effectDrawSkipped"] ?? "?"}`,
|
||||
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
|
||||
`creatures full:${creature.full ?? 0} bg:${creature.fullBudgetUsed ?? 0}/${creature.fullBudget ?? "?"} bgSkip:${creature.fullBudgetSkips ?? 0} vFull:${creature.visibleFullUsed ?? 0}/${creature.visibleFullBudget ?? "?"} vSkip:${creature.visibleFullSkips ?? 0} vMotion:${creature.visibleMotionUsed ?? 0}/${creature.visibleMotionBudget ?? "?"} mPressure:${creature.mechanicalPressure ?? 0}${creature.mechanicalPressureActive ? "!" : ""} mContact:${creature.mechanicalDetailedContacts ?? 0}/${creature.mechanicalContactBudget ?? "?"}+${creature.mechanicalDegradedContacts ?? 0} offSkip:${creature.offscreenSkipped ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
|
||||
`creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`,
|
||||
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
|
||||
`spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
// and visual effects. Previously this bootstrap lived inline in several
|
||||
// modules; keeping it here prevents helper drift and load-order surprises.
|
||||
(function ensureDeterministicHelpers(global) {
|
||||
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
|
||||
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
|
||||
const keyPart = value => {
|
||||
if (value == null) return "null";
|
||||
|
|
@ -28,10 +27,48 @@
|
|||
};
|
||||
const deterministicFrame = (worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6);
|
||||
const deterministicUnit = (worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|"));
|
||||
const finiteOr = (value, fallback = 0) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, finiteOr(value, min)));
|
||||
const isPlainObject = value => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
const clonePlain = (value, fallback = {}) => {
|
||||
if (value == null) return fallback;
|
||||
try {
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
} catch (_) {}
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
};
|
||||
const birthHash32 = (seed = "") => {
|
||||
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
|
||||
if (match) {
|
||||
const parsed = parseInt(match[1], 36);
|
||||
if (Number.isFinite(parsed)) return parsed >>> 0;
|
||||
}
|
||||
let h = 2166136261;
|
||||
const value = String(seed || "");
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
h ^= value.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
const isEffectCauseText = (text = "") => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
||||
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
|
||||
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
|
||||
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && deterministicUnit(worldRef, salt, ...parts) < p); });
|
||||
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
|
||||
global.TarinaiCoreHelpers = Object.freeze({
|
||||
...(global.TarinaiCoreHelpers || {}),
|
||||
finiteOr,
|
||||
clampNumber,
|
||||
isPlainObject,
|
||||
clonePlain,
|
||||
birthHash32,
|
||||
isEffectCauseText,
|
||||
});
|
||||
global.isTarinaiEffectCauseText = global.isTarinaiEffectCauseText || isEffectCauseText;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
// Keep the historical bare high-level helper names available to legacy scripts while the
|
||||
|
|
|
|||
|
|
@ -40,13 +40,13 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
if ((worldRef.effects.length || 0) >= effectCap) return;
|
||||
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
|
||||
const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
|
||||
worldRef.effects.push(new Effect("flame", x, y, {
|
||||
worldRef.spawnEffect("flame", x, y, {
|
||||
vx: Math.cos(a) * speed * 0.35,
|
||||
vy: -Math.max(4, speed * 0.65),
|
||||
size: Math.max(4, size * 0.58),
|
||||
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
|
||||
color: "rgba(236,46,24,0.78)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function fireInteractionBudget(worldRef) {
|
||||
|
|
@ -90,14 +90,21 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
|
||||
if (!t || t.dead) return false;
|
||||
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
|
||||
const already = Boolean(t.burning || (t.burnTimer || 0) > 0.02);
|
||||
if (already && !force) return false;
|
||||
const ignitedDuringBirthRitual = !already && Boolean((t.birthRitualTimer || 0) > 0.04 || t.state === "birth_ritual");
|
||||
t.burning = true;
|
||||
const dangerSource = source?.roles?.danger ? source : (source?._achievementDangerSource?.roles?.danger ? source._achievementDangerSource : null);
|
||||
if (dangerSource) t._achievementDangerSource = dangerSource;
|
||||
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
||||
t.thought = FIRE_BURNING_TEXT;
|
||||
t.lastPanicCause = "burning";
|
||||
if (source && source !== t) t.lastPanicThreat = source;
|
||||
t.lastPanicDetail = FIRE_BURNING_TEXT;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
||||
t.sleeping = false;
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
|
||||
if (ignitedDuringBirthRitual) global.TarinaiAchievements?.recordIgnition?.({ world: worldRef || t.world, tarinai: t, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +115,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
t.firePanicCooldownUntil = now + 0.85;
|
||||
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
|
||||
t.lastPanicCause = "fire_seen";
|
||||
if (source && source !== t) t.lastPanicThreat = source;
|
||||
t.lastPanicDetail = FIRE_SEEN_TEXT;
|
||||
t.thought = FIRE_SEEN_TEXT;
|
||||
if (typeof t.enterPanic === "function") t.enterPanic({ threat: source, target: source, reason: FIRE_SEEN_TEXT, fear: 1.25, wake: true, cause: "fire_seen", surpriseTimer: 0.55, awakeLockTimer: 0.35 });
|
||||
|
|
@ -151,7 +159,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
box.nestFireEvacuated = false;
|
||||
box.flameWaterCooldown = 0;
|
||||
box.nestFireEffectCooldown = 0;
|
||||
worldRef?.log?.("巣箱に火が燃え移った。", "danger");
|
||||
worldRef?.log?.("\u5de3\u7bb1\u306b\u706b\u304c\u71c3\u3048\u79fb\u3063\u305f\u3002", "danger");
|
||||
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.35, life: 0.30, color: "rgba(255,122,48,0.56)" }));
|
||||
}
|
||||
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-fire");
|
||||
|
|
@ -198,7 +206,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
count += 1;
|
||||
}
|
||||
if (count) {
|
||||
worldRef.log?.(`炎上した巣箱から${count}匹のたりないが追い出された。`, "danger", { participants: occupants });
|
||||
worldRef.log?.(`\u708e\u4e0a\u3057\u305f\u5de3\u7bb1\u304b\u3089${count}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u8ffd\u3044\u51fa\u3055\u308c\u305f\u3002`, "danger", { participants: occupants });
|
||||
worldRef.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.55, life: 0.32, color: "rgba(255,92,38,0.58)" }));
|
||||
worldRef.markSpatialDirty?.(reason);
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
@ -222,7 +230,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
box.hp = 0;
|
||||
box.dead = true;
|
||||
}
|
||||
worldRef?.log?.("巣箱が焼失した。", "danger");
|
||||
worldRef?.log?.("\u5de3\u7bb1\u304c\u713c\u5931\u3057\u305f\u3002", "danger");
|
||||
worldRef?.effects?.push(new Effect("ring", box.x, box.y, { size: nestBoxFireRadius(box) * 1.45, life: 0.34, color: "rgba(90,54,38,0.58)" }));
|
||||
worldRef?.markTerrainDirtyAt?.(box.x, box.y, nestBoxFireRadius(box) + 18, "nest-box-burnout");
|
||||
worldRef?.markItemBucketsDirty?.("nest-box-burnout");
|
||||
|
|
@ -344,14 +352,11 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
const d = distXY(item.x, item.y, ant.x, ant.y);
|
||||
if (d <= spreadRadius + Math.max(ant.r || 5, 4)) changed = igniteAntByFire(ant, item, worldRef) || changed;
|
||||
}
|
||||
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(item.x, item.y, spreadRadius + 38, true) || []) : (worldRef.items || []);
|
||||
const nearby = typeof worldRef.nearbyNonGrassItems === "function"
|
||||
? (worldRef.nearbyNonGrassItems(item.x, item.y, spreadRadius + 38, true) || [])
|
||||
: (worldRef.items || []);
|
||||
for (const it of nearby) {
|
||||
if (!it || it.dead || it === item) continue;
|
||||
if (it.type === "grass" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 16, 12)) {
|
||||
const extinguished = tryGrassExtinguishFire(it, item, worldRef, dt);
|
||||
changed = extinguished || changed;
|
||||
if (!extinguished && (item.amount || 0) > 0) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
|
||||
}
|
||||
if (it.type === "zunchi" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 15, 12)) changed = igniteZunchiByFire(it, item, worldRef, dt) || changed;
|
||||
if (it.type === "nest_box" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 42, 28) * 1.15) changed = igniteNestBoxByFire(it, item, worldRef) || changed;
|
||||
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
|
||||
|
|
@ -360,6 +365,12 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const grass of worldRef.nearbyGrass?.(item.x, item.y, spreadRadius + 38, true) || []) {
|
||||
if (!grass || grass.dead || distXY(item.x, item.y, grass.x, grass.y) > spreadRadius + Math.max(grass.r || 16, 12)) continue;
|
||||
const extinguished = tryGrassExtinguishFire(grass, item, worldRef, dt);
|
||||
changed = extinguished || changed;
|
||||
if (!extinguished && (item.amount || 0) > 0) changed = igniteGrassByFire(grass, item, worldRef, dt) || changed;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
|
@ -425,6 +436,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
fire.amount = Math.min(260, Math.max(fire.amount || 0, other.amount || 0) + Math.min(34, Math.max(0, Number(other.amount || 0) * 0.35)));
|
||||
fire.flameLife = Math.max(fire.flameLife || 0, other.flameLife || 0);
|
||||
fire.r = Math.min(42, Math.max(fire.r || 14, other.r || 14));
|
||||
if (!fire._achievementDangerSource && other._achievementDangerSource) fire._achievementDangerSource = other._achievementDangerSource;
|
||||
other.amount = 0;
|
||||
merged += 1;
|
||||
if (merged >= maxMerges) break;
|
||||
|
|
@ -462,6 +474,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
}
|
||||
if (!best) return null;
|
||||
best.amount = Math.min(280, Math.max(Number(best.amount || 0), Number(opts.amount || 0) * 0.45) + 14);
|
||||
if (opts.source) best._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
best.flameLife = Math.max(Number(best.flameLife || 0), Math.max(1.0, Number(opts.life || 3.5) * 0.75));
|
||||
best.r = Math.min(44, Math.max(best.r || 14, Number(opts.radius || 14)));
|
||||
best.vx = 0;
|
||||
|
|
@ -534,6 +547,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
existing.r = Math.min(42, Math.max(existing.r || 14, Number(opts.radius || 14)));
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
if (opts.source) existing._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
worldRef.drawListDirty = true;
|
||||
return existing;
|
||||
}
|
||||
|
|
@ -544,6 +558,7 @@ function updateFirecracker(item, dt, worldRef) {
|
|||
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
|
||||
fire.vx = 0;
|
||||
fire.vy = 0;
|
||||
if (opts.source) fire._achievementDangerSource = opts.source._achievementDangerSource || opts.source;
|
||||
const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null;
|
||||
if (!addedFire) return null;
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
|
|||
77
js/game_dialogs.js
Normal file
77
js/game_dialogs.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const queue = [];
|
||||
let active = null;
|
||||
|
||||
function ensureDialog() {
|
||||
let dialog = document.getElementById("gameConfirmDialog");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "gameConfirmDialog";
|
||||
dialog.className = "field-dialog game-confirm-dialog hidden";
|
||||
dialog.setAttribute("role", "alertdialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-labelledby", "gameConfirmDialogTitle");
|
||||
dialog.setAttribute("aria-describedby", "gameConfirmDialogMessage");
|
||||
dialog.innerHTML = `
|
||||
<div class="field-dialog-panel game-confirm-panel">
|
||||
<h2 id="gameConfirmDialogTitle"></h2>
|
||||
<p id="gameConfirmDialogMessage" class="field-dialog-lead game-confirm-message"></p>
|
||||
<div class="field-dialog-actions game-confirm-actions">
|
||||
<button class="btn" data-game-dialog-cancel type="button"></button>
|
||||
<button class="btn primary" data-game-dialog-confirm type="button"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.addEventListener("click", event => {
|
||||
if (!active) return;
|
||||
if (event.target === dialog || event.target.closest("[data-game-dialog-cancel]")) settle(false);
|
||||
else if (event.target.closest("[data-game-dialog-confirm]")) settle(true);
|
||||
});
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!active || dialog.classList.contains("hidden")) return;
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation?.(); settle(false); }
|
||||
}, true);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (active || !queue.length) return;
|
||||
active = queue.shift();
|
||||
const dialog = ensureDialog();
|
||||
const title = dialog.querySelector("#gameConfirmDialogTitle");
|
||||
const message = dialog.querySelector("#gameConfirmDialogMessage");
|
||||
const confirmButton = dialog.querySelector("[data-game-dialog-confirm]");
|
||||
const cancelButton = dialog.querySelector("[data-game-dialog-cancel]");
|
||||
if (title) title.textContent = active.options.title || "\u78ba\u8a8d";
|
||||
if (message) message.textContent = active.options.message || "";
|
||||
if (confirmButton) {
|
||||
confirmButton.textContent = active.options.confirmText || "\u5b9f\u884c";
|
||||
confirmButton.classList.toggle("danger", Boolean(active.options.danger));
|
||||
confirmButton.classList.toggle("primary", !active.options.danger);
|
||||
}
|
||||
if (cancelButton) cancelButton.textContent = active.options.cancelText || "\u30ad\u30e3\u30f3\u30bb\u30eb";
|
||||
dialog.classList.remove("hidden");
|
||||
global.setTimeout(() => confirmButton?.focus?.(), 0);
|
||||
}
|
||||
|
||||
function settle(accepted) {
|
||||
if (!active) return;
|
||||
const current = active;
|
||||
active = null;
|
||||
document.getElementById("gameConfirmDialog")?.classList.add("hidden");
|
||||
current.restoreFocus?.focus?.();
|
||||
current.resolve(Boolean(accepted));
|
||||
global.setTimeout(pump, 0);
|
||||
}
|
||||
|
||||
function confirm(options = {}) {
|
||||
return new Promise(resolve => {
|
||||
queue.push({ options: { ...options }, resolve, restoreFocus: document.activeElement || null });
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
global.TarinaiGameDialogs = Object.freeze({ confirm });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -4,14 +4,11 @@
|
|||
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
|
||||
// editor hit-testing, and world obstacle queries.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) {
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function clampValue(v, min, max) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
});
|
||||
const clampValue = global.TarinaiCoreHelpers?.clampNumber || ((v, min, max) => Math.max(min, Math.min(max, v)));
|
||||
|
||||
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
||||
const c = Math.cos(num(angle));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
const GROUND_TYPE_ORDER = window.TarinaiDomainIds.GROUND_IDS;
|
||||
const GROUND_TYPES = Object.freeze({
|
||||
// \u30c7\u30d5\u30a9\u30eb\u30c8\u5730\u9762ID\u3002\u8868\u793a\u540d\u306f\u300c\u5730\u9762\u300d\u3002
|
||||
soil: Object.freeze({ id: "soil", label: "砂", description: "普通の砂地。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
soil: Object.freeze({ id: "soil", label: "\u7802", description: "\u666e\u901a\u306e\u7802\u5730\u3002", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }),
|
||||
dirt: Object.freeze({ id: "dirt", label: "\u571f", description: "\u8349\u304c\u3088\u304f\u80b2\u3064\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }),
|
||||
concrete: Object.freeze({ id: "concrete", label: "\u30b3\u30f3\u30af\u30ea\u30fc\u30c8", description: "\u8349\u304c\u751f\u3048\u306a\u3044", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
|
||||
blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044\u3002\u8349\u306f\u751f\u3048\u306a\u3044", stressMultiplier: 0.5, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }),
|
||||
|
|
|
|||
10
js/health.js
10
js/health.js
|
|
@ -32,14 +32,16 @@ const HEALTH = (() => {
|
|||
dwarfDrug: "\u77ee\u5c0f\u85ac",
|
||||
freeze: "\u51CD\u7D50",
|
||||
heatstroke: "\u71B1\u4E2D\u75C7",
|
||||
cleaning: "掃除",
|
||||
cleaning: "\u6383\u9664",
|
||||
electrocution: "\u611f\u96fb",
|
||||
});
|
||||
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
|
||||
|
||||
function causeLabel(reason = "") {
|
||||
const text = String(reason || "");
|
||||
if (!text) return "";
|
||||
if (/掃除|ロボ掃除機|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning;
|
||||
if (/\u6383\u9664|\u30ed\u30dc\u6383\u9664\u6a5f|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning;
|
||||
if (/\u611f\u96fb|\u901a\u96fb\u4e2d\u306e\u96fb\u7dda|electrocution|electric.?shock|shocked/i.test(text)) return CAUSES.electrocution;
|
||||
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
|
||||
if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze;
|
||||
if (/\u71B1\u4E2D\u75C7|\u9AD8\u6E29|\u6691\u3055|heat|hot/i.test(text)) return CAUSES.heatstroke;
|
||||
|
|
@ -273,6 +275,7 @@ const HEALTH = (() => {
|
|||
const jitter = (Math.random() - 0.5) * Math.max(4, (t.radius || 22) * 0.25);
|
||||
const x = t.x - side * (t.radius || 22) * (0.72 + Math.random() * 0.24);
|
||||
const y = t.y + (t.radius || 22) * (0.24 + Math.random() * 0.25) + jitter;
|
||||
// Damage-triggered release is involuntary and must never be redirected to a toilet.
|
||||
t.world.spawnZunchi?.(x, y, t);
|
||||
t.poopCount = (t.poopCount || 0) + 1;
|
||||
t.digest = Math.max(0, Number(t.digest || 0) - 1.0);
|
||||
|
|
@ -287,7 +290,7 @@ const HEALTH = (() => {
|
|||
}
|
||||
return true;
|
||||
}
|
||||
function applyDamage(t, amount, reason = "") {
|
||||
function applyDamage(t, amount, reason = "", options = {}) {
|
||||
const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100);
|
||||
t.maxEnergy = maxEnergy;
|
||||
const before = clamp(Number(t.energy) || 0, 0, maxEnergy);
|
||||
|
|
@ -296,6 +299,7 @@ const HEALTH = (() => {
|
|||
const actualLoss = Math.max(0, before - t.energy);
|
||||
let cause = "";
|
||||
if (actualLoss > 0.01) {
|
||||
globalThis.TarinaiAchievements?.recordDamageSource?.(t, options.source || null, { world: t.world, amount: actualLoss, reason });
|
||||
cause = rememberDamage(t, actualLoss, reason);
|
||||
const plushie = (t.world?.ownedItemsFor?.(t.id) || t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
|
||||
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@
|
|||
// User-facing undo/redo for direct editing actions.
|
||||
(function (global) {
|
||||
const MAX_HISTORY = 48;
|
||||
|
||||
function cloneSnapshot(snapshot) {
|
||||
if (!snapshot) return null;
|
||||
if (typeof structuredClone === "function") return structuredClone(snapshot);
|
||||
return JSON.parse(JSON.stringify(snapshot));
|
||||
}
|
||||
const MAX_HISTORY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function ensure(worldRef) {
|
||||
if (!worldRef) return null;
|
||||
|
|
@ -17,147 +12,123 @@
|
|||
return worldRef;
|
||||
}
|
||||
|
||||
function snapshotKey(snapshot) {
|
||||
try {
|
||||
// Snapshot payloads are produced as plain serializable data; stringify them
|
||||
// directly for duplicate suppression instead of deep-cloning once just to
|
||||
// compute the history key. The stored history entry is still cloned below.
|
||||
return JSON.stringify(snapshot);
|
||||
} catch (_) {
|
||||
return "";
|
||||
function codec() {
|
||||
const value = global.TarinaiSaveCodec;
|
||||
if (!value || typeof value.encodeBinarySnapshot !== "function" || typeof value.decodeBinarySnapshot !== "function") {
|
||||
throw new Error("TarinaiSaveCodec binary history API is unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
const DELETE_MARK = Object.freeze({ __delete: true });
|
||||
const HISTORY_FULL_INTERVAL = 6;
|
||||
const HISTORY_DELTA_MIN_SAVING = 0.72;
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
// Two independent 32-bit accumulators make accidental duplicate suppression
|
||||
// extremely unlikely without converting the complete snapshot to JSON text.
|
||||
function fingerprintBytes(bytes) {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0x9e3779b9;
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
const value = bytes[i];
|
||||
h1 ^= value;
|
||||
h1 = Math.imul(h1, 0x01000193) >>> 0;
|
||||
h2 ^= value + ((i & 255) << 8);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 0x85ebca6b) >>> 0;
|
||||
}
|
||||
return `${bytes.length}:${h1.toString(36)}:${h2.toString(36)}`;
|
||||
}
|
||||
|
||||
function diffValue(prev, next) {
|
||||
if (Object.is(prev, next)) return undefined;
|
||||
if (Array.isArray(prev) || Array.isArray(next)) {
|
||||
const a = JSON.stringify(prev);
|
||||
const b = JSON.stringify(next);
|
||||
return a === b ? undefined : cloneSnapshot(next);
|
||||
}
|
||||
if (!isPlainObject(prev) || !isPlainObject(next)) return cloneSnapshot(next);
|
||||
const patch = {};
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (!(key in next)) {
|
||||
patch[key] = DELETE_MARK;
|
||||
continue;
|
||||
}
|
||||
const child = diffValue(prev[key], next[key]);
|
||||
if (child !== undefined) patch[key] = child;
|
||||
}
|
||||
return Object.keys(patch).length ? patch : undefined;
|
||||
function makeHistoryEntry(world, snapshot, label) {
|
||||
if (!snapshot) return null;
|
||||
const bytes = codec().encodeBinarySnapshot(snapshot);
|
||||
return {
|
||||
bytes,
|
||||
key: fingerprintBytes(bytes),
|
||||
label: String(label || "action"),
|
||||
time: Number(world?.time) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPatch(base, patch) {
|
||||
if (patch === undefined) return cloneSnapshot(base);
|
||||
if (!isPlainObject(patch) || Array.isArray(patch)) return cloneSnapshot(patch);
|
||||
const out = isPlainObject(base) ? cloneSnapshot(base) : {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value && value.__delete === true && Object.keys(value).length === 1) {
|
||||
delete out[key];
|
||||
continue;
|
||||
}
|
||||
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? applyPatch(out[key], value) : cloneSnapshot(value);
|
||||
}
|
||||
return out;
|
||||
function captureEntry(world, label) {
|
||||
const snapshot = global.TarinaiSnapshot.createSnapshot(world);
|
||||
return makeHistoryEntry(world, snapshot, label);
|
||||
}
|
||||
|
||||
function materializeHistoryEntry(stack, entry) {
|
||||
if (!entry) return null;
|
||||
if (entry.snapshot) return cloneSnapshot(entry.snapshot);
|
||||
const sequence = [...(stack || []), entry];
|
||||
let start = -1;
|
||||
for (let i = sequence.length - 1; i >= 0; i -= 1) {
|
||||
if (sequence[i]?.snapshot) { start = i; break; }
|
||||
}
|
||||
if (start < 0) return null;
|
||||
let snap = cloneSnapshot(sequence[start].snapshot);
|
||||
for (let i = start + 1; i < sequence.length; i += 1) {
|
||||
const patch = sequence[i]?.patch;
|
||||
if (patch !== undefined) snap = applyPatch(snap, patch);
|
||||
}
|
||||
return snap;
|
||||
function stackBytes(stack) {
|
||||
let total = 0;
|
||||
for (const entry of stack || []) total += Number(entry?.bytes?.byteLength || entry?.bytes?.length || 0) || 0;
|
||||
return total;
|
||||
}
|
||||
|
||||
function makeHistoryEntry(world, snap, key, label, stack) {
|
||||
const fullEntry = { snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 };
|
||||
const stackLen = Array.isArray(stack) ? stack.length : 0;
|
||||
if (stackLen <= 0 || stackLen % HISTORY_FULL_INTERVAL === 0) return fullEntry;
|
||||
const prev = materializeHistoryEntry(stack.slice(0, -1), stack[stack.length - 1]);
|
||||
if (!prev) return fullEntry;
|
||||
const patch = diffValue(prev, snap);
|
||||
if (patch === undefined) return { patch: {}, key, label: String(label || "action"), time: world.time || 0 };
|
||||
try {
|
||||
const fullSize = JSON.stringify(fullEntry.snapshot).length;
|
||||
const patchSize = JSON.stringify(patch).length;
|
||||
if (patchSize > fullSize * HISTORY_DELTA_MIN_SAVING) return fullEntry;
|
||||
} catch (_) {
|
||||
return fullEntry;
|
||||
function trim(stack, maxBytes = MAX_HISTORY_BYTES, keepRecent = 1) {
|
||||
if (stack.length > MAX_HISTORY) stack.splice(0, stack.length - MAX_HISTORY);
|
||||
let total = stackBytes(stack);
|
||||
const minKeep = Math.max(0, Math.min(stack.length, Math.floor(Number(keepRecent) || 0)));
|
||||
while (stack.length > minKeep && total > maxBytes) {
|
||||
const removed = stack.shift();
|
||||
total -= Number(removed?.bytes?.byteLength || removed?.bytes?.length || 0) || 0;
|
||||
}
|
||||
return { patch, key, label: String(label || "action"), time: world.time || 0 };
|
||||
return total;
|
||||
}
|
||||
|
||||
function trimMemory(worldRef = global.world, options = {}) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world) return 0;
|
||||
const targetBytes = Math.max(1 * 1024 * 1024, Number(options.targetBytes || MAX_HISTORY_BYTES) || MAX_HISTORY_BYTES);
|
||||
const keepRecent = Math.max(1, Math.floor(Number(options.keepRecent || 6) || 6));
|
||||
const half = Math.floor(targetBytes * 0.5);
|
||||
const undoBytes = trim(world._undoStack, half, keepRecent);
|
||||
const redoBytes = trim(world._redoStack, targetBytes - Math.min(half, undoBytes), Math.min(keepRecent, 4));
|
||||
return undoBytes + redoBytes;
|
||||
}
|
||||
|
||||
function capture(worldRef = global.world, label = "action") {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || world._historyRestoring) return false;
|
||||
const snap = global.TarinaiSnapshot.createSnapshot(world);
|
||||
if (!snap) return false;
|
||||
const key = snapshotKey(snap);
|
||||
if (key && key === world._lastUndoSnapshotKey) return false;
|
||||
world._undoStack.push(makeHistoryEntry(world, snap, key, label, world._undoStack));
|
||||
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
||||
const entry = captureEntry(world, label);
|
||||
if (!entry) return false;
|
||||
if (entry.key && entry.key === world._lastUndoSnapshotKey) return false;
|
||||
world._undoStack.push(entry);
|
||||
trim(world._undoStack);
|
||||
world._redoStack.length = 0;
|
||||
world._lastUndoSnapshotKey = key;
|
||||
world._lastUndoSnapshotKey = entry.key;
|
||||
return true;
|
||||
}
|
||||
|
||||
function restore(worldRef, entry, stack = []) {
|
||||
const snapshot = materializeHistoryEntry(stack, entry);
|
||||
if (!worldRef || !snapshot) return false;
|
||||
function restore(worldRef, entry) {
|
||||
if (!worldRef || !entry?.bytes) return false;
|
||||
const snapshot = codec().decodeBinarySnapshot(entry.bytes);
|
||||
if (!snapshot) return false;
|
||||
worldRef._historyRestoring = true;
|
||||
try {
|
||||
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
|
||||
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
|
||||
worldRef._lastUndoSnapshotKey = entry.key || fingerprintBytes(entry.bytes);
|
||||
return true;
|
||||
} finally {
|
||||
worldRef._historyRestoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function undo(worldRef = global.world) {
|
||||
function moveHistory(worldRef, fromKey, toKey, fallbackLabel) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || !world._undoStack.length) return { ok: false, reason: "empty" };
|
||||
const current = global.TarinaiSnapshot.createSnapshot(world);
|
||||
const prev = world._undoStack.pop();
|
||||
if (current) world._redoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "redo", world._redoStack));
|
||||
if (world._redoStack.length > MAX_HISTORY) world._redoStack.splice(0, world._redoStack.length - MAX_HISTORY);
|
||||
const ok = restore(world, prev, world._undoStack);
|
||||
return { ok, label: prev.label || "" };
|
||||
const from = world?.[fromKey];
|
||||
const to = world?.[toKey];
|
||||
if (!world || !from?.length || !Array.isArray(to)) return { ok: false, reason: "empty" };
|
||||
|
||||
const current = captureEntry(world, fallbackLabel);
|
||||
const target = from.pop();
|
||||
if (current) {
|
||||
to.push(current);
|
||||
trim(to);
|
||||
}
|
||||
const ok = restore(world, target);
|
||||
return { ok, label: target?.label || "" };
|
||||
}
|
||||
|
||||
function undo(worldRef = global.world) {
|
||||
return moveHistory(worldRef, "_undoStack", "_redoStack", "redo");
|
||||
}
|
||||
|
||||
function redo(worldRef = global.world) {
|
||||
const world = ensure(worldRef);
|
||||
if (!world || !world._redoStack.length) return { ok: false, reason: "empty" };
|
||||
const current = global.TarinaiSnapshot.createSnapshot(world);
|
||||
const next = world._redoStack.pop();
|
||||
if (current) world._undoStack.push(makeHistoryEntry(world, current, snapshotKey(current), "undo", world._undoStack));
|
||||
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
||||
const ok = restore(world, next, world._redoStack);
|
||||
return { ok, label: next.label || "" };
|
||||
return moveHistory(worldRef, "_redoStack", "_undoStack", "undo");
|
||||
}
|
||||
|
||||
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo });
|
||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo, trimMemory });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
t.vx += nx * push * (0.35 + p);
|
||||
t.vy += ny * push * (0.35 + p) - Math.min(210, push * 0.22) * p;
|
||||
const dealtDamage = damage * (0.35 + p * 0.65);
|
||||
if (dealtDamage > 0) t.damage(dealtDamage, reason);
|
||||
if (dealtDamage > 0) t.damage(dealtDamage, reason, { source: it });
|
||||
if (isGenkotsu) {
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
let ballHit = 0;
|
||||
if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) {
|
||||
const ballRange = radius + Math.max(72, (it.r || 88) * 0.78);
|
||||
const balls = worldRef.nearbyItems?.(it.x, it.y, ballRange + 42, true) || worldRef.items || [];
|
||||
const balls = worldRef.nearbyNonGrassItems?.(it.x, it.y, ballRange + 42, true) || worldRef.items || [];
|
||||
for (const ball of balls) {
|
||||
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
|
||||
const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y));
|
||||
|
|
@ -84,6 +84,7 @@
|
|||
}
|
||||
const antHit = damage > 0 ? worldRef.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
|
||||
push: p => push * (0.62 + p * 1.25),
|
||||
source: it,
|
||||
}) : 0;
|
||||
if (isGenkotsu) {
|
||||
worldRef.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75, { limit: 96, effectLimit: 6 });
|
||||
|
|
@ -91,10 +92,10 @@
|
|||
const existingEffects = (worldRef.effects || []).length;
|
||||
const ringCount = existingEffects > 180 ? 2 : 3;
|
||||
const burstCount = existingEffects > 180 ? 4 : 7;
|
||||
for (let r = 0; r < ringCount; r++) worldRef.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 42, life: 0.32 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.68)" : "rgba(74,57,42,0.38)" }));
|
||||
for (let i = 0; i < burstCount; i++) worldRef.effects.push(new Effect("fight", it.x + deterministicRange(worldRef, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(worldRef, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(worldRef, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(worldRef, "genkotsu-impact-effect-life", 0.22, 0.42, it, i), color: "rgba(255,205,46,0.78)" }));
|
||||
for (let r = 0; r < ringCount; r++) worldRef.spawnEffect("ring", it.x, it.y, { size: 32 + r * 42, life: 0.32 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.68)" : "rgba(74,57,42,0.38)" });
|
||||
for (let i = 0; i < burstCount; i++) worldRef.spawnEffect("fight", it.x + deterministicRange(worldRef, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(worldRef, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(worldRef, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(worldRef, "genkotsu-impact-effect-life", 0.22, 0.42, it, i), color: "rgba(255,205,46,0.78)" });
|
||||
} else {
|
||||
worldRef.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
|
||||
worldRef.spawnEffect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" });
|
||||
}
|
||||
if (hit > 0 || antHit > 0 || isGenkotsu) worldRef.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45));
|
||||
if (isGenkotsu) audio.genkotsuImpact?.();
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@
|
|||
if (!worldRef?.nearbyItems) return;
|
||||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
||||
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
|
||||
const items = worldRef.nearbyNonGrassItems?.(item.x, item.y, searchRadius) || worldRef.nearbyItems?.(item.x, item.y, searchRadius) || [];
|
||||
const seen = new Set(items);
|
||||
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
|
||||
// 全item走査ではなく、機構type bucketだけを追加確認する。
|
||||
// \u5927\u304d\u306a\u6a5f\u69cb\u306f\u4e2d\u5fc3\u304c\u8fd1\u508d\u30bb\u30eb\u5916\u3067\u3082\u5f53\u305f\u308b\u53ef\u80fd\u6027\u304c\u3042\u308b\u3002
|
||||
// \u5168item\u8d70\u67fb\u3067\u306f\u306a\u304f\u3001\u6a5f\u69cbtype bucket\u3060\u3051\u3092\u8ffd\u52a0\u78ba\u8a8d\u3059\u308b\u3002
|
||||
const mechanicalTypes = ["rotator", "poison_block", "reciprocator"];
|
||||
for (const type of mechanicalTypes) {
|
||||
const bucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType(type) : (worldRef.items || []);
|
||||
|
|
@ -276,10 +276,11 @@
|
|||
}
|
||||
return out;
|
||||
}
|
||||
return worldRef.nearbyItems?.(cx, cy, radius) || worldRef.items || [];
|
||||
return worldRef.nearbyNonGrassItems?.(cx, cy, radius) || worldRef.items || [];
|
||||
}
|
||||
|
||||
function fanWindAt(fan, x, y, opts = {}) {
|
||||
if (global.TarinaiSignalSystem?.powerOverride?.(fan) === false) return null;
|
||||
if (!fan || fan.dead || fan.type !== "fan") return null;
|
||||
const range = Math.max(80, Number(opts.range || 300) || 300);
|
||||
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
|
||||
|
|
@ -307,6 +308,8 @@
|
|||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
ensureFanBodyAngle(item);
|
||||
const powered = global.TarinaiSignalSystem?.powerOverride?.(item) !== false;
|
||||
if (!powered) { item.vx = 0; item.vy = 0; return true; }
|
||||
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
|
||||
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
|
||||
updateFanSwing(item, frameDt);
|
||||
|
|
@ -435,7 +438,7 @@
|
|||
const now = worldRef.time || 0;
|
||||
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
|
||||
const radius = Math.max(26, (balloon.r || 20) + 22);
|
||||
for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) {
|
||||
for (const it of worldRef.nearbyNonGrassItems?.(balloon.x, balloon.y, radius) || worldRef.nearbyItems?.(balloon.x, balloon.y, radius) || []) {
|
||||
if (!it || it.dead || it === balloon) continue;
|
||||
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker" || it.type === "firecracker") {
|
||||
const extra = it.type === "fire" ? 16 : (it.type === "firecracker" || it.type === "flame_firecracker" ? 8 : 4);
|
||||
|
|
@ -448,7 +451,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
const WATER_BALLOON_SURPRISE_TEXT = "水風船が割れておどろいている。";
|
||||
const WATER_BALLOON_SURPRISE_TEXT = "\u6c34\u98a8\u8239\u304c\u5272\u308c\u3066\u304a\u3069\u308d\u3044\u3066\u3044\u308b\u3002";
|
||||
|
||||
function tarinaiOpennessValue(t) {
|
||||
const v = (typeof global.effectivePersonalityValue === "function")
|
||||
|
|
@ -577,7 +580,7 @@
|
|||
const d = Math.max(1, distXY(balloon.x, balloon.y, t.x, t.y));
|
||||
const strength = clamp(1 - d / 165, 0, 1);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34 + strength * 0.44);
|
||||
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "水風船が割れて驚いた");
|
||||
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "\u6c34\u98a8\u8239\u304c\u5272\u308c\u3066\u9a5a\u3044\u305f");
|
||||
if (typeof t.spawnBubble === "function" && strength > 0.35) t.spawnBubble("!", { life: 0.72 });
|
||||
const awayX = (t.x - balloon.x) / d;
|
||||
const awayY = (t.y - balloon.y) / d;
|
||||
|
|
@ -628,13 +631,13 @@
|
|||
worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }));
|
||||
}
|
||||
spawnWaterFromBalloon(balloon, worldRef);
|
||||
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火"
|
||||
: (cause?.type === "firecracker" ? "爆竹"
|
||||
: (cause?.type === "shoot" ? "射撃"
|
||||
: (cause?.type === "genkotsu" ? "げんこつ"
|
||||
: (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "強い衝撃"))));
|
||||
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "\u706b"
|
||||
: (cause?.type === "firecracker" ? "\u7206\u7af9"
|
||||
: (cause?.type === "shoot" ? "\u5c04\u6483"
|
||||
: (cause?.type === "genkotsu" ? "\u3052\u3093\u3053\u3064"
|
||||
: (cause && typeof isPinType === "function" && isPinType(cause.type) ? "\u92f2" : "\u5f37\u3044\u885d\u6483"))));
|
||||
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
|
||||
worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] });
|
||||
worldRef.log?.(`\u6c34\u98a8\u8239\u304c${sourceLabel}\u306b\u89e6\u308c\u3066\u5272\u308c\u305f\u3002`, "accident", { participants: [] });
|
||||
}
|
||||
applyWaterBalloonSurprise(balloon, worldRef);
|
||||
worldRef?.markSpatialDirty?.("water-balloon-pop");
|
||||
|
|
@ -643,6 +646,7 @@
|
|||
global.TarinaiItemDynamicBallSystem = Object.freeze({
|
||||
update,
|
||||
updateFan,
|
||||
fanWindAt,
|
||||
updateWaterDropMotion,
|
||||
resolveObstacleCollisions,
|
||||
popBalloon,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@
|
|||
if (!ball || ball.dead || ball.type !== "ball" || !worldRef?.nearbyItems) return 0;
|
||||
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
|
||||
const range = (ball.r || 18) + speed * Math.max(0.016, Number(worldRef.dt || 0.016) || 0.016) + 28;
|
||||
const candidates = worldRef.nearbyItems(ball.x, ball.y, range) || [];
|
||||
const candidates = worldRef.nearbyNonGrassItems?.(ball.x, ball.y, range) || worldRef.nearbyItems?.(ball.x, ball.y, range) || [];
|
||||
let picked = 0;
|
||||
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
||||
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
||||
|
|
@ -218,12 +218,13 @@
|
|||
if (t.enterPanic) {
|
||||
t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
|
||||
} else {
|
||||
t.lastPanicThreat = item;
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
|
||||
}
|
||||
if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2", { source: item });
|
||||
if ((worldRef.time || 0) >= (item.pinLogAt || -999) + 1.2) {
|
||||
item.pinLogAt = worldRef.time || 0;
|
||||
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
|
||||
|
|
@ -264,7 +265,7 @@
|
|||
item.pinDamageTick = (item.pinDamageTick || 0) + dt;
|
||||
while (item.pinDamageTick >= 0.55) {
|
||||
item.pinDamageTick -= 0.55;
|
||||
if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
|
||||
if (!isOshibyo && t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2", { source: item });
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
|
||||
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
|
||||
if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
}
|
||||
|
||||
|
||||
const STICKY_BOMB_PANIC_TEXT = "粘着ボムが付いてパニックになっている。";
|
||||
const STICKY_BOMB_PANIC_TEXT = "\u7c98\u7740\u30dc\u30e0\u304c\u4ed8\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002";
|
||||
const STICKY_BOMB_FUSE_SECONDS = 12.0;
|
||||
const STICKY_BOMB_BLAST_SCALE = 0.68; // identical to TOOL_SIZE_SCALES.small
|
||||
|
||||
|
|
@ -83,10 +83,11 @@
|
|||
panicStickyBombCarrier(carrier, item, worldRef, true);
|
||||
if (old && old !== carrier) {
|
||||
old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18);
|
||||
old.thought = "粘着ボムが移った";
|
||||
old.thought = "\u7c98\u7740\u30dc\u30e0\u304c\u79fb\u3063\u305f";
|
||||
if (reason === "transfer") global.TarinaiAchievements?.recordStickyBombPass?.(item, { world: worldRef, from: old, to: carrier });
|
||||
}
|
||||
if (worldRef?.effects && typeof Effect !== "undefined") {
|
||||
worldRef.effects.push(new Effect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" }));
|
||||
worldRef.spawnEffect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" });
|
||||
}
|
||||
worldRef?.markItemBucketsDirty?.("sticky-bomb-attach");
|
||||
worldRef?.markSpatialDirty?.("sticky-bomb-attach");
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
item.amount = 0;
|
||||
item.hp = 0;
|
||||
audio.explode?.();
|
||||
worldRef?.log?.("粘着ボムが爆発した。", "accident");
|
||||
worldRef?.log?.("\u7c98\u7740\u30dc\u30e0\u304c\u7206\u767a\u3057\u305f\u3002", "accident");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +158,7 @@
|
|||
if (best) attachStickyBomb(item, best, worldRef, "attach");
|
||||
}
|
||||
if (worldRef?.effects && typeof Effect !== "undefined" && item.fuseTimer < Math.max(1.2, (item.fuseMax || STICKY_BOMB_FUSE_SECONDS) * 0.35) && deterministicChance(worldRef, "sticky-bomb-spark", step * 5.8, item, Math.floor((worldRef.time || 0) * 20))) {
|
||||
worldRef.effects.push(new Effect("fight", item.x, item.y - (item.r || 16) * 0.55, { size: 4 + Math.random() * 4, life: 0.16, color: "rgba(255,224,92,0.78)", vy: -20 }));
|
||||
worldRef.spawnEffect("fight", item.x, item.y - (item.r || 16) * 0.55, { size: 4 + Math.random() * 4, life: 0.16, color: "rgba(255,224,92,0.78)", vy: -20 });
|
||||
}
|
||||
worldRef && (worldRef.drawListDirty = true);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,33 @@
|
|||
// Owns zunchi item motion, high-speed collision, and lifecycle stage ticking.
|
||||
// Prototype methods delegate here.
|
||||
(function (global) {
|
||||
function burstOwnerHitRadius(item, tarinai) {
|
||||
return (tarinai?.radius || 20) * 0.72 + (item?.r || 14);
|
||||
}
|
||||
|
||||
function updateBurstOwnerClearance(item, worldRef) {
|
||||
if (!item?.achievementBurstOwnerId || item.achievementBurstClearedOwner === true || !worldRef) return false;
|
||||
const owner = worldRef.liveTarinaiById?.(String(item.achievementBurstOwnerId))
|
||||
|| (worldRef.tarinai || []).find(candidate => candidate && !candidate.dead && String(candidate.id || "") === String(item.achievementBurstOwnerId))
|
||||
|| null;
|
||||
if (!owner) return false;
|
||||
const clearance = burstOwnerHitRadius(item, owner) + 3;
|
||||
if (distXY(item.x, item.y, owner.x, owner.y) <= clearance) return false;
|
||||
item.achievementBurstClearedOwner = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateMotion(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "zunchi") return false;
|
||||
if (item.playerHeld || item._heldByPlayer) {
|
||||
if (item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
return true;
|
||||
}
|
||||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
if (speed < 0.08) {
|
||||
if (item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
item.vx = 0; item.vy = 0;
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
return true;
|
||||
|
|
@ -30,8 +53,11 @@
|
|||
worldRef.drawListDirty = true;
|
||||
}
|
||||
global.TarinaiItemDynamicBallSystem.resolveObstacleCollisions(item, dt, worldRef);
|
||||
resolveHighSpeedTarinaiCollision(item, dt, worldRef, speed);
|
||||
item.spin = (item.spin || 0) + speed * dt / Math.max(8, item.r || 14);
|
||||
updateBurstOwnerClearance(item, worldRef);
|
||||
const collisionSpeed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
if (collisionSpeed < 0.08 && item.achievementBurstOwnerId) item.achievementBurstNeverStopped = false;
|
||||
resolveHighSpeedTarinaiCollision(item, dt, worldRef, collisionSpeed);
|
||||
item.spin = (item.spin || 0) + collisionSpeed * dt / Math.max(8, item.r || 14);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +67,13 @@
|
|||
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, search, true) || []) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
const d = distXY(item.x, item.y, t.x, t.y);
|
||||
if (d > (t.radius || 20) * 0.72 + (item.r || 14)) continue;
|
||||
if (d > burstOwnerHitRadius(item, t)) continue;
|
||||
const hitTarinaiIdBeforeDamage = String(t.id || t.releasedLiveId || "");
|
||||
const isBurstOwner = Boolean(item.achievementBurstOwnerId && String(item.achievementBurstOwnerId) === hitTarinaiIdBeforeDamage);
|
||||
// Burst zunchi spawn inside their owner. Ignore that initial overlap
|
||||
// completely; only a projectile that has first cleared the owner can
|
||||
// return and become a valid self-collision.
|
||||
if (isBurstOwner && item.achievementBurstClearedOwner !== true) continue;
|
||||
const now = worldRef.time || 0;
|
||||
if ((item.lastHitTarinaiAt || {})[t.id] && item.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
|
||||
item.lastHitTarinaiAt = item.lastHitTarinaiAt || {};
|
||||
|
|
@ -53,7 +85,17 @@
|
|||
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
|
||||
if (speed >= 185) {
|
||||
const damage = Math.min(18, Math.max(1, (speed - 185) / 22));
|
||||
// A fatal damage call releases the tarinai live ID before control returns here.
|
||||
// Capture ownership eligibility first so a valid self-zunchi death is not lost.
|
||||
const eligibleSelfZunchiDeath = Boolean(
|
||||
isBurstOwner
|
||||
&& item.achievementBurstClearedOwner === true
|
||||
&& item.achievementBurstNeverStopped !== false
|
||||
);
|
||||
worldRef.applyImpactDamage?.(t, damage, "\u885d\u7a81", { source: item }) || t.damage?.(damage, "\u885d\u7a81");
|
||||
if (t.dead && eligibleSelfZunchiDeath) {
|
||||
globalThis.TarinaiAchievements?.recordSelfZunchiDeath?.({ world: worldRef, tarinai: t, zunchi: item, speed });
|
||||
}
|
||||
}
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
|
||||
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
|
||||
|
|
@ -85,13 +127,13 @@
|
|||
if (item.stage === "fresh" && item.freshness < 0.45) { item.stage = "dry"; item.stageTimer = 0; }
|
||||
if (item.stage === "dry" && item.freshness < 0.18) { item.stage = "decomposing"; item.stageTimer = 0; item.fertility = Math.max(item.fertility || 0, 0.18); }
|
||||
if (worldRef?.effects && typeof Effect === "function" && global.deterministicChance(worldRef, "zunchi-fire-effect", dt * 1.15, item)) {
|
||||
worldRef.effects.push(new Effect("flame", item.x + deterministicRange(worldRef, "zunchi-fire-x", -5, 5, item), item.y - Math.max(4, (item.r || 14) * 0.25), {
|
||||
worldRef.spawnEffect("flame", item.x + deterministicRange(worldRef, "zunchi-fire-x", -5, 5, item), item.y - Math.max(4, (item.r || 14) * 0.25), {
|
||||
vx: deterministicRange(worldRef, "zunchi-fire-vx", -3, 3, item),
|
||||
vy: deterministicRange(worldRef, "zunchi-fire-vy", -10, -4, item),
|
||||
size: deterministicRange(worldRef, "zunchi-fire-size", 6, 12, item),
|
||||
life: deterministicRange(worldRef, "zunchi-fire-life", 0.18, 0.34, item),
|
||||
color: "rgba(236,46,24,0.72)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
if (item.burnTimer <= 0.01 || (item.amount || 0) <= 0.5) {
|
||||
item.burning = false;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@
|
|||
sleep_drug: {
|
||||
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
|
||||
},
|
||||
first_aid: {
|
||||
label: "\u6551\u6025\u85ac", color: "rgba(210, 72, 82, 0.82)", kind: "instant",
|
||||
tags: ["item", "heal"], display: () => "\u5373\u6642 / HP\u56de\u5fa9\u306e\u307f",
|
||||
},
|
||||
sedative: {
|
||||
label: "\u93ae\u9759\u85ac", color: "rgba(92, 142, 174, 0.82)", kind: "timed", defaultDuration: 42,
|
||||
tags: ["item", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6050\u6016\u3068\u3051\u3093\u304b\u6c17\u5206\u3092\u6291\u3048\u308b`,
|
||||
},
|
||||
mystery_drug: {
|
||||
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
|
||||
},
|
||||
|
|
@ -103,6 +111,14 @@
|
|||
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
|
||||
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
|
||||
}
|
||||
Object.assign(rawDefinitions.first_aid, { mysteryEligible: false, zundaJuiceRemovable: false });
|
||||
Object.assign(rawDefinitions.sedative, {
|
||||
modifiers: { fearMultiplier: 0.45, fightWeightMultiplier: 0.28, speedMultiplier: 0.72 },
|
||||
visualEffectId: "sedative",
|
||||
bubblePolicy: "none",
|
||||
mysteryEligible: true,
|
||||
zundaJuiceRemovable: true,
|
||||
});
|
||||
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
|
||||
|
||||
Object.assign(rawDefinitions.love_mochi, {
|
||||
|
|
@ -150,6 +166,34 @@
|
|||
return tarinai.infectSleepDisease?.(context.item || null) || true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.first_aid, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const max = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100);
|
||||
const scale = Math.max(0.35, Number(context.scale ?? 1) || 1);
|
||||
const amount = Math.max(18, Math.round(max * 0.38 * scale));
|
||||
const before = Number(tarinai.energy || 0) || 0;
|
||||
tarinai.recoverHealth?.(amount);
|
||||
const recovered = Math.max(0, (Number(tarinai.energy || 0) || 0) - before);
|
||||
if (recovered > 0.0001) global.TarinaiAchievements?.recordFirstAidRecovery?.({ world: tarinai.world, tarinai, item: context.item || null, source: context.source || "", recovered });
|
||||
tarinai.hpBarTimer = Math.max(tarinai.hpBarTimer || 0, 2.4);
|
||||
tarinai.spawnPowerItemEffect?.("first_aid", 0.42);
|
||||
tarinai.bubble?.("HP+", 2.2, "rgba(210,72,82,0.82)", { force: true });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.sedative, {
|
||||
onApply(tarinai, context = {}) {
|
||||
const duration = Math.round((this.defaultDuration || 42) * Math.max(0.35, Number(context.scale ?? 1) || 1));
|
||||
tarinai.setTimedItemEffect?.("sedative", duration);
|
||||
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 2.4);
|
||||
tarinai.intimidatedTimer = Math.max(0, (tarinai.intimidatedTimer || 0) - 1.8);
|
||||
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 10);
|
||||
tarinai.postConflictPeaceTimer = Math.max(tarinai.postConflictPeaceTimer || 0, 14);
|
||||
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -18, social: -2 });
|
||||
tarinai.spawnPowerItemEffect?.("sedative", 0.36);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Object.assign(rawDefinitions.laxative, {
|
||||
onApply(tarinai, context = {}) {
|
||||
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
|
||||
|
|
@ -249,7 +293,7 @@
|
|||
return this.timerValue(tarinai, id) > 0.04;
|
||||
}
|
||||
activeSummary(tarinai = null) {
|
||||
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
|
||||
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "sedative", "love_mochi", "fight_mochi"];
|
||||
return order
|
||||
.filter(id => this.isActive(tarinai, id))
|
||||
.map(id => ({
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@
|
|||
|
||||
(function (global) {
|
||||
const rawDefinitions = {
|
||||
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0 },
|
||||
food: { label: "\u30da\u30c3\u30c8\u30d5\u30fc\u30c9", nutrition: 20.0, hungerRelief: 1.08, interest: "normal", decayRateMultiplier: 1.0 },
|
||||
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82 },
|
||||
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "love_mochi" },
|
||||
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "fight_mochi" },
|
||||
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "sleep_drug" },
|
||||
first_aid: { label: "\u6551\u6025\u85ac", nutrition: 1.0, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "first_aid" },
|
||||
sedative: { label: "\u93ae\u9759\u85ac", nutrition: 11.0, hungerRelief: 0.12, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "sedative" },
|
||||
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "laxative" },
|
||||
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "protein" },
|
||||
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, effectId: "niteropu" },
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
worldRef?.markSpatialDirty?.("food-passive-resize");
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
}
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
|
||||
}
|
||||
} else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) {
|
||||
|
|
@ -41,7 +40,6 @@
|
|||
item.amount -= lost;
|
||||
if (lost > 0) {
|
||||
changed = true;
|
||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +51,13 @@
|
|||
if (item.type === "water") item.amount -= dt * 0.9;
|
||||
decayServingFood(item, dt, worldRef);
|
||||
if (item.type === "trace") item.amount -= dt * 1.35;
|
||||
if (item.type === "splat") item.amount -= dt * 1.05;
|
||||
if (item.type === "splat") {
|
||||
const before = Number(item.amount || 0) || 0;
|
||||
item.amount -= dt * 1.05;
|
||||
if (Math.floor(before / 18) !== Math.floor(Math.max(0, item.amount) / 18)) {
|
||||
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), "splat-fade");
|
||||
}
|
||||
}
|
||||
if (item.type === "ant_corpse") item.amount -= dt * 0.018;
|
||||
return { done: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
if (!item || item.dead) return { done: true };
|
||||
item.age += dt;
|
||||
if (item.type === "zunchi") item.spawnGrace = Math.max(0, (item.spawnGrace || 0) - dt);
|
||||
if (item.type === "toilet") item.toiletFlash = Math.max(0, (item.toiletFlash || 0) - dt * 1.8);
|
||||
if (item.dropTimer > 0) {
|
||||
const beforeDrop = item.dropTimer;
|
||||
item.dropTimer = Math.max(0, item.dropTimer - dt);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
// globals instead of carrying helper implementations inline.
|
||||
(function (global) {
|
||||
const DUPLICATOR_STORABLE_TYPES = new Set([
|
||||
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug",
|
||||
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "first_aid", "sedative",
|
||||
"laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug",
|
||||
"zunda_juice", "water", "pushpin", "oshibyo"
|
||||
]);
|
||||
|
|
@ -76,6 +76,7 @@ function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, o
|
|||
function duplicatorApplyStoredPin(duplicator, worldRef) {
|
||||
const storedType = String(duplicator?.storedFoodType || "");
|
||||
if (!duplicator || duplicator.type !== "duplicator" || !isPinType(storedType)) return false;
|
||||
if (globalThis.TarinaiSignalSystem?.powerOverride?.(duplicator) === false) return false;
|
||||
const now = Number(worldRef?.time || 0);
|
||||
if (now < Number(duplicator.nextDuplicatedPinAt || 0)) return false;
|
||||
const dish = duplicatorLoadPoint(duplicator);
|
||||
|
|
|
|||
|
|
@ -225,6 +225,101 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.fillStyle = "rgba(255,238,128,0.94)";
|
||||
ctx.fillRect(-r * 0.78, r * 0.20, r * 1.56, r * 0.24);
|
||||
ctx.restore();
|
||||
} else if (this.type === "toilet") {
|
||||
const r = this.r || 48;
|
||||
const flash = clamp(this.toiletFlash || 0, 0, 1);
|
||||
const footprint = globalThis.TarinaiToiletSandSystem?.footprint?.(this) || {
|
||||
centerX: this.x,
|
||||
centerY: this.y + r * 0.08,
|
||||
halfWidth: r * 1.55,
|
||||
halfHeight: r * 0.72,
|
||||
cornerRadius: r * 0.18,
|
||||
angle: Number(this.angle || 0) || 0,
|
||||
};
|
||||
const localY = footprint.centerY - this.y;
|
||||
ctx.save();
|
||||
ctx.rotate(footprint.angle || 0);
|
||||
if (itemShadowsEnabled()) {
|
||||
ctx.shadowColor = "rgba(54,42,31,0.16)";
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.shadowOffsetY = 3;
|
||||
}
|
||||
ctx.fillStyle = styleNight ? "rgba(91,77,68,0.96)" : "rgba(143,116,91,0.96)";
|
||||
ctx.strokeStyle = styleNight ? "rgba(45,39,36,0.92)" : "rgba(79,62,49,0.90)";
|
||||
ctx.lineWidth = Math.max(1.8, r * 0.060);
|
||||
roundedRect(ctx, -footprint.halfWidth, localY - footprint.halfHeight, footprint.halfWidth * 2, footprint.halfHeight * 2, footprint.cornerRadius);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
const inset = Math.max(4, r * 0.14);
|
||||
ctx.fillStyle = styleNight ? "rgba(146,128,91,0.96)" : "rgba(218,190,128,0.98)";
|
||||
roundedRect(ctx, -footprint.halfWidth + inset, localY - footprint.halfHeight + inset, (footprint.halfWidth - inset) * 2, (footprint.halfHeight - inset) * 2, Math.max(2, footprint.cornerRadius * 0.62));
|
||||
ctx.fill();
|
||||
ctx.fillStyle = styleNight ? "rgba(176,156,111,0.72)" : "rgba(244,220,164,0.78)";
|
||||
for (const grain of [[-1.08,-0.20,0.055],[-0.62,0.21,0.045],[-0.18,-0.15,0.050],[0.28,0.19,0.042],[0.72,-0.18,0.052],[1.12,0.16,0.040]]) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(r * grain[0], localY + r * grain[1], Math.max(1, r * grain[2]), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (flash > 0.02) {
|
||||
ctx.globalAlpha *= flash * 0.42;
|
||||
ctx.fillStyle = "rgba(255,244,204,0.96)";
|
||||
roundedRect(ctx, -footprint.halfWidth + inset * 0.55, localY - footprint.halfHeight + inset * 0.55, (footprint.halfWidth - inset * 0.55) * 2, (footprint.halfHeight - inset * 0.55) * 2, Math.max(2, footprint.cornerRadius * 0.78));
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "rain_shelter") {
|
||||
const r = this.r || 50;
|
||||
const footprint = globalThis.TarinaiParasolSystem?.parasolFootprint?.(this) || {
|
||||
centerX: this.x,
|
||||
centerY: this.y + r * 0.84,
|
||||
radiusX: r * 1.58,
|
||||
radiusY: r * 0.68,
|
||||
};
|
||||
ctx.save();
|
||||
ctx.fillStyle = styleNight ? "rgba(18,18,28,0.26)" : "rgba(54,48,54,0.20)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(footprint.centerX - this.x, footprint.centerY - this.y, footprint.radiusX, footprint.radiusY, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (itemShadowsEnabled()) {
|
||||
ctx.shadowColor = "rgba(54,42,31,0.18)";
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.shadowOffsetY = 5;
|
||||
}
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = styleNight ? "rgba(70,70,78,0.90)" : "rgba(92,86,76,0.82)";
|
||||
ctx.lineWidth = Math.max(2.2, r * 0.065);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -r * 0.55);
|
||||
ctx.lineTo(0, r * 0.84);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-r * 0.24, r * 0.84);
|
||||
ctx.lineTo(r * 0.24, r * 0.84);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = styleNight ? "rgba(116,76,112,0.96)" : "rgba(225,104,126,0.96)";
|
||||
ctx.strokeStyle = styleNight ? "rgba(62,42,68,0.92)" : "rgba(126,54,72,0.84)";
|
||||
ctx.lineWidth = Math.max(1.8, r * 0.065);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-r * 1.12, -r * 0.24);
|
||||
ctx.quadraticCurveTo(-r * 0.72, -r * 0.98, 0, -r * 1.10);
|
||||
ctx.quadraticCurveTo(r * 0.72, -r * 0.98, r * 1.12, -r * 0.24);
|
||||
ctx.quadraticCurveTo(r * 0.76, -r * 0.06, r * 0.50, -r * 0.24);
|
||||
ctx.quadraticCurveTo(r * 0.24, -r * 0.04, 0, -r * 0.24);
|
||||
ctx.quadraticCurveTo(-r * 0.24, -r * 0.04, -r * 0.50, -r * 0.24);
|
||||
ctx.quadraticCurveTo(-r * 0.76, -r * 0.06, -r * 1.12, -r * 0.24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = styleNight ? "rgba(210,184,205,0.44)" : "rgba(255,220,224,0.62)";
|
||||
ctx.lineWidth = Math.max(1, r * 0.025);
|
||||
for (const x of [-0.50, 0, 0.50]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -r * 1.08);
|
||||
ctx.quadraticCurveTo(r * x * 0.72, -r * 0.68, r * x, -r * 0.24);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "water") {
|
||||
drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
|
||||
} else if (this.type === "grass") {
|
||||
|
|
@ -454,6 +549,7 @@ draw(ctx, t, lighting = null) {
|
|||
} else if (this.type === "duplicator") {
|
||||
ctx.save();
|
||||
const loaded = !!this.storedFoodType;
|
||||
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||
const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9);
|
||||
bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6");
|
||||
bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7");
|
||||
|
|
@ -471,7 +567,7 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)";
|
||||
ctx.fillStyle = !powered ? "rgba(180,76,70,0.72)" : (loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)");
|
||||
ctx.beginPath();
|
||||
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
|
@ -529,6 +625,76 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "seesaw") {
|
||||
const r = this.r || 60;
|
||||
const tilt = Number(this.seesawTilt || 0) || 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = styleNight ? "rgba(81,68,57,0.96)" : "rgba(130,99,69,0.98)";
|
||||
ctx.strokeStyle = styleNight ? "rgba(41,34,29,0.86)" : "rgba(78,55,37,0.82)";
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-r * 0.22, r * 0.42);
|
||||
ctx.lineTo(0, -r * 0.05);
|
||||
ctx.lineTo(r * 0.22, r * 0.42);
|
||||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.rotate(tilt);
|
||||
const grad = ctx.createLinearGradient(0, -r * 0.14, 0, r * 0.14);
|
||||
grad.addColorStop(0, styleNight ? "#c5895c" : "#f0ad6f");
|
||||
grad.addColorStop(1, styleNight ? "#8d5d3f" : "#ba7447");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.strokeStyle = styleNight ? "rgba(57,37,29,0.9)" : "rgba(104,59,35,0.85)";
|
||||
roundedRect(ctx, -r * 1.08, -r * 0.13, r * 2.16, r * 0.26, r * 0.08);
|
||||
ctx.fill(); ctx.stroke();
|
||||
for (const side of [-1, 1]) {
|
||||
ctx.fillStyle = side < 0 ? "rgba(88,177,231,0.98)" : "rgba(239,111,116,0.98)";
|
||||
roundedRect(ctx, side * r * 0.79 - r * 0.18, -r * 0.26, r * 0.36, r * 0.22, r * 0.07);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(70,55,45,0.75)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(side * r * 0.90, -r * 0.22);
|
||||
ctx.lineTo(side * r * 0.90, -r * 0.52);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.fillStyle = "rgba(70,58,48,0.9)";
|
||||
ctx.beginPath(); ctx.arc(0, 0, r * 0.13, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "circuit_board") {
|
||||
const circuit = globalThis.TarinaiCircuitBoardSystem;
|
||||
const angle = Number(this.angle || 0) || 0;
|
||||
const active = Object.values(this._circuitOutputState || {}).some(Boolean);
|
||||
ctx.save();
|
||||
ctx.rotate(angle);
|
||||
ctx.fillStyle = active ? "rgba(44,139,82,0.98)" : (styleNight ? "rgba(41,91,62,0.98)" : "rgba(47,126,76,0.98)");
|
||||
ctx.strokeStyle = styleNight ? "rgba(19,48,31,0.92)" : "rgba(26,74,42,0.90)";
|
||||
ctx.lineWidth = 2.4;
|
||||
const boardHalfW = Number(circuit?.boardHalfWidth?.(this) ?? circuit?.HALF_W ?? 32) || 32;
|
||||
const boardHalfH = Number(circuit?.boardHalfHeight?.(this) ?? circuit?.HALF_H ?? 32) || 32;
|
||||
roundedRect(ctx, -boardHalfW, -boardHalfH, boardHalfW * 2, boardHalfH * 2, 10); ctx.fill(); ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(216,190,81,0.64)";
|
||||
ctx.lineWidth = 1.4;
|
||||
const traceRows = Math.max(1, Math.min(7, Math.floor(boardHalfH / 9)));
|
||||
for (let i = -traceRows; i <= traceRows; i++) {
|
||||
const y = i * Math.max(5, boardHalfH / Math.max(2, traceRows * 1.7));
|
||||
ctx.beginPath(); ctx.moveTo(-boardHalfW * 0.72, y); ctx.lineTo(-boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(boardHalfW * 0.72, y); ctx.lineTo(boardHalfW * 0.28, y); ctx.lineTo(0, y * 0.45); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "rgba(30,39,34,0.94)";
|
||||
roundedRect(ctx, -15, -16, 30, 32, 5); ctx.fill();
|
||||
ctx.fillStyle = "rgba(236,245,235,0.92)";
|
||||
ctx.font = "bold 11px ui-rounded, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
ctx.fillText("LOGIC", 0, 0);
|
||||
for (const port of circuit?.ports?.(this) || []) {
|
||||
const local = circuit.localPortPosition(port, this);
|
||||
const on = port.direction === "input" ? Boolean(this._circuitInputState?.[port.id]) : Boolean(this._circuitOutputState?.[port.id]);
|
||||
ctx.fillStyle = on ? "rgba(255,228,91,1)" : (port.direction === "input" ? "rgba(92,189,255,0.96)" : "rgba(255,143,77,0.96)");
|
||||
ctx.strokeStyle = "rgba(25,35,31,0.88)";
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.beginPath(); ctx.arc(local.x, local.y, 5.4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "signboard") {
|
||||
ctx.save();
|
||||
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
|
||||
|
|
@ -590,8 +756,9 @@ draw(ctx, t, lighting = null) {
|
|||
} else if (this.type === "dry_ice") {
|
||||
const r = this.r || 21;
|
||||
const time = this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0);
|
||||
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||
ctx.save();
|
||||
if (itemShadowsEnabled()) {
|
||||
if (powered && itemShadowsEnabled()) {
|
||||
ctx.shadowColor = "rgba(90, 178, 255, 0.30)";
|
||||
ctx.shadowBlur = 11;
|
||||
}
|
||||
|
|
@ -642,7 +809,7 @@ draw(ctx, t, lighting = null) {
|
|||
roundedRect(ctx, -bodyW * 0.48, y + bodyH - footH * 0.4, bodyW * 0.96, footH, r * 0.10);
|
||||
ctx.fill();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (powered) for (let i = 0; i < 4; i++) {
|
||||
const phase = (time * (0.42 + i * 0.06) + i * 0.23 + (this.seed || 0)) % 1;
|
||||
const sx = -r * 0.50 + i * r * 0.33 + Math.sin(time * 1.4 + i) * r * 0.06;
|
||||
const sy = y - capH * 0.62 - phase * r * 0.76;
|
||||
|
|
@ -655,8 +822,9 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.restore();
|
||||
} else if (this.type === "stove") {
|
||||
const r = this.r || 24;
|
||||
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||
ctx.save();
|
||||
if (itemShadowsEnabled()) {
|
||||
if (powered && itemShadowsEnabled()) {
|
||||
ctx.shadowColor = "rgba(255, 120, 43, 0.34)";
|
||||
ctx.shadowBlur = 9;
|
||||
}
|
||||
|
|
@ -669,17 +837,19 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.fillStyle = "rgba(42, 32, 28, 0.92)";
|
||||
roundedRect(ctx, -r * 0.48, -r * 0.34, r * 0.96, r * 0.62, r * 0.14);
|
||||
ctx.fill();
|
||||
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
|
||||
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, r * 0.16);
|
||||
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
|
||||
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (powered) {
|
||||
const flame = 0.85 + 0.15 * Math.sin((this.world?.time || (typeof performance !== "undefined" ? performance.now() * 0.001 : 0)) * 4.2 + (this.seed || 0));
|
||||
ctx.fillStyle = "rgba(255, 168, 57, 0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, r * 0.16);
|
||||
ctx.bezierCurveTo(-r * 0.38, -r * 0.10, -r * 0.10, -r * 0.52 * flame, 0, -r * 0.66 * flame);
|
||||
ctx.bezierCurveTo(r * 0.34, -r * 0.20, r * 0.40, -r * 0.02, 0, r * 0.16);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255, 235, 120, 0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -r * 0.10, r * 0.16, r * 0.28 * flame, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
} else if (this.type === "ant_nest") {
|
||||
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
|
||||
|
|
@ -778,9 +948,11 @@ draw(ctx, t, lighting = null) {
|
|||
const r = this.r || 26;
|
||||
const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this);
|
||||
const windAngle = itemAngleFor(this);
|
||||
const spin = (this.fanSpin || 0) + t * 10.5;
|
||||
const powered = globalThis.TarinaiSignalSystem?.powerOverride?.(this) !== false;
|
||||
const spin = (this.fanSpin || 0) + (powered ? t * 10.5 : 0);
|
||||
ctx.save();
|
||||
ctx.rotate(windAngle);
|
||||
if (powered) {
|
||||
const windRange = 320;
|
||||
const windStart = r * 1.02;
|
||||
const windSpread = 0.62;
|
||||
|
|
@ -839,6 +1011,7 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = fanAlpha;
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
ctx.rotate(bodyAngle);
|
||||
|
|
@ -1139,7 +1312,9 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.strokeStyle = active ? "#9b6512" : "#69717a"; ctx.lineWidth=3;
|
||||
ctx.beginPath(); ctx.roundRect?.(-27,-18,54,36,8); if (!ctx.roundRect) { ctx.rect(-27,-18,54,36); } ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = active ? "#fff6c9" : "#7b838d"; ctx.beginPath(); ctx.arc(0,-3,9,0,Math.PI*2); ctx.fill();
|
||||
ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(`${this.pressureTarget === "item" ? "物" : "た"}${this.pressureThreshold || 1}`,0,14);
|
||||
const detectorMark = { tarinai:"\u305f", item:"\u7269", time:"\u6642", hunger_avg:"\u8179", stress_avg:"\u5727", sleep_avg:"\u7720", low_hp_count:"HP", sick_count:"\u75c5", temperature:"\u2103", zunchi_count:"\u7cde", ant_count:"\u87fb", water_count:"\u6c34" }[this.pressureTarget] || "\u305f";
|
||||
const detectorLabel = this.pressureTarget === "time" ? detectorMark : `${detectorMark}${Number(this.pressureMin ?? 1)}-${Number(this.pressureMax ?? 300)}`;
|
||||
ctx.fillStyle = styleNight ? "#eef2f6" : "#26323e"; ctx.font="bold 10px ui-rounded, sans-serif"; ctx.textAlign="center"; ctx.fillText(detectorLabel,0,14);
|
||||
ctx.restore();
|
||||
} else if (this.type === "rotator") {
|
||||
const angle = itemAngleFor(this);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
|
||||
clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "\u9078\u629e\u4e2d\u306e\u9053\u5177\u30dc\u30bf\u30f3\u3092\u89e3\u9664\u3057\u3001\u901a\u5e38\u306e\u89b3\u5bdf\u72b6\u614b\u306b\u623b\u3059\u3002" },
|
||||
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "▦", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" },
|
||||
area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "\u2327", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" },
|
||||
copy: { id: "copy", label: "\u30b3\u30d4\u30fc", placeable: false, scalable: false, iconText: "\u29c9", tooltip: "\u7f6e\u3044\u305f\u30a2\u30a4\u30c6\u30e0\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30b3\u30d4\u30fc\u3057\u3001\u6b21\u306e\u30af\u30ea\u30c3\u30af\u3067\u8a2d\u5b9a\u3054\u3068\u8cbc\u308a\u4ed8\u3051\u308b\u3002" },
|
||||
undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" },
|
||||
redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" },
|
||||
|
|
@ -16,9 +16,9 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3084\u706b\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
|
||||
rope: { id: "rope", label: "\u30d2\u30e2", placeable: false, scalable: false, iconText: "\u2570", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3075\u306b\u3083\u3075\u306b\u3083\u3057\u305f\u30d2\u30e2\u3067\u7e4b\u3050\u3002" },
|
||||
rod: { id: "rod", label: "\u68d2", placeable: false, scalable: false, iconText: "\u2501", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001\u66f2\u304c\u3089\u306a\u3044\u68d2\u3067\u63a5\u7d9a\u3059\u308b\u3002" },
|
||||
spring: { id: "spring", label: "バネ", placeable: false, scalable: false, iconText: "≋", tooltip: "2つの対象を順にクリックして、伸縮するバネでつなぐ。" },
|
||||
wire: { id: "wire", label: "電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。触ると感電。" },
|
||||
insulated_wire: { id: "insulated_wire", label: "絶縁電線", placeable: false, scalable: false, iconText: "⌁", tooltip: "検知器の信号を電気で伝える。感電しない。" },
|
||||
spring: { id: "spring", label: "\u30d0\u30cd", placeable: false, scalable: false, iconText: "\u224b", tooltip: "2\u3064\u306e\u5bfe\u8c61\u3092\u9806\u306b\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001\u4f38\u7e2e\u3059\u308b\u30d0\u30cd\u3067\u3064\u306a\u3050\u3002" },
|
||||
wire: { id: "wire", label: "\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u89e6\u308b\u3068\u611f\u96fb\u3002" },
|
||||
insulated_wire: { id: "insulated_wire", label: "\u7d76\u7e01\u96fb\u7dda", placeable: false, scalable: false, iconText: "\u2301", tooltip: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3092\u96fb\u6c17\u3067\u4f1d\u3048\u308b\u3002\u611f\u96fb\u3057\u306a\u3044\u3002" },
|
||||
shoot: { id: "shoot", label: "\u5c04\u6483", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "\u9283\u3067\u653b\u6483\u3059\u308b\u3002" },
|
||||
robot_cleaner: { id: "robot_cleaner", itemType: "robot_cleaner", label: "\u30ed\u30dc\u6383\u9664\u6a5f", placeable: true, scalable: false, radius: 22, amount: 999, iconText: "\u25c9", tooltip: "\u30b4\u30df\u3084\u6c5a\u308c\u3092\u7247\u4ed8\u3051\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
|
||||
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
|
||||
|
|
@ -43,19 +43,19 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
|
||||
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
|
||||
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
|
||||
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "移動できる地下空間。たりないが眠ることもある。" },
|
||||
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
|
||||
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
|
||||
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u79fb\u52d5\u3067\u304d\u308b\u5730\u4e0b\u7a7a\u9593\u3002\u305f\u308a\u306a\u3044\u304c\u7720\u308b\u3053\u3068\u3082\u3042\u308b\u3002" },
|
||||
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0b\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
|
||||
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56f2\u306e\u6c17\u6e29\u309210\u2103\u4e0a\u3052\u308b\u3002\u52b9\u679c\u306f\u91cd\u306d\u3089\u308c\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
|
||||
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
|
||||
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
|
||||
balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "たりないのおもちゃ。割れると水滴が飛び散る。" },
|
||||
fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。軽いものを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" },
|
||||
balloon: { id: "balloon", itemType: "balloon", label: "\u6c34\u98a8\u8239", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "\ud83d\udca7", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u306e\u304a\u3082\u3061\u3083\u3002\u5272\u308c\u308b\u3068\u6c34\u6ef4\u304c\u98db\u3073\u6563\u308b\u3002" },
|
||||
fan: { id: "fan", itemType: "fan", label: "\u6247\u98a8\u6a5f", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "\ud83c\udf00", collisionShape: "circle", tooltip: "\u524d\u65b9\u306b\u98a8\u3092\u51fa\u3059\u3002\u8efd\u3044\u3082\u306e\u3092\u62bc\u3057\u3001\u5c11\u3057\u6dbc\u3057\u304f\u3059\u308b\u3002Q/E\u3067\u7f6e\u304f\u5411\u304d\u3092\u5909\u3048\u308b\u3002\u914d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u958b\u304f\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f5c\u52d5\u3059\u308b\u3002" },
|
||||
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
|
||||
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" },
|
||||
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002\u96fb\u7dda\u63a5\u7d9a\u6642\u306f\u4fe1\u53f7ON\u306e\u9593\u3060\u3051\u4f9b\u7d66\u3059\u308b\u3002" },
|
||||
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
|
||||
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true },
|
||||
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" },
|
||||
sticky_bomb: { id: "sticky_bomb", itemType: "sticky_bomb", label: "粘着ボム", placeable: true, scalable: false, radius: 16, amount: 999, iconText: "\uD83D\uDCA3", collisionShape: "circle", tooltip: "\u4e00\u5b9a\u6642\u9593\u3067\u7206\u767a\u3059\u308b\u7206\u5f3e\u3002\u4ed8\u7740\u3057\u305f\u500b\u4f53\u306f\u30d1\u30cb\u30c3\u30af\u306b\u306a\u308b\u3002\u4ed6\u500b\u4f53\u306b\u89e6\u308c\u308b\u3068\u30dc\u30e0\u304c\u305d\u3063\u3061\u306b\u79fb\u52d5\u3059\u308b\u3002" },
|
||||
sticky_bomb: { id: "sticky_bomb", itemType: "sticky_bomb", label: "\u7c98\u7740\u30dc\u30e0", placeable: true, scalable: false, radius: 16, amount: 999, iconText: "\uD83D\uDCA3", collisionShape: "circle", tooltip: "\u4e00\u5b9a\u6642\u9593\u3067\u7206\u767a\u3059\u308b\u7206\u5f3e\u3002\u4ed8\u7740\u3057\u305f\u500b\u4f53\u306f\u30d1\u30cb\u30c3\u30af\u306b\u306a\u308b\u3002\u4ed6\u500b\u4f53\u306b\u89e6\u308c\u308b\u3068\u30dc\u30e0\u304c\u305d\u3063\u3061\u306b\u79fb\u52d5\u3059\u308b\u3002" },
|
||||
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
|
||||
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
|
||||
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
|
|
@ -64,16 +64,22 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
one_way_fence: { id: "one_way_fence", itemType: "one_way_fence", label: "一方通行柵", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "⇧", collisionShape: "oriented_rect", tooltip: "矢印方向には通過でき、逆方向からは通れない柵。Q/Eで45度、Shift+Q/Eで5度ずつ向きを変える。" },
|
||||
one_way_fence: { id: "one_way_fence", itemType: "one_way_fence", label: "\u4e00\u65b9\u901a\u884c\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "\u21e7", collisionShape: "oriented_rect", tooltip: "\u77e2\u5370\u65b9\u5411\u306b\u306f\u901a\u904e\u3067\u304d\u3001\u9006\u65b9\u5411\u304b\u3089\u306f\u901a\u308c\u306a\u3044\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
|
||||
rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
|
||||
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
|
||||
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
|
||||
pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "検知器", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "▣", collisionShape: "circle", tooltip: "表示された矩形範囲内にいるたりない、またはアイテム数を検知して電線へ信号を送る。クリックで対象・必要数・範囲を設定。" },
|
||||
pressure_switch: { id: "pressure_switch", itemType: "pressure_switch", label: "\u691c\u77e5\u5668", placeable: true, scalable: false, radius: 34, amount: 999, iconText: "\u25a3", collisionShape: "circle", tooltip: "\u69d8\u3005\u306a\u72b6\u6cc1\u3092\u691c\u77e5\u3057\u3066\u96fb\u7dda\u306b\u4fe1\u53f7\u3092\u9001\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
|
||||
seesaw: { id: "seesaw", itemType: "seesaw", label: "\u30b7\u30fc\u30bd\u30fc", placeable: true, scalable: false, radius: 60, amount: 999, iconText: "\u2195", collisionShape: "circle", tooltip: "\u4e21\u7aef\u306b\u305f\u308a\u306a\u3044\u304c\u4e57\u308b\u3068\u4e8c\u5339\u3067\u904a\u3073\u3001\u4e92\u3044\u306b\u53cb\u597d\u3092\u7bc9\u304f\u3002" },
|
||||
circuit_board: { id: "circuit_board", itemType: "circuit_board", label: "\u57fa\u76e4", placeable: true, scalable: false, radius: 46, amount: 999, iconText: "\u25a6", collisionShape: "oriented_rect", tooltip: "\u96fb\u7dda\u306e\u4fe1\u53f7\u3067\u8ad6\u7406\u6f14\u7b97\u304c\u3067\u304d\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u8a73\u7d30\u8a2d\u5b9a\u3002" },
|
||||
water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" },
|
||||
trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u30da\u30c3\u30c8\u30d5\u30fc\u30c9", placeable: true, scalable: true, radius: 14, amount: 85, iconText: "\ud83e\udd63", tooltip: "\u7279\u6b8a\u52b9\u679c\u306e\u306a\u3044\u666e\u901a\u306e\u3054\u306f\u3093\u3002" },
|
||||
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
|
||||
toilet: { id: "toilet", itemType: "toilet", label: "\u30c8\u30a4\u30ec\u306e\u7802", placeable: true, scalable: true, radius: 48, amount: 999, iconText: "\u25a3", tooltip: "\u4e0a\u306b\u6392\u6cc4\u3055\u308c\u305f\u305a\u3093\u3061\u304c\u76f4\u3061\u306b\u6d88\u6ec5\u3059\u308b\u3002" },
|
||||
rain_shelter: { id: "rain_shelter", itemType: "rain_shelter", label: "\u30d1\u30e9\u30bd\u30eb", placeable: true, scalable: true, radius: 50, amount: 999, iconText: "\u26f1", tooltip: "\u96e8\u3068\u65e5\u5dee\u3057\u3092\u9632\u304e\u3001\u4f53\u611f\u6c17\u6e29\u3092\u7a4f\u3084\u304b\u306b\u3059\u308b\u3002" },
|
||||
first_aid: { id: "first_aid", itemType: "first_aid", label: "\u6551\u6025\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u271a", tooltip: "\u98df\u3079\u308b\u3068HP\u3092\u5373\u6642\u56de\u5fa9\u3059\u308b\u3002" },
|
||||
sedative: { id: "sedative", itemType: "sedative", label: "\u93ae\u9759\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, iconText: "\u25cc", tooltip: "\u98df\u3079\u308b\u3068\u6050\u6016\u3084\u3051\u3093\u304b\u6c17\u5206\u3092\u843d\u3061\u7740\u304b\u305b\u308b\u3002" },
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,23 @@
|
|||
// Static item/tool metadata shared by tool definitions and UI helpers.
|
||||
(function (global) {
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]),
|
||||
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
|
||||
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "rain_shelter", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "circuit_board"]),
|
||||
food_interest: new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
|
||||
hazard: new Set(["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
|
||||
pin: new Set(["pushpin", "oshibyo"]),
|
||||
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
|
||||
freeStackingMovable: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi", "water", "zunda_juice", "mercury", "genkotsu", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "ant_corpse"]),
|
||||
sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]),
|
||||
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch"]),
|
||||
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "rain_shelter", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "pressure_switch", "seesaw", "circuit_board"]),
|
||||
});
|
||||
|
||||
|
||||
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence"]));
|
||||
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
|
||||
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]);
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
|
||||
const ELECTRIC_TOOL_IDS = Object.freeze(["pressure_switch", "wire", "insulated_wire", "circuit_board"]);
|
||||
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h", "circuit_board"]);
|
||||
const PHYSICS_CATEGORY_TOOL_IDS = Object.freeze(PHYSICS_TOOL_IDS.filter(id => !ELECTRIC_TOOL_IDS.includes(id)));
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan", "circuit_board"]));
|
||||
|
||||
const PIN_BEHAVIORS = Object.freeze({
|
||||
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
|
||||
|
|
@ -25,10 +28,11 @@
|
|||
|
||||
const TOOL_CATEGORIES = Object.freeze([
|
||||
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] },
|
||||
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
|
||||
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] },
|
||||
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
|
||||
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
|
||||
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "toilet", "rain_shelter", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "seesaw", "signboard"] },
|
||||
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_CATEGORY_TOOL_IDS },
|
||||
{ id: "electric", label: "\u96fb\u6c17", themeClass: "tool-category-electric", order: 46, toolIds: ELECTRIC_TOOL_IDS },
|
||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] },
|
||||
]);
|
||||
|
||||
|
|
@ -53,6 +57,8 @@
|
|||
FENCE_ITEM_TYPES,
|
||||
MECHANICAL_ITEM_TYPES,
|
||||
PHYSICS_TOOL_IDS,
|
||||
PHYSICS_CATEGORY_TOOL_IDS,
|
||||
ELECTRIC_TOOL_IDS,
|
||||
ROTATABLE_ITEM_TYPES,
|
||||
PIN_BEHAVIORS,
|
||||
TOOL_CATEGORIES,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// Layer: data/item-type-catalog
|
||||
// Builds the current save item-type table from live tool, structure, and link definitions.
|
||||
// The item-type table remains build-defined. Schema 36 includes the current mechanical and signal tool ordering.
|
||||
// The item-type table is build-defined; removed item types are not retained for compatibility.
|
||||
(function (global) {
|
||||
const DomainIds = global.TarinaiDomainIds;
|
||||
if (!DomainIds) throw new Error("TarinaiDomainIds must be loaded before item_type_catalog.js");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,39 @@
|
|||
|
||||
// Type-specific Item constructor state. Kept out of items.js so construction stays focused on shared fields.
|
||||
|
||||
// Shared toilet-sand geometry. Rendering and poop absorption must use the
|
||||
// same rounded rectangle so the visible sand and active area never diverge.
|
||||
function toiletSandFootprint(item = null) {
|
||||
if (!item || item.dead || item.type !== "toilet") return null;
|
||||
const r = Math.max(24, Number(item.r || 48) || 48);
|
||||
return {
|
||||
centerX: Number(item.x || 0),
|
||||
centerY: Number(item.y || 0) + r * 0.08,
|
||||
halfWidth: r * 1.55,
|
||||
halfHeight: r * 0.72,
|
||||
cornerRadius: r * 0.18,
|
||||
angle: Number(item.angle || 0) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toiletSandContains(item = null, x = 0, y = 0) {
|
||||
const footprint = toiletSandFootprint(item);
|
||||
if (!footprint) return false;
|
||||
const dx = Number(x || 0) - footprint.centerX;
|
||||
const dy = Number(y || 0) - footprint.centerY;
|
||||
const cos = Math.cos(footprint.angle);
|
||||
const sin = Math.sin(footprint.angle);
|
||||
const lx = dx * cos + dy * sin;
|
||||
const ly = -dx * sin + dy * cos;
|
||||
const innerX = Math.max(0, footprint.halfWidth - footprint.cornerRadius);
|
||||
const innerY = Math.max(0, footprint.halfHeight - footprint.cornerRadius);
|
||||
const qx = Math.max(0, Math.abs(lx) - innerX);
|
||||
const qy = Math.max(0, Math.abs(ly) - innerY);
|
||||
return qx * qx + qy * qy <= footprint.cornerRadius * footprint.cornerRadius;
|
||||
}
|
||||
|
||||
globalThis.TarinaiToiletSandSystem = Object.freeze({ footprint: toiletSandFootprint, contains: toiletSandContains });
|
||||
|
||||
function initializeItemTypeState(item, type, x, y) {
|
||||
if (!item) return item;
|
||||
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
|
||||
|
|
@ -22,6 +55,14 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
if (type === "slave_chain" || type === "golden_crown") {
|
||||
item.amount = Math.max(1, Number(item.amount || 0) || 1);
|
||||
}
|
||||
if (type === "toilet") {
|
||||
item.amount = 999;
|
||||
item.toiletFlash = Math.max(0, Number(item.toiletFlash || 0) || 0);
|
||||
}
|
||||
if (type === "rain_shelter") {
|
||||
item.amount = 999;
|
||||
item.rainShelterRange = Math.max(76, (item.r || 50) * 1.58);
|
||||
}
|
||||
if (type === "bed") {
|
||||
item.comfort = rand(0.86, 1.14);
|
||||
item.wear = 0;
|
||||
|
|
@ -215,16 +256,39 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
};
|
||||
}
|
||||
|
||||
if (type === "seesaw") {
|
||||
item.amount = 999;
|
||||
item.angle = 0;
|
||||
item.seesawSeatIds = ["", ""];
|
||||
item.seesawPhase = 0;
|
||||
item.seesawTilt = 0;
|
||||
item.seesawPlayTime = 0;
|
||||
item.deletable = true;
|
||||
}
|
||||
if (type === "circuit_board") {
|
||||
item.amount = 999;
|
||||
item.angle = defaultItemAngle(type);
|
||||
item.circuitConfig = globalThis.TarinaiCircuitBoardSystem?.defaultConfig?.() || { v: 4, cols: 10, rows: 10, inputs: [], outputs: [], nodes: [], wires: [] };
|
||||
globalThis.TarinaiCircuitBoardSystem?.ensureConfig?.(item);
|
||||
item._circuitInputState = Object.create(null);
|
||||
item._circuitOutputState = Object.create(null);
|
||||
item.deletable = true;
|
||||
}
|
||||
|
||||
if (type === "pressure_switch") {
|
||||
item.pressureTarget = "tarinai";
|
||||
item.pressureThreshold = 1;
|
||||
item.pressureMin = 1;
|
||||
item.pressureMax = 300;
|
||||
item.pressureWidth = 220;
|
||||
item.pressureHeight = 160;
|
||||
item.pressureTimeStart = 360;
|
||||
item.pressureTimeEnd = 1080;
|
||||
item.signalActive = false;
|
||||
item.deletable = true;
|
||||
item.amount = 999;
|
||||
}
|
||||
|
||||
|
||||
if (type === "reciprocator") {
|
||||
item.physicsBody = {
|
||||
type: "reciprocator",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
const SCHEDULED_ITEM_TYPES = Object.freeze([
|
||||
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo",
|
||||
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
|
||||
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
|
||||
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch",
|
||||
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "first_aid", "sedative", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
|
||||
"stone", "bed", "nest_box", "pipe", "toilet", "rain_shelter", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch",
|
||||
]);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@
|
|||
function runRealtime(worldRef, state, dt) {
|
||||
let ran = 0;
|
||||
const now = worldRef.time || 0;
|
||||
const nextRealtime = [];
|
||||
for (const item of state.realtime || []) {
|
||||
const realtime = state.realtime || [];
|
||||
let write = 0;
|
||||
const len = realtime.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const item = realtime[i];
|
||||
if (!item || item.dead) continue;
|
||||
const interval = itemUpdateInterval(item);
|
||||
if (!Number.isFinite(interval)) {
|
||||
|
|
@ -123,13 +126,14 @@
|
|||
item._schedulerLastAt = now;
|
||||
ran += 1;
|
||||
const nextInterval = itemUpdateInterval(item);
|
||||
if (Number.isFinite(nextInterval) && nextInterval <= 0) nextRealtime.push(item);
|
||||
if (Number.isFinite(nextInterval) && nextInterval <= 0) realtime[write++] = item;
|
||||
else scheduleItem(state, item, now, nextInterval);
|
||||
} else {
|
||||
scheduleItem(state, item, now, interval);
|
||||
}
|
||||
}
|
||||
state.realtime = nextRealtime;
|
||||
realtime.length = write;
|
||||
state.realtime = realtime;
|
||||
worldRef._itemUpdateRealtime = state.realtime;
|
||||
return ran;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,24 @@
|
|||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "moon", "purple"]
|
||||
},
|
||||
"first_aid": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "cross_tablet",
|
||||
"palette": { "fill": "#fff1f1", "stroke": "rgba(180,55,65,0.78)", "highlight": "rgba(220,64,78,0.82)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "cross", "red"]
|
||||
},
|
||||
"sedative": {
|
||||
"renderer": "split_pill",
|
||||
"shape": "calm_tablet",
|
||||
"palette": { "fill": "#edf7ff", "stroke": "rgba(58,104,142,0.78)", "highlight": "rgba(92,142,174,0.82)" },
|
||||
"scale": 1,
|
||||
"uiPreviewScale": 1,
|
||||
"fieldScale": 1,
|
||||
"accessibleShapeTraits": ["tablet", "calm", "blue"]
|
||||
},
|
||||
"laxative": {
|
||||
"renderer": "laxative_tablet",
|
||||
"shape": "rounded_tablet_zunchi_motif",
|
||||
|
|
|
|||
32
js/items.js
32
js/items.js
|
|
@ -281,7 +281,25 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
|
|||
const localR = Math.max(1, Number(r) || 10);
|
||||
const compact = !!opts.compact;
|
||||
ctx.save();
|
||||
if (key === "food" || key === "sweet" || key === "love_mochi" || key === "fight_mochi") {
|
||||
if (key === "food") {
|
||||
ctx.fillStyle = "#b97842";
|
||||
ctx.strokeStyle = "#754725";
|
||||
ctx.lineWidth = Math.max(1.2, localR * 0.14);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, localR * 0.16, localR * 1.18, localR * 0.48, 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = "#d9aa70";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -localR * 0.02, localR * 0.92, localR * 0.42, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
const dots = compact ? 5 : 7;
|
||||
ctx.fillStyle = "#7d4d2c";
|
||||
for (let i = 0; i < dots; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(randSeed(localSeed + i, -localR * 0.62, localR * 0.62), randSeed(localSeed + i + 9, -localR * 0.25, localR * 0.20), Math.max(1.1, localR * 0.14), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (key === "sweet" || key === "love_mochi" || key === "fight_mochi") {
|
||||
const sweet = key === "sweet";
|
||||
const loveMochi = key === "love_mochi";
|
||||
const fightMochi = key === "fight_mochi";
|
||||
|
|
@ -319,14 +337,6 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
|
|||
ctx.moveTo(-localR * 0.44, localR * 0.42);
|
||||
ctx.lineTo(localR * 0.44, -localR * 0.42);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillStyle = "#fff7dc";
|
||||
const dots = compact ? 4 : 5;
|
||||
for (let i = 0; i < dots; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(randSeed(localSeed + i, -localR * 0.55, localR * 0.55), randSeed(localSeed + i + 7, -localR * 0.34, localR * 0.34), Math.max(1.0, localR * 0.12), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
} else if (key === "grass") {
|
||||
ctx.strokeStyle = "#5fa53f";
|
||||
|
|
@ -586,6 +596,8 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
nest_box: 0.78,
|
||||
pipe: 0.84,
|
||||
signboard: 0.76,
|
||||
toilet: 0.45,
|
||||
rain_shelter: 0.62,
|
||||
duplicator: 0.82,
|
||||
fence_v: 0.70,
|
||||
fence_h: 0.70,
|
||||
|
|
@ -611,7 +623,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
|
|||
if (isConsumableSpriteType(type)) scale *= 1.95;
|
||||
else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85;
|
||||
else if (type === "duplicator") scale *= 1.55;
|
||||
else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu"].includes(type)) scale *= 1.08;
|
||||
else if (["signboard", "toilet", "rain_shelter", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu"].includes(type)) scale *= 1.08;
|
||||
else if (isParamEffectItemType(type)) scale *= 1.35;
|
||||
if (watermark) scale *= 1.42;
|
||||
ctx.scale(scale, scale);
|
||||
|
|
|
|||
15
js/main.js
15
js/main.js
|
|
@ -16,8 +16,21 @@ function ensureWorldBootstrap() {
|
|||
let world = ensureWorldBootstrap();
|
||||
let last = nowSec();
|
||||
let statsTimer = 0;
|
||||
let statsRefreshPending = false;
|
||||
window.__tarinaiFps = 0;
|
||||
|
||||
function scheduleStatsRefresh() {
|
||||
if (statsRefreshPending) return;
|
||||
if (typeof colonyStatsUiVisible === "function" && !colonyStatsUiVisible()) return;
|
||||
statsRefreshPending = true;
|
||||
const run = () => {
|
||||
statsRefreshPending = false;
|
||||
if (!document.hidden) renderStats();
|
||||
};
|
||||
if (typeof window.requestIdleCallback === "function") window.requestIdleCallback(run, { timeout: 900 });
|
||||
else window.setTimeout(run, 0);
|
||||
}
|
||||
|
||||
const LOADING_SPRITES = SPRITES
|
||||
.filter(asset => asset && !asset.actionOnly)
|
||||
.slice(0, 11)
|
||||
|
|
@ -96,7 +109,7 @@ function loop() {
|
|||
statsTimer += dt;
|
||||
const statsInterval = 2.5;
|
||||
if (statsTimer > statsInterval) {
|
||||
renderStats();
|
||||
scheduleStatsRefresh();
|
||||
statsTimer = 0;
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
|
||||
(function (global) {
|
||||
const footprints = global.TarinaiCollisionFootprints;
|
||||
function num(v, fallback = 0) {
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
});
|
||||
|
||||
function rectCorners(rect, padding = 0) {
|
||||
return footprints?.rectCorners?.(rect, padding) || [];
|
||||
|
|
@ -324,6 +324,13 @@
|
|||
return 0;
|
||||
}
|
||||
|
||||
function motionLevel(item, dt = 0.016) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return 0;
|
||||
const speed = Math.abs(signedDrive(item));
|
||||
if (item.type === "rotator" || item.type === "poison_block") return speed * Math.max(48, extent(item));
|
||||
return speed;
|
||||
}
|
||||
|
||||
function pointVelocity(item, x, y) {
|
||||
if (!item) return { x: 0, y: 0 };
|
||||
if (item.type === "rotator" || item.type === "poison_block") {
|
||||
|
|
@ -905,7 +912,7 @@
|
|||
const rects = obstacleRects(item);
|
||||
if (!rects.length) return false;
|
||||
const queryRadius = radius || reach(item) + 96;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, queryRadius, true) || worldRef.nearbyObstacles?.(item.x, item.y, queryRadius, false) || worldRef.items || [];
|
||||
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, queryRadius, true) || worldRef.nearbyObstacles?.(item.x, item.y, queryRadius, false) || worldRef.items || [];
|
||||
let changed = false;
|
||||
let contacts = 0;
|
||||
for (const other of source) {
|
||||
|
|
@ -1025,7 +1032,7 @@
|
|||
function fenceRectsNear(item, worldRef, radius) {
|
||||
const out = [];
|
||||
if (!item || !worldRef?.items) return out;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
|
||||
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
|
||||
for (const other of source) {
|
||||
if (!other || other === item || other.dead || isMechanicalType(other)) continue;
|
||||
if (other.type === "gate_fence" && other.gateOpen) continue;
|
||||
|
|
@ -1036,7 +1043,7 @@
|
|||
|
||||
function hasFenceNearby(item, worldRef, radius) {
|
||||
if (!item || !worldRef?.items) return false;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
|
||||
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, radius, true) || worldRef.items;
|
||||
for (const other of source) {
|
||||
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
|
||||
if (other.type === "gate_fence" && other.gateOpen) continue;
|
||||
|
|
@ -1081,7 +1088,7 @@
|
|||
function hasInteractionCandidates(item, worldRef, radius = null) {
|
||||
if (!item || !worldRef) return false;
|
||||
const r = radius || reach(item) + 80;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, r, true) || worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.items || [];
|
||||
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, r, true) || worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.items || [];
|
||||
for (const other of source) {
|
||||
if (!other || other === item || other.dead) continue;
|
||||
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type) || isPhysicalCircleContactObject(other)) return true;
|
||||
|
|
@ -1094,7 +1101,7 @@
|
|||
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
|
||||
let changed = false;
|
||||
const radius = reach(item) + 80;
|
||||
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
|
||||
const source = worldRef.nearbyNonGrassItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
|
||||
for (const other of source) {
|
||||
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
|
||||
changed = resolvePair(item, other, dt, worldRef) || changed;
|
||||
|
|
@ -1383,8 +1390,8 @@
|
|||
function isMechanicallyActive(item) {
|
||||
if (!item || item.dead || !isMechanicalType(item)) return false;
|
||||
if (item.playerHeld || item._heldByPlayer) return false;
|
||||
if (item.type === "rotator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.angular, ps(item, "spin", 0))) > 0.004;
|
||||
if (item.type === "reciprocator") return isPowered(item) || Math.abs(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0))) > 0.08;
|
||||
if (item.type === "rotator") return motionLevel(item) > 0.55;
|
||||
if (item.type === "reciprocator") return motionLevel(item) > 0.08;
|
||||
if (item.type === "poison_block") return passiveItemAwake(item);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1396,10 +1403,17 @@
|
|||
const cacheKey = `${worldRef.itemBucketRebuildsTotal || 0}:${maxItems}`;
|
||||
const cached = worldRef._mechanicalBodiesCache;
|
||||
if (cached?.key === cacheKey && Array.isArray(cached.items)) {
|
||||
return cached.items.filter(item => item && !item.dead);
|
||||
let write = 0;
|
||||
for (let read = 0; read < cached.items.length; read += 1) {
|
||||
const item = cached.items[read];
|
||||
if (item && !item.dead) cached.items[write++] = item;
|
||||
}
|
||||
cached.items.length = write;
|
||||
return cached.items;
|
||||
}
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const seen = worldRef._mechanicalBodiesSeen || (worldRef._mechanicalBodiesSeen = new Set());
|
||||
seen.clear();
|
||||
const add = (item) => {
|
||||
if (!item || item.dead || seen.has(item)) return false;
|
||||
item.world = worldRef;
|
||||
|
|
@ -1425,7 +1439,7 @@
|
|||
}
|
||||
if (out.length >= maxItems) break;
|
||||
}
|
||||
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out.slice() };
|
||||
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out };
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -1473,14 +1487,17 @@
|
|||
|
||||
function buildMechanicalFrame(worldRef, opts = {}) {
|
||||
const bodies = Array.isArray(opts.bodies) ? opts.bodies : collectMechanicalBodies(worldRef, opts);
|
||||
const activeSet = opts.activeSet || new Set();
|
||||
const activeSet = opts.activeSet || (worldRef?._mechanicalActiveSet || (worldRef._mechanicalActiveSet = new Set()));
|
||||
if (!opts.activeSet) activeSet.clear();
|
||||
if (!opts.activeSet) {
|
||||
for (const item of bodies) if (isMechanicallyActive(item)) activeSet.add(item);
|
||||
}
|
||||
const focus = opts.focus !== false && worldRef?.nearbyObstacles && bodies.length > Math.max(36, Number(opts.focusThreshold || 48) || 48) && activeSet.size > 0 && activeSet.size < bodies.length * 0.65;
|
||||
let source = bodies;
|
||||
if (focus) {
|
||||
const chosen = new Set(activeSet);
|
||||
const chosen = worldRef._mechanicalFocusedSet || (worldRef._mechanicalFocusedSet = new Set());
|
||||
chosen.clear();
|
||||
for (const item of activeSet) chosen.add(item);
|
||||
for (const item of activeSet) {
|
||||
const radius = Math.max(90, reach(item) + 128);
|
||||
const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || [];
|
||||
|
|
@ -1489,11 +1506,15 @@
|
|||
chosen.add(other);
|
||||
}
|
||||
}
|
||||
source = Array.from(chosen);
|
||||
source = worldRef._mechanicalFocusedSource || (worldRef._mechanicalFocusedSource = []);
|
||||
source.length = 0;
|
||||
for (const item of chosen) source.push(item);
|
||||
}
|
||||
const pad = Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6;
|
||||
const records = [];
|
||||
const byItem = new Map();
|
||||
const records = worldRef?._mechanicalFrameRecords || (worldRef._mechanicalFrameRecords = []);
|
||||
const byItem = worldRef?._mechanicalFrameByItem || (worldRef._mechanicalFrameByItem = new Map());
|
||||
records.length = 0;
|
||||
byItem.clear();
|
||||
let active = activeSet.size;
|
||||
let collidable = 0;
|
||||
for (const item of source) {
|
||||
|
|
@ -1527,7 +1548,9 @@
|
|||
// bodies plus nearby mechanical neighbors instead of scanning every static
|
||||
// collider in the field.
|
||||
if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all;
|
||||
const chosen = new Set(active.map(r => r.item));
|
||||
const chosen = worldRef._mechanicalBroadphaseChosen || (worldRef._mechanicalBroadphaseChosen = new Set());
|
||||
chosen.clear();
|
||||
for (const r of active) chosen.add(r.item);
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
for (const r of active) {
|
||||
const item = r.item;
|
||||
|
|
@ -1538,7 +1561,8 @@
|
|||
chosen.add(other);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
const out = worldRef._mechanicalBroadphaseRecords || (worldRef._mechanicalBroadphaseRecords = []);
|
||||
out.length = 0;
|
||||
for (const item of chosen) {
|
||||
const cached = frame.byItem.get(item);
|
||||
const rec = cached || makeBodyRecord(item, frame.activeSet, Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6);
|
||||
|
|
@ -1547,9 +1571,10 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
function buildColliderGrid(records, opts = {}) {
|
||||
function buildColliderGrid(records, opts = {}, gridScratch = null) {
|
||||
const cellSize = Math.max(96, Number(opts.cellSize || 156) || 156);
|
||||
const grid = new Map();
|
||||
const grid = gridScratch || new Map();
|
||||
grid.clear();
|
||||
const maxCellsPerBody = Math.max(4, Number(opts.maxCellsPerBody || 64) || 64);
|
||||
let inserted = 0;
|
||||
for (const rec of records || []) {
|
||||
|
|
@ -1607,12 +1632,13 @@
|
|||
if (!frame || frame.collidable < 2 || frame.active <= 0) return { solved: 0, checked: 0, pairs: 0, candidates: 0, gridCells: 0 };
|
||||
const records = Array.isArray(opts.records) ? opts.records : broadphaseCandidateRecords(worldRef, frame, opts);
|
||||
if (records.length < 2) return { solved: 0, checked: 0, pairs: 0, candidates: records.length, gridCells: 0 };
|
||||
const gridInfo = buildColliderGrid(records, opts);
|
||||
const gridInfo = buildColliderGrid(records, opts, worldRef._mechanicalColliderGrid || (worldRef._mechanicalColliderGrid = new Map()));
|
||||
const grid = gridInfo.grid;
|
||||
const seen = Object.create(null);
|
||||
const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240);
|
||||
const maxCandidates = maxPairs >= 100000000 ? Number.POSITIVE_INFINITY : Math.max(maxPairs + 48, Math.min(900, maxPairs * 3));
|
||||
const pairCandidates = [];
|
||||
const pairCandidates = worldRef._mechanicalPairCandidates || (worldRef._mechanicalPairCandidates = []);
|
||||
pairCandidates.length = 0;
|
||||
let pairs = 0;
|
||||
for (const bucket of grid.values()) {
|
||||
for (let i = 0; i < bucket.length; i += 1) {
|
||||
|
|
@ -1797,7 +1823,7 @@
|
|||
const a = itemAngle(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 + side * 0.32, -1.6, 1.6), "pair-spin-response");
|
||||
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 - side * 0.32, -1.6, 1.6), "pair-spin-response");
|
||||
commitBody(item);
|
||||
worldRef?.markSpatialDirty?.("poke-passive-rotator");
|
||||
return true;
|
||||
|
|
@ -1806,7 +1832,7 @@
|
|||
const a = axis(item);
|
||||
const pose = bodyPose(item);
|
||||
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1);
|
||||
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 + side * 34, -115, 115), "pair-slide-response");
|
||||
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 - side * 34, -115, 115), "pair-slide-response");
|
||||
commitBody(item);
|
||||
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
|
||||
return true;
|
||||
|
|
@ -1827,6 +1853,8 @@
|
|||
poisonHazardRects,
|
||||
extent,
|
||||
reach,
|
||||
signedDrive,
|
||||
motionLevel,
|
||||
resetAnchor,
|
||||
axis,
|
||||
railAxisAngle,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@
|
|||
simulationThrottled: false,
|
||||
creatureCadenceScale: 0,
|
||||
creatureBackgroundBudget: 1000000000,
|
||||
pressureCreatureFullBudget: 1000000000,
|
||||
pressureCreatureMotionBudget: 1000000000,
|
||||
pressureObstacleContactBudget: 1000000000,
|
||||
collisionSourceBudget: 1000000000,
|
||||
collisionImpactBudget: 1000000000,
|
||||
itemCadenceScale: 0,
|
||||
scheduledItemBudget: 1000000000,
|
||||
aiBudget: 1000000000,
|
||||
|
|
@ -61,6 +66,11 @@
|
|||
simulationThrottled: true,
|
||||
creatureCadenceScale: 1,
|
||||
creatureBackgroundBudget: 36,
|
||||
pressureCreatureFullBudget: 36,
|
||||
pressureCreatureMotionBudget: 58,
|
||||
pressureObstacleContactBudget: 14,
|
||||
collisionSourceBudget: 36,
|
||||
collisionImpactBudget: 20,
|
||||
itemCadenceScale: 1,
|
||||
scheduledItemBudget: 30,
|
||||
aiBudget: 10,
|
||||
|
|
@ -89,6 +99,11 @@
|
|||
simulationThrottled: true,
|
||||
creatureCadenceScale: 1.55,
|
||||
creatureBackgroundBudget: 16,
|
||||
pressureCreatureFullBudget: 18,
|
||||
pressureCreatureMotionBudget: 30,
|
||||
pressureObstacleContactBudget: 7,
|
||||
collisionSourceBudget: 18,
|
||||
collisionImpactBudget: 10,
|
||||
itemCadenceScale: 1.55,
|
||||
scheduledItemBudget: 14,
|
||||
aiBudget: 6,
|
||||
|
|
@ -119,7 +134,7 @@
|
|||
let fpsAccumFrames = 0;
|
||||
let smoothedFps = 0;
|
||||
let pendingProfileId = "";
|
||||
let pendingProfileSamples = 0;
|
||||
let pendingProfileSeconds = 0;
|
||||
|
||||
function bucketFor(label) {
|
||||
let b = buckets.get(label);
|
||||
|
|
@ -225,13 +240,19 @@
|
|||
|
||||
function chooseAutoProfile(fps) {
|
||||
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
|
||||
if (autoProfileId === "full") return fps < 51 ? "balanced" : "full";
|
||||
if (autoProfileId === "aggressive") return fps >= 45 ? "balanced" : "aggressive";
|
||||
if (fps >= 57) return "full";
|
||||
if (fps < 38) return "aggressive";
|
||||
// Wide enter/exit bands prevent profile flapping around a single FPS line.
|
||||
if (autoProfileId === "full") return fps < 48 ? "balanced" : "full";
|
||||
if (autoProfileId === "aggressive") return fps > 45 ? "balanced" : "aggressive";
|
||||
if (fps < 30) return "aggressive";
|
||||
if (fps > 56) return "full";
|
||||
return "balanced";
|
||||
}
|
||||
|
||||
function transitionHoldSeconds(fromId, toId) {
|
||||
const rank = { full: 2, balanced: 1, aggressive: 0 };
|
||||
return (rank[toId] ?? 1) < (rank[fromId] ?? 1) ? 5 : 15;
|
||||
}
|
||||
|
||||
function applyAutoProfile(nextId) {
|
||||
if (!profiles[nextId] || nextId === autoProfileId) return false;
|
||||
const before = activeProfile();
|
||||
|
|
@ -251,28 +272,29 @@
|
|||
fpsAccumSeconds += dt;
|
||||
fpsAccumFrames += 1;
|
||||
if (fpsAccumSeconds < 0.75) return;
|
||||
const sampledFps = fpsAccumFrames / Math.max(0.001, fpsAccumSeconds);
|
||||
const sampleSeconds = fpsAccumSeconds;
|
||||
const sampledFps = fpsAccumFrames / Math.max(0.001, sampleSeconds);
|
||||
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
|
||||
global.__tarinaiFps = Math.round(smoothedFps);
|
||||
fpsAccumSeconds = 0;
|
||||
fpsAccumFrames = 0;
|
||||
if (visualSettings.details !== "auto") return;
|
||||
const desired = chooseAutoProfile(sampledFps);
|
||||
const desired = chooseAutoProfile(smoothedFps);
|
||||
if (desired === autoProfileId) {
|
||||
pendingProfileId = "";
|
||||
pendingProfileSamples = 0;
|
||||
pendingProfileSeconds = 0;
|
||||
return;
|
||||
}
|
||||
if (pendingProfileId !== desired) {
|
||||
pendingProfileId = desired;
|
||||
pendingProfileSamples = 1;
|
||||
pendingProfileSeconds = sampleSeconds;
|
||||
return;
|
||||
}
|
||||
pendingProfileSamples += 1;
|
||||
if (pendingProfileSamples >= 2) {
|
||||
pendingProfileSeconds += sampleSeconds;
|
||||
if (pendingProfileSeconds >= transitionHoldSeconds(autoProfileId, desired)) {
|
||||
applyAutoProfile(desired);
|
||||
pendingProfileId = "";
|
||||
pendingProfileSamples = 0;
|
||||
pendingProfileSeconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +354,7 @@
|
|||
}
|
||||
function dprScaleValue() { return activeProfile().dprScale; }
|
||||
function performanceProfile() { return activeProfile(); }
|
||||
function diagnosticsEnabled() { return debugEnabled; }
|
||||
function simulationOptimizationTier() { return activeProfile().id; }
|
||||
function consumeResizeRequest() {
|
||||
const v = resizeRequested;
|
||||
|
|
@ -389,6 +412,7 @@
|
|||
setVisualSetting,
|
||||
dprScale: dprScaleValue,
|
||||
performanceProfile,
|
||||
diagnosticsEnabled,
|
||||
simulationOptimizationTier,
|
||||
consumeResizeRequest,
|
||||
setMetric,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// Shared tiny helpers for positional projection used by links, mechanical
|
||||
// bodies, and circle/circle collision code.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; });
|
||||
|
||||
function markSpatialDirty(worldRef, item, reason = "projection", threshold = 0.35, minInterval = 0.065) {
|
||||
const world = worldRef || item?.world || null;
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@
|
|||
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
|
||||
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||
|
||||
function num(value, fallback = 0) {
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
});
|
||||
function bool(value, fallback = true) {
|
||||
if (value == null) return !!fallback;
|
||||
return !!value;
|
||||
}
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, num(value, min)));
|
||||
}
|
||||
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
|
||||
function isBodyType(typeOrItem) {
|
||||
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
|
||||
return BODY_TYPES.has(type);
|
||||
|
|
@ -271,6 +269,7 @@
|
|||
x: num(endpoint.x),
|
||||
y: num(endpoint.y),
|
||||
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
|
||||
portId: String(endpoint.portId || ""),
|
||||
fuzzyResolve: endpoint.fuzzyResolve === true,
|
||||
supportLost: endpoint.supportLost === true,
|
||||
_ref: endpoint._ref || null,
|
||||
|
|
@ -385,13 +384,16 @@
|
|||
}
|
||||
|
||||
function collectPhysicsItems(worldRef, opts = {}) {
|
||||
const bodies = [];
|
||||
const constraints = [];
|
||||
if (!worldRef?.itemsOfType) return { bodies, constraints };
|
||||
if (!worldRef?.itemsOfType) return { bodies: [], constraints: [] };
|
||||
const bodies = worldRef._physicsBodiesScratch || (worldRef._physicsBodiesScratch = []);
|
||||
const constraints = worldRef._physicsConstraintsScratch || (worldRef._physicsConstraintsScratch = []);
|
||||
bodies.length = 0;
|
||||
constraints.length = 0;
|
||||
worldRef.ensureItemBuckets?.("physics-world-system");
|
||||
const maxBodies = Math.max(1, Number(opts.maxItems || Infinity) || Infinity);
|
||||
const maxConstraints = Math.max(0, Number(opts.maxLinks || Infinity) || Infinity);
|
||||
const bodyCandidates = [];
|
||||
const bodyCandidates = worldRef._physicsBodyCandidatesScratch || (worldRef._physicsBodyCandidatesScratch = []);
|
||||
bodyCandidates.length = 0;
|
||||
for (const type of BODY_TYPES) {
|
||||
for (const item of worldRef.itemsOfType(type) || []) {
|
||||
if (!item || item.dead) continue;
|
||||
|
|
@ -473,7 +475,7 @@
|
|||
if (!ep) return null;
|
||||
const kind = ep.kind || "item";
|
||||
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
|
||||
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null };
|
||||
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null, port: String(ep.portId || "") };
|
||||
};
|
||||
return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } };
|
||||
}
|
||||
|
|
@ -510,7 +512,7 @@
|
|||
const kind = ep.kind || "item";
|
||||
const refIdx = Number(ep.refIdx);
|
||||
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null;
|
||||
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
|
||||
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), portId: String(ep.port || ""), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
|
||||
};
|
||||
const midArr = Array.isArray(p.mid) ? p.mid : [];
|
||||
item.physicsConstraint = normalizeConstraint({ type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : (item.type === "rod" ? "rigid-distance" : (item.type === "spring" ? "spring-distance" : "signal-wire"))), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
// of placement blocking. Render and actual placement both delegate here so a
|
||||
// green preview means the same footprint the click will try to place.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) {
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
});
|
||||
function itemRadius(type, fallback = 12) {
|
||||
return global.itemRadiusFor(type, fallback);
|
||||
}
|
||||
|
|
@ -21,6 +21,28 @@
|
|||
function toolSizeName(worldRef, type) {
|
||||
return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium");
|
||||
}
|
||||
function pickMode(worldRef) {
|
||||
const value = String(worldRef?.toolPickMode || "free").toLowerCase();
|
||||
if (value === "grid") return "grid40"; // compatibility with v39.16.34
|
||||
return new Set(["grid20", "grid40", "grid60"]).has(value) ? value : "free";
|
||||
}
|
||||
function gridStep(worldRef) {
|
||||
const mode = pickMode(worldRef);
|
||||
if (mode === "grid20") return 20;
|
||||
if (mode === "grid60") return 60;
|
||||
if (mode === "grid40") return 40;
|
||||
return 0;
|
||||
}
|
||||
function snapPoint(worldRef, x = 0, y = 0) {
|
||||
const step = gridStep(worldRef);
|
||||
if (!step) return { x: num(x), y: num(y), snapped: false, step: 0 };
|
||||
return {
|
||||
x: Math.round(num(x) / step) * step,
|
||||
y: Math.round(num(y) / step) * step,
|
||||
snapped: true,
|
||||
step,
|
||||
};
|
||||
}
|
||||
function isRotatable(type) {
|
||||
return global.isRotatableItemType(type);
|
||||
}
|
||||
|
|
@ -30,14 +52,13 @@
|
|||
function rectOutside(worldRef, rect, itemType = "") {
|
||||
if (!worldRef || !rect) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const fence = global.isFenceItemType?.(itemType);
|
||||
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < (fence ? 0 : pad) || num(rect.bottom) > (worldRef.h || 0) - (fence ? 0 : pad);
|
||||
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < 0 || num(rect.bottom) > (worldRef.h || 0);
|
||||
}
|
||||
function circleOutside(worldRef, x, y, radius) {
|
||||
if (!worldRef) return true;
|
||||
const pad = global.CONFIG?.worldPadding || 30;
|
||||
const r = Math.max(0, num(radius));
|
||||
return num(x) - r < pad || num(y) - r < pad || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad;
|
||||
return num(x) - r < pad || num(y) - r < 0 || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0);
|
||||
}
|
||||
function orientedAabb(rect) {
|
||||
if (!rect) return null;
|
||||
|
|
@ -144,12 +165,24 @@
|
|||
top = Math.max(top, num(item.y) - num(aabb.top));
|
||||
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
|
||||
}
|
||||
const fence = global.isFenceItemType?.(item.type);
|
||||
const yPad = fence ? 0 : pad;
|
||||
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, yPad + top, (worldRef.h || 0) - yPad - bottom) : Math.max(yPad + top, Math.min((worldRef.h || 0) - yPad - bottom, y)) };
|
||||
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, top, (worldRef.h || 0) - bottom) : Math.max(top, Math.min((worldRef.h || 0) - bottom, y)) };
|
||||
}
|
||||
const radius = Math.max(14, (num(item.r, itemRadius(item.type, 12)) || itemRadius(item.type, 12)) * (item.type === "bed" ? 1.55 : 1.25));
|
||||
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, pad + radius, (worldRef.h || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.h || 0) - pad - radius, y)) };
|
||||
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, radius, (worldRef.h || 0) - radius) : Math.max(radius, Math.min((worldRef.h || 0) - radius, y)) };
|
||||
}
|
||||
function overlayForItem(item) {
|
||||
if (!item || item.dead) return null;
|
||||
if (item.type === "toilet") {
|
||||
const footprint = global.TarinaiToiletSandSystem?.footprint?.(item) || null;
|
||||
if (footprint) return { shape: "roundedRect", ...footprint };
|
||||
}
|
||||
if (item.type === "rain_shelter") {
|
||||
const footprint = global.TarinaiParasolSystem?.parasolFootprint?.(item) || null;
|
||||
if (footprint) return { shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY, halfWidth: footprint.radiusX, halfHeight: footprint.radiusY, cornerRadius: Math.max(8, Math.min(18, footprint.radiusY * 0.32)) };
|
||||
}
|
||||
const def = global.toolDefinition?.(item.type) || null;
|
||||
if (def?.collisionShape === "circle") return { shape: "circle", centerX: num(item.x), centerY: num(item.y), radius: Math.max(8, num(item.r, itemRadius(item.type, 12))) };
|
||||
return null;
|
||||
}
|
||||
function geometryFor(worldRef, item) {
|
||||
if (!worldRef || !item) return null;
|
||||
|
|
@ -174,6 +207,7 @@
|
|||
r: item.r || radius,
|
||||
rect: geometry.rect || null,
|
||||
rects,
|
||||
overlay: overlayForItem(item),
|
||||
boundsBlocked: bounds,
|
||||
blocked: opts.skipBlocked ? bounds : (bounds || Boolean(worldRef.placementBlocked?.(item) || worldRef.fencePlacementBlocked?.(item))),
|
||||
};
|
||||
|
|
@ -182,7 +216,8 @@
|
|||
const p = worldRef?.pointer;
|
||||
const type = toolTypeFor(worldRef);
|
||||
if (!p?.inside || !type) return null;
|
||||
const x = num(p.x), y = num(p.y);
|
||||
const snapped = snapPoint(worldRef, p.x, p.y);
|
||||
const x = num(snapped.x), y = num(snapped.y);
|
||||
const tmp = makePreviewItem(worldRef, type, x, y);
|
||||
if (!tmp) return null;
|
||||
const r = tmp.r;
|
||||
|
|
@ -203,7 +238,7 @@
|
|||
const rectsForBounds = geometry.rects && geometry.rects.length ? geometry.rects : (geometry.rect ? [geometry.rect] : null);
|
||||
const ownBounds = extra.boundsRadius ? circleOutside(worldRef, x, y, extra.boundsRadius) : boundsBlocked(worldRef, tmp, { rects: rectsForBounds });
|
||||
const blocked = ownBounds || Boolean(worldRef.placementBlocked?.(tmp));
|
||||
return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp };
|
||||
return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], overlay: overlayForItem(tmp), influence: extra.influence || null, blocked, item: tmp };
|
||||
}
|
||||
global.TarinaiPlacementPreviewSystem = Object.freeze({
|
||||
applyToolOrientation,
|
||||
|
|
@ -211,5 +246,8 @@
|
|||
clampPointFor,
|
||||
forItem: previewForItem,
|
||||
forWorld: previewForWorld,
|
||||
gridStep,
|
||||
pickMode,
|
||||
snapPoint,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
519
js/render.js
519
js/render.js
|
|
@ -175,16 +175,59 @@ function strokeWorldObstacleRect(ctx, rect) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function drawWireConnectableHighlights(ctx, world) {
|
||||
if (!world || !["wire", "insulated_wire"].includes(String(world.tool || ""))) return;
|
||||
const signal = globalThis.TarinaiSignalSystem;
|
||||
const circuit = globalThis.TarinaiCircuitBoardSystem;
|
||||
if (!signal?.isSignalConnectable) return;
|
||||
const pulse = 0.72 + Math.sin((world.time || 0) * 7.5) * 0.13;
|
||||
const pointer = world.pointer || {};
|
||||
ctx.save();
|
||||
ctx.setLineDash([7, 5]);
|
||||
const blockedTypes = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||
for (const item of world.items || []) {
|
||||
if (!item || item.dead || item.type === "splat" || blockedTypes.has(item.type)) continue;
|
||||
const signalCapable = signal.isSignalConnectable(item);
|
||||
if (item.type === "circuit_board") {
|
||||
for (const port of circuit?.ports?.(item) || []) {
|
||||
const point = circuit.portWorld(item, port);
|
||||
if (!point) continue;
|
||||
const hovered = pointer.inside && Math.hypot(pointer.x - point.x, pointer.y - point.y) <= 17;
|
||||
const selected = world.pendingLinkEndpoint?.id === item.id && world.pendingLinkEndpoint?.portId === port.portId;
|
||||
ctx.globalAlpha = selected ? 1 : (hovered ? 0.98 : 0.72);
|
||||
ctx.fillStyle = port.direction === "input" ? "rgba(74,177,255,0.22)" : "rgba(255,132,65,0.22)";
|
||||
ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (port.direction === "input" ? "rgba(74,177,255,0.98)" : "rgba(255,132,65,0.98)");
|
||||
ctx.lineWidth = selected || hovered ? 3.8 : 2.5;
|
||||
ctx.beginPath(); ctx.arc(point.x, point.y, (selected || hovered ? 12 : 9) * pulse, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const selected = world.pendingLinkEndpoint?.id === item.id;
|
||||
const radius = Math.max(18, Number(item.r || 22) * 1.22 + 7);
|
||||
ctx.globalAlpha = selected ? 1 : (signalCapable ? 0.66 : 0.30);
|
||||
ctx.strokeStyle = selected ? "rgba(255,235,92,1)" : (signalCapable ? "rgba(90,207,255,0.94)" : "rgba(224,232,238,0.72)");
|
||||
ctx.lineWidth = selected ? 4 : (signalCapable ? 2.4 : 1.6);
|
||||
ctx.beginPath(); ctx.ellipse(item.x, item.y, radius, radius * 0.78, 0, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawToolTargetHoverHighlight(ctx, world) {
|
||||
if (!world) return;
|
||||
const tool = String(world.tool || "");
|
||||
const target = tool === "pinch" ? world.hoverGrabTarget : (tool === "delete" ? world.hoverDeleteTarget : null);
|
||||
const mode = world.hoverGiveTarget ? "give" : (tool === "poke" ? "poke" : (tool === "pinch" ? "pinch" : (tool === "delete" ? "delete" : "")));
|
||||
const target = mode === "give" ? world.hoverGiveTarget : (mode === "poke" ? world.hoverPokeTarget : (mode === "pinch" ? world.hoverGrabTarget : (mode === "delete" ? world.hoverDeleteTarget : null)));
|
||||
if (!target || target.dead || target.playerHeld || target._heldByPlayer) return;
|
||||
const p = world.pointer || {};
|
||||
if (!p.inside) return;
|
||||
const style = tool === "delete"
|
||||
? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" }
|
||||
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" };
|
||||
const style = mode === "give"
|
||||
? { color: "rgba(224,98,142,0.98)", fill: "rgba(224,98,142,0.14)", label: "\u4e0e\u3048\u308b" }
|
||||
: (mode === "delete"
|
||||
? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "\u524a\u9664" }
|
||||
: (mode === "poke"
|
||||
? { color: "rgba(224,132,72,0.98)", fill: "rgba(224,132,72,0.13)", label: "\u3064\u3064\u304f" }
|
||||
: { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "\u3064\u307e\u3080" }));
|
||||
const pulse = 0.62 + Math.sin((world.time || 0) * 9.5) * 0.12;
|
||||
const worldLine = world.screenSizeToWorld ? world.screenSizeToWorld(4.6) : 4.6;
|
||||
const outerLine = world.screenSizeToWorld ? world.screenSizeToWorld(2.1) : 2.1;
|
||||
|
|
@ -489,8 +532,7 @@ function placementPreviewFor(world) {
|
|||
function drawPlacementPreview(ctx, world) {
|
||||
const preview = placementPreviewFor(world);
|
||||
if (!preview) return;
|
||||
const { type, x, y, r, rect, rects, influence, blocked } = preview;
|
||||
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
|
||||
const { type, x, y, r, rect, rects, overlay, influence, blocked } = preview;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = blocked ? 0.44 : 0.72;
|
||||
ctx.lineWidth = blocked ? 2.2 : 2.0;
|
||||
|
|
@ -514,7 +556,20 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
if (rect || (rects && rects.length)) {
|
||||
if (overlay?.shape === "circle") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(overlay.centerX, overlay.centerY, Math.max(1, overlay.radius), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else if (overlay?.shape === "roundedRect") {
|
||||
ctx.save();
|
||||
ctx.translate(overlay.centerX, overlay.centerY);
|
||||
ctx.rotate(overlay.angle || 0);
|
||||
roundedRect(ctx, -overlay.halfWidth, -overlay.halfHeight, overlay.halfWidth * 2, overlay.halfHeight * 2, overlay.cornerRadius || 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
} else if (rect || (rects && rects.length)) {
|
||||
if (rects && rects.length) for (const rr of rects) drawRectPreview(rr);
|
||||
else drawRectPreview(rect);
|
||||
if (type === "nest_box" && world.nestBoxSolidRects) {
|
||||
|
|
@ -524,14 +579,9 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
|
||||
}
|
||||
}
|
||||
} else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, Math.max(9, r * 1.55), Math.max(6, r * 0.86), 0, 0, Math.PI * 2);
|
||||
ctx.arc(x, y, Math.max(8, r), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
|
@ -567,12 +617,14 @@ function drawPlacementPreview(ctx, world) {
|
|||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
const markerX = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerX : x;
|
||||
const markerY = (overlay?.shape === "circle" || overlay?.shape === "roundedRect") ? overlay.centerY : y;
|
||||
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 8, y);
|
||||
ctx.lineTo(x + 8, y);
|
||||
ctx.moveTo(x, y - 8);
|
||||
ctx.lineTo(x, y + 8);
|
||||
ctx.moveTo(markerX - 8, markerY);
|
||||
ctx.lineTo(markerX + 8, markerY);
|
||||
ctx.moveTo(markerX, markerY - 8);
|
||||
ctx.lineTo(markerX, markerY + 8);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -587,7 +639,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
: (world.items || []).filter(item => item && !item.dead && item.type === "pressure_switch");
|
||||
|
||||
for (const item of switches) {
|
||||
if (!item || item.dead) continue;
|
||||
if (!item || item.dead || item.pressureTarget === "time" || item.pressureTarget === "temperature") continue;
|
||||
const isEditing = editing?.item === item;
|
||||
specs.push({
|
||||
x: Number(item.x || 0) || 0,
|
||||
|
|
@ -596,6 +648,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
height: Math.max(60, Math.min(840, Number(isEditing ? editing.height : item.pressureHeight || 160) || 160)),
|
||||
active: Boolean(item.signalActive),
|
||||
preview: false,
|
||||
shape: "rect",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -607,6 +660,7 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
height: 160,
|
||||
active: false,
|
||||
preview: true,
|
||||
shape: "rect",
|
||||
});
|
||||
}
|
||||
if (!specs.length) return;
|
||||
|
|
@ -621,7 +675,8 @@ function drawPressureSwitchRangeOverlay(ctx, world) {
|
|||
ctx.lineWidth = Math.max(1.2, line);
|
||||
ctx.setLineDash(spec.preview ? [10, 7] : [8, 8]);
|
||||
ctx.beginPath();
|
||||
ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height);
|
||||
if (spec.shape === "circle") ctx.arc(spec.x, spec.y, spec.radius, 0, Math.PI * 2);
|
||||
else ctx.rect(spec.x - spec.width * 0.5, spec.y - spec.height * 0.5, spec.width, spec.height);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
|
@ -638,6 +693,7 @@ function drawOperationToolPreview(ctx, world) {
|
|||
if (item) {
|
||||
const placementInfo = TARINAI_RENDER_GLOBAL.TarinaiPlacementPreviewSystem.forItem(world, item) || null;
|
||||
const previewRects = placementInfo?.rects || world.solidObstacleRects?.(item) || [];
|
||||
const previewOverlay = placementInfo?.overlay || null;
|
||||
const blocked = Boolean(placementInfo ? placementInfo.blocked : (world.placementBlocked?.(item) || world.fencePlacementBlocked?.(item)));
|
||||
let copyPreviewDrew = false;
|
||||
if (typeof item.draw === "function") {
|
||||
|
|
@ -666,7 +722,18 @@ function drawOperationToolPreview(ctx, world) {
|
|||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.88)" : "rgba(110,178,55,0.88)";
|
||||
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
|
||||
const rects = previewRects;
|
||||
if (rects.length) {
|
||||
if (previewOverlay?.shape === "ellipse") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(previewOverlay.centerX, previewOverlay.centerY, Math.max(1, previewOverlay.radiusX), Math.max(1, previewOverlay.radiusY), 0, 0, Math.PI * 2);
|
||||
ctx.fill(); ctx.stroke();
|
||||
} else if (previewOverlay?.shape === "roundedRect") {
|
||||
ctx.save();
|
||||
ctx.translate(previewOverlay.centerX, previewOverlay.centerY);
|
||||
ctx.rotate(previewOverlay.angle || 0);
|
||||
roundedRect(ctx, -previewOverlay.halfWidth, -previewOverlay.halfHeight, previewOverlay.halfWidth * 2, previewOverlay.halfHeight * 2, previewOverlay.cornerRadius || 8);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
} else if (rects.length) {
|
||||
for (const rr of rects) {
|
||||
if (rr.oriented) {
|
||||
ctx.save(); ctx.translate(rr.cx, rr.cy); ctx.rotate(rr.angle || 0);
|
||||
|
|
@ -676,10 +743,12 @@ function drawOperationToolPreview(ctx, world) {
|
|||
}
|
||||
} else {
|
||||
const r = Math.max(10, item.r || itemRadiusFor?.(type, 16) || 16);
|
||||
ctx.beginPath(); ctx.ellipse(p.x, p.y, r * 1.45, r * 0.95, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(item.x, item.y, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath(); ctx.moveTo(p.x - 9, p.y); ctx.lineTo(p.x + 9, p.y); ctx.moveTo(p.x, p.y - 9); ctx.lineTo(p.x, p.y + 9); ctx.stroke();
|
||||
const markerX = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerX : item.x;
|
||||
const markerY = (previewOverlay?.shape === "ellipse" || previewOverlay?.shape === "roundedRect") ? previewOverlay.centerY : item.y;
|
||||
ctx.beginPath(); ctx.moveTo(markerX - 9, markerY); ctx.lineTo(markerX + 9, markerY); ctx.moveTo(markerX, markerY - 9); ctx.lineTo(markerX, markerY + 9); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
return;
|
||||
|
|
@ -864,7 +933,7 @@ function linkIntersectsVisibleRect(it, worldRef, rect, margin = 18) {
|
|||
}
|
||||
|
||||
function isBehindSpriteLayerItem(it) {
|
||||
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed"));
|
||||
return Boolean(it && !it.dead && (isLinkRenderItem(it) || it.type === "bed" || it.type === "grass_bed" || it.type === "toilet" || it.type === "seesaw"));
|
||||
}
|
||||
|
||||
function renderLayerSortY(entity) {
|
||||
|
|
@ -878,7 +947,7 @@ function renderLayerSortY(entity) {
|
|||
if (entity.kind === "queen" || entity.kind === "worker") return y + Math.max(4, Number(entity.size || 10) || 10);
|
||||
const type = String(entity.type || "");
|
||||
const r = Math.max(0, Number(entity.r || entity.radius || entity.size || 0) || 0);
|
||||
if (type === "bed" || type === "grass_bed") return y + Math.max(8, r * 0.72);
|
||||
if (type === "bed" || type === "grass_bed" || type === "toilet") return y + Math.max(8, r * 0.72);
|
||||
if (type === "nest_box") return y + Math.max(10, r * 0.76);
|
||||
if (type === "pipe") return y + Math.max(8, r * 0.64);
|
||||
if (type === "fan") return y + Math.max(10, r * 0.95);
|
||||
|
|
@ -1008,41 +1077,109 @@ function visibleWorldRect(worldRef, margin = 160) {
|
|||
|
||||
function renderRadiusForEntity(entity) {
|
||||
if (!entity) return 40;
|
||||
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
|
||||
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "glass_wall" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
|
||||
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.reach(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem.scalar(entity, "railTravel", 150) || 150)));
|
||||
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
|
||||
if (entity.type === "pipe") return Math.max(78, (entity.r || 36) * 2.8);
|
||||
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
|
||||
if (entity.kind === "queen") return 42;
|
||||
if (entity.kind === "worker") return 26;
|
||||
if (entity.radius) return Math.max(56, entity.radius * 2.8);
|
||||
if (entity.size) return Math.max(34, entity.size * 2.4);
|
||||
return Math.max(42, (entity.r || 18) * 3.0);
|
||||
const type = String(entity.type || "");
|
||||
const dynamicRadius = type === "genkotsu" || type === "reciprocator" || type === "rotator" || type === "poison_block";
|
||||
if (!dynamicRadius && Number.isFinite(entity._renderCullRadius)) return entity._renderCullRadius;
|
||||
let value;
|
||||
if (type === "genkotsu") value = Math.max(420, (entity.r || 88) * 6.2);
|
||||
else if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") value = Math.max(96, (entity.r || 24) * 4.2);
|
||||
else if (type === "reciprocator") value = Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.reach(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem.scalar(entity, "railTravel", 150) || 150)));
|
||||
else if (type === "nest_box") value = Math.max(86, (entity.r || 34) * 3.2);
|
||||
else if (type === "pipe") value = Math.max(78, (entity.r || 36) * 2.8);
|
||||
else if (type === "ant_nest") value = Math.max(72, (entity.r || 28) * 3.0);
|
||||
else if (entity.kind === "queen") value = 42;
|
||||
else if (entity.kind === "worker") value = 26;
|
||||
else if (entity.radius) value = Math.max(56, entity.radius * 2.8);
|
||||
else if (entity.size) value = Math.max(34, entity.size * 2.4);
|
||||
else value = Math.max(42, (entity.r || 18) * 3.0);
|
||||
if (!dynamicRadius && !entity.kind && !(typeof Tarinai !== "undefined" && entity instanceof Tarinai)) entity._renderCullRadius = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPointVisibleInRect(x, y, radius, rect) {
|
||||
const r = Math.max(0, Number(radius || 0) || 0);
|
||||
const px = Number.isFinite(x) ? x : 0;
|
||||
const py = Number.isFinite(y) ? y : 0;
|
||||
return px + r >= rect.left && px - r <= rect.right && py + r >= rect.top && py - r <= rect.bottom;
|
||||
}
|
||||
|
||||
function isEntityVisibleInRect(entity, rect, extra = 0) {
|
||||
if (!entity || entity.dead) return false;
|
||||
const r = renderRadiusForEntity(entity) + extra;
|
||||
const x = Number.isFinite(entity.x) ? entity.x : 0;
|
||||
const y = Number.isFinite(entity.y) ? entity.y : 0;
|
||||
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
|
||||
return isPointVisibleInRect(entity.x, entity.y, renderRadiusForEntity(entity) + extra, rect);
|
||||
}
|
||||
|
||||
function syncCarriedPlushieToOwner(it, worldRef) {
|
||||
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
|
||||
function carriedPlushieRenderPose(it, worldRef) {
|
||||
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return null;
|
||||
const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
|
||||
if (!owner) return false;
|
||||
const ox = it.x, oy = it.y;
|
||||
it.ownerId = it.ownerId || owner.id;
|
||||
it.onHead = true;
|
||||
it.x = owner.x;
|
||||
it.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
|
||||
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
|
||||
if (!owner) return null;
|
||||
return { owner, x: owner.x, y: owner.y - Math.max(22, (owner.radius || 24) * 1.02) };
|
||||
}
|
||||
|
||||
function drawCarriedPlushieAtOwner(it, worldRef, ctx, time, lighting) {
|
||||
const pose = carriedPlushieRenderPose(it, worldRef);
|
||||
if (!pose) return false;
|
||||
ctx.save();
|
||||
ctx.translate((pose.x || 0) - (it.x || 0), (pose.y || 0) - (it.y || 0));
|
||||
it.draw(ctx, time, lighting);
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetScratchSet(set) {
|
||||
if (set && typeof set.clear === "function") {
|
||||
set.clear();
|
||||
return set;
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function resetScratchMap(map) {
|
||||
if (map && typeof map.clear === "function") {
|
||||
map.clear();
|
||||
return map;
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function pushRenderLayerEntry(stack, entity) {
|
||||
const pool = stack._layeredPool || (stack._layeredPool = []);
|
||||
const index = stack.layered.length;
|
||||
const entry = pool[index] || (pool[index] = { entity: null, y: 0, rank: 0 });
|
||||
entry.entity = entity;
|
||||
entry.y = renderLayerSortY(entity);
|
||||
entry.rank = renderLayerKindRank(entity);
|
||||
stack.layered.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function compareRenderEntries(a, b) {
|
||||
return a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0));
|
||||
}
|
||||
|
||||
function mergeSortedRenderEntries(a, b, out) {
|
||||
out.length = 0;
|
||||
let i = 0, j = 0;
|
||||
while (i < a.length && j < b.length) out.push(compareRenderEntries(a[i], b[j]) <= 0 ? a[i++] : b[j++]);
|
||||
while (i < a.length) out.push(a[i++]);
|
||||
while (j < b.length) out.push(b[j++]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareBackItems(a, b) {
|
||||
return (a._renderSortY ?? renderLayerSortY(a)) - (b._renderSortY ?? renderLayerSortY(b)) || (a.x || 0) - (b.x || 0);
|
||||
}
|
||||
|
||||
function mergeSortedBackItems(a, b, out) {
|
||||
out.length = 0;
|
||||
let i = 0, j = 0;
|
||||
while (i < a.length && j < b.length) out.push(compareBackItems(a[i], b[j]) <= 0 ? a[i++] : b[j++]);
|
||||
while (i < a.length) out.push(a[i++]);
|
||||
while (j < b.length) out.push(b[j++]);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function collectVisibleRenderStack(worldRef, visibleRect) {
|
||||
function collectVisibleRenderStack(worldRef, visibleRect, creatureSortRect = visibleRect) {
|
||||
const end = window.TarinaiPerf.begin("render.stackBuild");
|
||||
try {
|
||||
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [], champions: [] };
|
||||
|
|
@ -1053,75 +1190,112 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
|||
stack.lodgedPins.length = 0;
|
||||
if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0;
|
||||
if (stack.champions) stack.champions.length = 0;
|
||||
const seen = new Set();
|
||||
const addItem = (it) => {
|
||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
|
||||
const seen = worldRef._renderVisibleSeen = resetScratchSet(worldRef._renderVisibleSeen);
|
||||
const staticCache = worldRef._renderStaticVisibleCache || (worldRef._renderStaticVisibleCache = { key: "", backItems: [], layered: [] });
|
||||
const rectKey = `${Math.floor(visibleRect.left / 48)},${Math.floor(visibleRect.top / 48)},${Math.ceil(visibleRect.right / 48)},${Math.ceil(visibleRect.bottom / 48)}`;
|
||||
const staticKey = `${worldRef.renderStaticVersion || 0}:${rectKey}`;
|
||||
const dynamicBack = worldRef._renderDynamicBack || (worldRef._renderDynamicBack = []);
|
||||
const dynamicLayered = worldRef._renderDynamicLayered || (worldRef._renderDynamicLayered = []);
|
||||
const dynamicLayerPool = worldRef._renderDynamicLayerPool || (worldRef._renderDynamicLayerPool = []);
|
||||
dynamicBack.length = 0;
|
||||
dynamicLayered.length = 0;
|
||||
|
||||
const addItemTo = (it, backTarget, layeredTarget) => {
|
||||
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it)) return;
|
||||
const visible = isEntityVisibleInRect(it, visibleRect) || linkIntersectsVisibleRect(it, worldRef, visibleRect);
|
||||
const pose = it.isStructure && it.type === "plushie" && it.carriedById ? carriedPlushieRenderPose(it, worldRef) : null;
|
||||
const visible = pose
|
||||
? isPointVisibleInRect(pose.x, pose.y, renderRadiusForEntity(it), visibleRect)
|
||||
: (isEntityVisibleInRect(it, visibleRect) || (isLinkRenderItem(it) && linkIntersectsVisibleRect(it, worldRef, visibleRect)));
|
||||
if (!visible) return;
|
||||
seen.add(it);
|
||||
if (isPinType(it.type) && it.pinState === "lodged") {
|
||||
stack.lodgedPins.push(it);
|
||||
return;
|
||||
if (isPinType(it.type) && it.pinState === "lodged") { stack.lodgedPins.push(it); return; }
|
||||
if (isPinType(it.type) && it.pinState === "ball_lodged") { (stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it); return; }
|
||||
if (pose) { stack.carriedPlushies.push(it); return; }
|
||||
if (isBehindSpriteLayerItem(it)) { it._renderSortY = renderLayerSortY(it); backTarget.push(it); }
|
||||
else {
|
||||
const entry = { entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) };
|
||||
layeredTarget.push(entry);
|
||||
}
|
||||
if (isPinType(it.type) && it.pinState === "ball_lodged") {
|
||||
(stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it);
|
||||
return;
|
||||
}
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) {
|
||||
stack.carriedPlushies.push(it);
|
||||
return;
|
||||
}
|
||||
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
|
||||
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
|
||||
};
|
||||
const addLayered = (entity) => {
|
||||
const addDynamicEntity = (entity) => {
|
||||
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
|
||||
seen.add(entity);
|
||||
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
|
||||
const index = dynamicLayered.length;
|
||||
const entry = dynamicLayerPool[index] || (dynamicLayerPool[index] = { entity: null, y: 0, rank: 0 });
|
||||
entry.entity = entity;
|
||||
entry.y = renderLayerSortY(entity);
|
||||
entry.rank = renderLayerKindRank(entity);
|
||||
dynamicLayered.push(entry);
|
||||
if (entity.isTarinaiChampion && typeof entity.drawChampionCrown === "function") (stack.champions || (stack.champions = [])).push(entity);
|
||||
};
|
||||
|
||||
worldRef.ensureSpatial?.("render-visible");
|
||||
if (worldRef.spatial?.nearbyRectInto) {
|
||||
// Pull only visible spatial buckets, then do a render-radius check for
|
||||
// oversized sprites/effects that extend outside their origin cell.
|
||||
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
|
||||
itemScratch.length = 0;
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
|
||||
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
|
||||
for (const it of itemScratch) addItem(it);
|
||||
// Link-like items can cross the viewport while their midpoint lives in an
|
||||
// off-screen spatial cell. Pull them from their type buckets as a small,
|
||||
// deterministic fallback so a newly connected or very long link never
|
||||
// disappears merely because its cached origin is outside the query rect.
|
||||
for (const type of LINK_RENDER_TYPES) {
|
||||
for (const it of worldRef.itemsOfType?.(type) || []) addItem(it);
|
||||
if (staticCache.key !== staticKey) {
|
||||
staticCache.key = staticKey;
|
||||
staticCache.backItems.length = 0;
|
||||
staticCache.layered.length = 0;
|
||||
const staticScratch = worldRef._renderVisibleStaticScratch || (worldRef._renderVisibleStaticScratch = []);
|
||||
const cacheRect = { left: visibleRect.left - 52, top: visibleRect.top - 52, right: visibleRect.right + 52, bottom: visibleRect.bottom + 52 };
|
||||
staticScratch.length = 0;
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, cacheRect, staticScratch);
|
||||
const oldVisibleRect = visibleRect;
|
||||
visibleRect = cacheRect;
|
||||
for (const it of staticScratch) addItemTo(it, staticCache.backItems, staticCache.layered);
|
||||
visibleRect = oldVisibleRect;
|
||||
staticCache.backItems.sort(compareBackItems);
|
||||
staticCache.layered.sort(compareRenderEntries);
|
||||
seen.clear();
|
||||
}
|
||||
for (const it of staticCache.backItems) seen.add(it);
|
||||
for (const entry of staticCache.layered) seen.add(entry.entity);
|
||||
|
||||
const dynamicScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
|
||||
dynamicScratch.length = 0;
|
||||
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, dynamicScratch);
|
||||
for (const it of dynamicScratch) addItemTo(it, dynamicBack, dynamicLayered);
|
||||
|
||||
const linkScratch = worldRef._renderVisibleLinkScratch || (worldRef._renderVisibleLinkScratch = []);
|
||||
linkScratch.length = 0;
|
||||
if (worldRef.spatial.linkRenderCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.linkRenderCells, visibleRect, linkScratch);
|
||||
for (const it of linkScratch) addItemTo(it, dynamicBack, dynamicLayered);
|
||||
|
||||
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
|
||||
tarinaiScratch.length = 0;
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
|
||||
for (const t of tarinaiScratch) addLayered(t);
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect, tarinaiScratch);
|
||||
for (const t of tarinaiScratch) {
|
||||
if (!t || t.dead || seen.has(t) || !isEntityVisibleInRect(t, creatureSortRect)) continue;
|
||||
addDynamicEntity(t);
|
||||
}
|
||||
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
|
||||
antScratch.length = 0;
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
|
||||
for (const a of antScratch) addLayered(a);
|
||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, creatureSortRect, antScratch);
|
||||
for (const a of antScratch) {
|
||||
if (!a || a.dead || seen.has(a) || !isEntityVisibleInRect(a, creatureSortRect)) continue;
|
||||
addDynamicEntity(a);
|
||||
}
|
||||
|
||||
if (!Array.isArray(worldRef.carriedPlushies) || (worldRef.time || 0) >= (worldRef._nextCarriedPlushieRefreshAt || 0)) {
|
||||
const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []);
|
||||
worldRef.carriedPlushies = [];
|
||||
worldRef.carriedPlushies = worldRef.carriedPlushies || [];
|
||||
worldRef.carriedPlushies.length = 0;
|
||||
for (const it of plushies || []) if (it && !it.dead && it.isStructure && it.type === "plushie" && it.carriedById) worldRef.carriedPlushies.push(it);
|
||||
worldRef._nextCarriedPlushieRefreshAt = (worldRef.time || 0) + 0.45;
|
||||
}
|
||||
const plushieCandidates = worldRef.carriedPlushies;
|
||||
for (const it of plushieCandidates || []) {
|
||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
|
||||
}
|
||||
for (const it of worldRef.carriedPlushies || []) addItemTo(it, dynamicBack, dynamicLayered);
|
||||
} else {
|
||||
for (const it of worldRef.items || []) addItem(it);
|
||||
for (const t of worldRef.tarinai || []) addLayered(t);
|
||||
for (const a of worldRef.ants || []) addLayered(a);
|
||||
for (const it of worldRef.items || []) addItemTo(it, dynamicBack, dynamicLayered);
|
||||
for (const t of worldRef.tarinai || []) if (isEntityVisibleInRect(t, creatureSortRect)) addDynamicEntity(t);
|
||||
for (const a of worldRef.ants || []) if (isEntityVisibleInRect(a, creatureSortRect)) addDynamicEntity(a);
|
||||
}
|
||||
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
|
||||
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
|
||||
|
||||
// Native V8 sorting is faster than maintaining a previous-order Map plus
|
||||
// insertion sort for these already-culled render lists. Keep the hot path
|
||||
// allocation-free and let the engine optimize the comparator.
|
||||
dynamicBack.sort(compareBackItems);
|
||||
dynamicLayered.sort(compareRenderEntries);
|
||||
mergeSortedBackItems(staticCache.key === staticKey ? staticCache.backItems : [], dynamicBack, stack.backItems);
|
||||
mergeSortedRenderEntries(staticCache.key === staticKey ? staticCache.layered : [], dynamicLayered, stack.layered);
|
||||
return stack;
|
||||
} finally {
|
||||
if (end) end();
|
||||
|
|
@ -1463,35 +1637,60 @@ function drawLightRays(ctx, w, h, lighting) {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawAtmosphericLighting(ctx, w, h, lighting) {
|
||||
ctx.save();
|
||||
function redrawAtmosphericOverlay(w, h, lighting, tick) {
|
||||
if (!atmosphereOverlayCache.canvas) {
|
||||
atmosphereOverlayCache.canvas = document.createElement("canvas");
|
||||
atmosphereOverlayCache.ctx = atmosphereOverlayCache.canvas.getContext("2d");
|
||||
}
|
||||
const cw = Math.max(1, Math.ceil(w));
|
||||
const ch = Math.max(1, Math.ceil(h));
|
||||
if (atmosphereOverlayCache.w !== cw || atmosphereOverlayCache.h !== ch) {
|
||||
atmosphereOverlayCache.w = cw;
|
||||
atmosphereOverlayCache.h = ch;
|
||||
atmosphereOverlayCache.canvas.width = cw;
|
||||
atmosphereOverlayCache.canvas.height = ch;
|
||||
}
|
||||
const c = atmosphereOverlayCache.ctx;
|
||||
c.clearRect(0, 0, cw, ch);
|
||||
c.save();
|
||||
if (lighting.nightStrength > 0) {
|
||||
ctx.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
|
||||
ctx.fillStyle = "#223683";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
c.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
|
||||
c.fillStyle = "#223683";
|
||||
c.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
if (lighting.goldenStrength > 0) {
|
||||
ctx.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
|
||||
ctx.fillStyle = "#ffad68";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
c.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
|
||||
c.fillStyle = "#ffad68";
|
||||
c.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
if (lighting.noonStrength > 0) {
|
||||
ctx.globalAlpha = lighting.noonStrength * 0.035;
|
||||
ctx.fillStyle = "#fff8cf";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
c.globalAlpha = lighting.noonStrength * 0.035;
|
||||
c.fillStyle = "#fff8cf";
|
||||
c.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
ctx.restore();
|
||||
c.restore();
|
||||
|
||||
drawRadialGlow(ctx, w * 0.5, h * 0.46, Math.max(w, h) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
|
||||
drawRadialGlow(c, cw * 0.5, ch * 0.46, Math.max(cw, ch) * 0.50, lighting.nightStrength > 0.18 ? "rgba(91, 121, 255, ALPHA)" : "rgba(255, 236, 175, ALPHA)", lighting.bloom * LIGHTING_TUNING.bloomWashAlpha);
|
||||
|
||||
ctx.save();
|
||||
const vignette = ctx.createRadialGradient(w * 0.5, h * 0.46, Math.min(w, h) * 0.18, w * 0.5, h * 0.48, Math.max(w, h) * 0.76);
|
||||
c.save();
|
||||
const vignette = c.createRadialGradient(cw * 0.5, ch * 0.46, Math.min(cw, ch) * 0.18, cw * 0.5, ch * 0.48, Math.max(cw, ch) * 0.76);
|
||||
vignette.addColorStop(0, "rgba(0,0,0,0)");
|
||||
vignette.addColorStop(0.72, "rgba(0,0,0,0)");
|
||||
vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
|
||||
ctx.fillStyle = vignette;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
c.fillStyle = vignette;
|
||||
c.fillRect(0, 0, cw, ch);
|
||||
c.restore();
|
||||
atmosphereOverlayCache.tick = tick;
|
||||
}
|
||||
|
||||
function drawAtmosphericLighting(ctx, w, h, lighting) {
|
||||
// Full-screen gradients are expensive on large/high-DPI canvases. Lighting
|
||||
// changes slowly, so rebuild the overlay at 8 Hz and reuse one composited image.
|
||||
const tick = Math.floor((world?.time || 0) * 8);
|
||||
if (!atmosphereOverlayCache.canvas || atmosphereOverlayCache.w !== Math.ceil(w) || atmosphereOverlayCache.h !== Math.ceil(h) || atmosphereOverlayCache.tick !== tick) {
|
||||
redrawAtmosphericOverlay(w, h, lighting, tick);
|
||||
}
|
||||
ctx.drawImage(atmosphereOverlayCache.canvas, 0, 0, w, h);
|
||||
}
|
||||
|
||||
function drawRain(ctx, w, h, t, opts = {}) {
|
||||
|
|
@ -1553,20 +1752,37 @@ function visualEffectLimit(level) {
|
|||
return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96));
|
||||
}
|
||||
|
||||
const screenFallbackCache = { canvas: null, ctx: null, w: 0, h: 0, key: "" };
|
||||
const atmosphereOverlayCache = { canvas: null, ctx: null, w: 0, h: 0, tick: -1 };
|
||||
|
||||
function fillScreenFallback(ctx, w, h, lighting) {
|
||||
const light = lighting?.light ?? 0.7;
|
||||
ctx.save();
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, Math.max(1, h));
|
||||
if (light > 0.42) {
|
||||
bg.addColorStop(0, "#cfe3d3");
|
||||
bg.addColorStop(1, "#d9d0ad");
|
||||
} else {
|
||||
bg.addColorStop(0, "#24355f");
|
||||
bg.addColorStop(1, "#4f5d71");
|
||||
const key = `${Math.round(w)}x${Math.round(h)}:${light > 0.42 ? "day" : "night"}`;
|
||||
if (!screenFallbackCache.canvas) {
|
||||
screenFallbackCache.canvas = document.createElement("canvas");
|
||||
screenFallbackCache.ctx = screenFallbackCache.canvas.getContext("2d");
|
||||
}
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
if (screenFallbackCache.key !== key) {
|
||||
const cw = Math.max(1, Math.round(w));
|
||||
const ch = Math.max(1, Math.round(h));
|
||||
screenFallbackCache.w = cw;
|
||||
screenFallbackCache.h = ch;
|
||||
screenFallbackCache.key = key;
|
||||
screenFallbackCache.canvas.width = cw;
|
||||
screenFallbackCache.canvas.height = ch;
|
||||
const c = screenFallbackCache.ctx;
|
||||
const bg = c.createLinearGradient(0, 0, 0, ch);
|
||||
if (light > 0.42) {
|
||||
bg.addColorStop(0, "#cfe3d3");
|
||||
bg.addColorStop(1, "#d9d0ad");
|
||||
} else {
|
||||
bg.addColorStop(0, "#24355f");
|
||||
bg.addColorStop(1, "#4f5d71");
|
||||
}
|
||||
c.fillStyle = bg;
|
||||
c.fillRect(0, 0, cw, ch);
|
||||
}
|
||||
ctx.drawImage(screenFallbackCache.canvas, 0, 0, w, h);
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
|
@ -1583,7 +1799,12 @@ function render() {
|
|||
|
||||
const endSetup = renderPerfBegin("render.setup");
|
||||
const lighting = getLightingState(world);
|
||||
fillScreenFallback(ctx, screenW, screenH, lighting);
|
||||
const fieldLeft = fieldOffset.x - cameraX * viewScale;
|
||||
const fieldTop = fieldOffset.y - cameraY * viewScale;
|
||||
const fieldRight = fieldLeft + sceneW * viewScale;
|
||||
const fieldBottom = fieldTop + sceneH * viewScale;
|
||||
const fieldCoversScreen = fieldLeft <= 0 && fieldTop <= 0 && fieldRight >= screenW && fieldBottom >= screenH;
|
||||
if (!fieldCoversScreen) fillScreenFallback(ctx, screenW, screenH, lighting);
|
||||
const light = lighting.light;
|
||||
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
|
||||
renderPerfEnd(endSetup);
|
||||
|
|
@ -1610,6 +1831,10 @@ function render() {
|
|||
renderPerfEnd(endBackground);
|
||||
|
||||
const visibleRect = visibleWorldRect(world, 180);
|
||||
// Terrain and large item effects keep the wider margin, while moving creatures
|
||||
// only enter the depth-sort list near the actual viewport. Their own render
|
||||
// radius still extends the cull test beyond this smaller margin.
|
||||
const creatureSortRect = visibleWorldRect(world, 72);
|
||||
|
||||
beginFieldTransform();
|
||||
const endPreviews = renderPerfBegin("render.previews");
|
||||
|
|
@ -1619,7 +1844,7 @@ function render() {
|
|||
drawTerrainLayer(ctx, world, lighting, visibleRect);
|
||||
|
||||
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
|
||||
const renderStack = collectVisibleRenderStack(world, visibleRect);
|
||||
const renderStack = collectVisibleRenderStack(world, visibleRect, creatureSortRect);
|
||||
window.TarinaiPerf.setMetric("render.visibleBackItems", renderStack.backItems.length);
|
||||
window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length);
|
||||
const endBackItems = renderPerfBegin("render.draw.backItems");
|
||||
|
|
@ -1630,7 +1855,7 @@ function render() {
|
|||
|
||||
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
|
||||
|
||||
const drawnAttachedPinIds = new Set();
|
||||
const drawnAttachedPinIds = world._renderDrawnAttachedPinIds = resetScratchSet(world._renderDrawnAttachedPinIds);
|
||||
const drawAttachedPinsForEntity = (entity) => {
|
||||
if (!entity) return;
|
||||
if (TARINAI_RENDER_GLOBAL.TarinaiPinAttachmentSystem.drawAttachedForEntity) {
|
||||
|
|
@ -1655,12 +1880,19 @@ function render() {
|
|||
}
|
||||
};
|
||||
|
||||
const carriedPlushiesByOwner = new Map();
|
||||
const carriedPlushiesByOwner = world._renderCarriedPlushiesByOwner = resetScratchMap(world._renderCarriedPlushiesByOwner);
|
||||
const carriedOwnerLists = world._renderCarriedOwnerLists || (world._renderCarriedOwnerLists = []);
|
||||
let carriedOwnerListCount = 0;
|
||||
for (const it of renderStack.carriedPlushies) {
|
||||
if (!it?.carriedById) continue;
|
||||
const list = carriedPlushiesByOwner.get(it.carriedById) || [];
|
||||
let list = carriedPlushiesByOwner.get(it.carriedById);
|
||||
if (!list) {
|
||||
list = carriedOwnerLists[carriedOwnerListCount] || (carriedOwnerLists[carriedOwnerListCount] = []);
|
||||
carriedOwnerListCount += 1;
|
||||
list.length = 0;
|
||||
carriedPlushiesByOwner.set(it.carriedById, list);
|
||||
}
|
||||
list.push(it);
|
||||
carriedPlushiesByOwner.set(it.carriedById, list);
|
||||
}
|
||||
|
||||
const endLayered = renderPerfBegin("render.draw.layered");
|
||||
|
|
@ -1670,8 +1902,7 @@ function render() {
|
|||
// A carried plushie shares its owner's depth position instead of being
|
||||
// rendered above every entity as a global attachment layer.
|
||||
for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) {
|
||||
syncCarriedPlushieToOwner(it, world);
|
||||
it.draw(ctx, world.time, lighting);
|
||||
drawCarriedPlushieAtOwner(it, world, ctx, world.time, lighting);
|
||||
}
|
||||
}
|
||||
renderPerfEnd(endLayered);
|
||||
|
|
@ -1692,6 +1923,7 @@ function render() {
|
|||
}
|
||||
renderPerfEnd(endAttachments);
|
||||
|
||||
drawWireConnectableHighlights(ctx, world);
|
||||
drawToolTargetHoverHighlight(ctx, world);
|
||||
|
||||
const endEffects = renderPerfBegin("render.draw.effects");
|
||||
|
|
@ -1715,17 +1947,15 @@ function render() {
|
|||
}
|
||||
if (totalEffects > 180) world._renderEffectCursor = cursor;
|
||||
}
|
||||
world._effectRenderStats = {
|
||||
total: totalEffects,
|
||||
drawn: drawnEffects,
|
||||
skipped: Math.max(0, totalEffects - drawnEffects),
|
||||
budget: maxEffects,
|
||||
scanCap: effectScanCap,
|
||||
level: effectLevel,
|
||||
};
|
||||
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
|
||||
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
|
||||
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
|
||||
if (window.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||
world._effectRenderStats = {
|
||||
total: totalEffects, drawn: drawnEffects, skipped: Math.max(0, totalEffects - drawnEffects),
|
||||
budget: maxEffects, scanCap: effectScanCap, level: effectLevel,
|
||||
};
|
||||
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
|
||||
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
|
||||
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
|
||||
} else world._effectRenderStats = null;
|
||||
renderPerfEnd(endEffects);
|
||||
drawPressureSwitchRangeOverlay(ctx, world);
|
||||
ctx.restore();
|
||||
|
|
@ -1828,7 +2058,8 @@ function drawPointerItemTooltip(ctx, world) {
|
|||
const w = Math.min(maxBoxW, Math.max(92, textW + 22));
|
||||
const h = Math.max(38, 16 + lines.length * lineH);
|
||||
const x = clamp(pos.x - w / 2, 8, canvasW - w - 8);
|
||||
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8);
|
||||
const targetGapScale = 1.45;
|
||||
const y = clamp(pos.y - (target.r || 24) * targetGapScale - h, 8, canvasH - h - 8);
|
||||
ctx.fillStyle = "rgba(46,38,30,0.66)";
|
||||
ctx.strokeStyle = "rgba(255,248,220,0.74)";
|
||||
ctx.lineWidth = 1.2;
|
||||
|
|
|
|||
|
|
@ -200,8 +200,8 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
|||
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
|
||||
const label = robotTargetLabel(type);
|
||||
let removed = false;
|
||||
const cleanReason = "掃除";
|
||||
const cleanLogReason = "ロボ掃除機に吸い込まれた";
|
||||
const cleanReason = "\u6383\u9664";
|
||||
const cleanLogReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f";
|
||||
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
|
||||
triggerRobotPanic(item, target, worldRef, true);
|
||||
if (typeof target.die === "function") {
|
||||
|
|
@ -216,7 +216,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
|||
} else {
|
||||
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
|
||||
reason: "robot-cleaner",
|
||||
userReason: type === "grass_bed" ? "ロボ掃除機に片付けられた" : cleanLogReason,
|
||||
userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanLogReason,
|
||||
wake: true,
|
||||
breaker: item,
|
||||
}));
|
||||
|
|
@ -231,6 +231,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
|||
item.robotCleanerScanTimer = 0;
|
||||
item.robotCleanerCleanFlash = 0.42;
|
||||
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
|
||||
global.TarinaiAchievements?.recordRobotClean?.({ world: worldRef, robot: item, target, type });
|
||||
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
|
||||
worldRef.markItemBucketsDirty?.("robot-cleaner");
|
||||
worldRef.markSpatialDirty?.("robot-cleaner");
|
||||
|
|
|
|||
279
js/save_codec.js
279
js/save_codec.js
|
|
@ -23,6 +23,7 @@
|
|||
"itemIndex",
|
||||
"normalItems",
|
||||
"physicsItems",
|
||||
"achievementMetadata",
|
||||
]);
|
||||
let lastEncodeStats = null;
|
||||
let lastDecodeStats = null;
|
||||
|
|
@ -601,7 +602,8 @@
|
|||
|
||||
function writeWorld(writer, w = []) {
|
||||
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]);
|
||||
writer.u(w[3] || 0); // worldTick10
|
||||
writer.u(w[3] || 0); // total elapsed worldTick10
|
||||
writer.u(Math.max(0, Number(w[18] ?? 0) || 0)); // explicit elapsed completed days
|
||||
let mask = 0;
|
||||
if (valuesDiffer(w[5], -9990)) mask |= 1;
|
||||
if (valuesDiffer(w[6], 1)) mask |= 2;
|
||||
|
|
@ -609,7 +611,8 @@
|
|||
if (valuesDiffer(w[9], 0)) mask |= 8;
|
||||
if (valuesDiffer(w[10], 0)) mask |= 16;
|
||||
if (valuesDiffer(w[11], 0)) mask |= 32;
|
||||
writeBitFields(writer, [mask], [6]);
|
||||
if (valuesDiffer(w[19], 0)) mask |= 64;
|
||||
writeBitFields(writer, [mask], [7]);
|
||||
if (mask & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative
|
||||
if (mask & 2) writer.u(w[6] || 1); // generation
|
||||
if (mask & 4) writer.u(w[7] || 0); // dead count
|
||||
|
|
@ -619,11 +622,13 @@
|
|||
if (mask & 8) writer.u(w[9] || 0); // birthSerial
|
||||
if (mask & 16) writer.u(w[10] || 0); // tarinai population limit
|
||||
if (mask & 32) writer.u(w[11] || 0); // object limit
|
||||
if (mask & 64) writer.u(w[19] || 0); // manual Tarinai additions
|
||||
}
|
||||
function readWorld(reader) {
|
||||
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
|
||||
const tick = reader.u();
|
||||
const [mask] = readBitFields(reader, [6]);
|
||||
const elapsedDays = reader.u();
|
||||
const [mask] = readBitFields(reader, [7]);
|
||||
const lastBirthAt = (mask & 1) ? reader.s() : -9990;
|
||||
const generation = (mask & 2) ? reader.u() : 1;
|
||||
const deadCount = (mask & 4) ? reader.u() : 0;
|
||||
|
|
@ -631,10 +636,15 @@
|
|||
const birthSerial = (mask & 8) ? reader.u() : 0;
|
||||
const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0;
|
||||
const objectLimit = (mask & 32) ? reader.u() : 0;
|
||||
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
|
||||
const manualTarinaiAddedCount = (mask & 64) ? reader.u() : 0;
|
||||
const row = [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
|
||||
row[18] = elapsedDays;
|
||||
row[19] = manualTarinaiAddedCount;
|
||||
row[20] = BINARY_SCHEMA_VERSION;
|
||||
return row;
|
||||
}
|
||||
|
||||
function birthHash32(seed = "") {
|
||||
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
|
||||
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
|
||||
if (match) {
|
||||
const parsed = parseInt(match[1], 36);
|
||||
|
|
@ -647,7 +657,7 @@
|
|||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
});
|
||||
function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
|
||||
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1));
|
||||
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
|
||||
|
|
@ -1094,6 +1104,7 @@
|
|||
if (!indexed && (Number(endpoint.x) || 0) !== (Number(row?.[1]) || 0)) mask |= 128;
|
||||
if (!indexed && (Number(endpoint.y) || 0) !== (Number(row?.[2]) || 0)) mask |= 256;
|
||||
if (endpoint.t != null && Number.isFinite(Number(endpoint.t))) mask |= 512;
|
||||
if (endpoint.port) mask |= 1024;
|
||||
writer.u(mask);
|
||||
if (mask & 1) writer.str(endpoint.id);
|
||||
if (mask & 2) writer.str(endpoint.type);
|
||||
|
|
@ -1104,6 +1115,7 @@
|
|||
if (mask & 128) writer.s((Number(endpoint.x) || 0) - (Number(row?.[1]) || 0));
|
||||
if (mask & 256) writer.s((Number(endpoint.y) || 0) - (Number(row?.[2]) || 0));
|
||||
if (mask & 512) writer.u(endpoint.t);
|
||||
if (mask & 1024) writer.str(endpoint.port);
|
||||
}
|
||||
function readPhysicsEndpoint(reader, row = []) {
|
||||
const [present] = readBitFields(reader, [1]);
|
||||
|
|
@ -1123,7 +1135,8 @@
|
|||
const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0);
|
||||
const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0);
|
||||
const t = (mask & 512) ? reader.u() : null;
|
||||
return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t };
|
||||
const port = (mask & 1024) ? reader.str() : "";
|
||||
return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t, port };
|
||||
}
|
||||
function writePhysicsConstraintExtra(writer, extra, type, row = []) {
|
||||
const constraint = extra?.constraint && typeof extra.constraint === "object" ? extra.constraint : {};
|
||||
|
|
@ -1214,18 +1227,19 @@
|
|||
else if (type === "zunchi") writeFixedSignedDefaults(writer, list, [0, 100, 100], 3);
|
||||
else if (type === "signboard") writeSaveString(writer, list[0] || "");
|
||||
else if (type === "robot_cleaner") writeFixedSignedDefaults(writer, list, [3, 0], 2);
|
||||
else if (type === "circuit_board") writeSaveString(writer, list[0] || "{}");
|
||||
else if (type === "duplicator") writeFixedSignedDefaults(writer, list, [-1], 1);
|
||||
else if (type === "ball" || type === "balloon") writeFixedSignedDefaults(writer, list, [0, 0], 2);
|
||||
else if (type === "ant_nest") writeFixedSignedDefaults(writer, list, [0, 0], 2);
|
||||
else if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") writeFixedSignedDefaults(writer, list, [0], 1);
|
||||
else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1], 2);
|
||||
else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1, 0], 3);
|
||||
else if (type === "nest_box") writeFixedSignedDefaults(writer, list, [0, 0, 0, 0], 4);
|
||||
else if (isPinSaveType(type)) writeFixedSignedDefaults(writer, list, [0, -1, 0, 0, 0], 5);
|
||||
else if (type === "fan") {
|
||||
const angle = defaultAngleMilli(type);
|
||||
writeFixedSignedDefaults(writer, list, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
|
||||
}
|
||||
else if (type === "pressure_switch") writeFixedSignedDefaults(writer, list, [0, 1, 220, 160], 4);
|
||||
else if (type === "pressure_switch") writeFixedSignedDefaults(writer, list, [0, 0, 10, 220, 160, 360, 1080], 7);
|
||||
else if (isRotatableSaveType(type)) writeFixedSignedDefaults(writer, list, [defaultAngleMilli(type), 0], 2);
|
||||
else if (isServingFoodSaveType(type)) writeFixedSignedDefaults(writer, list, [row?.[3] || 0, row?.[3] || 0], 2);
|
||||
else writeGenericExtra(writer, list);
|
||||
|
|
@ -1241,18 +1255,19 @@
|
|||
if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3);
|
||||
if (type === "signboard") return [readSaveString(reader)];
|
||||
if (type === "robot_cleaner") return readFixedSignedDefaults(reader, [3, 0], 2);
|
||||
if (type === "circuit_board") return [readSaveString(reader)];
|
||||
if (type === "duplicator") return readFixedSignedDefaults(reader, [-1], 1);
|
||||
if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2);
|
||||
if (type === "ant_nest") return readFixedSignedDefaults(reader, [0, 0], 2);
|
||||
if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") return readFixedSignedDefaults(reader, [0], 1);
|
||||
if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1], 2);
|
||||
if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1, 0], 3);
|
||||
if (type === "nest_box") return readFixedSignedDefaults(reader, [0, 0, 0, 0], 4);
|
||||
if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5);
|
||||
if (type === "fan") {
|
||||
const angle = defaultAngleMilli(type);
|
||||
return readFixedSignedDefaults(reader, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
|
||||
}
|
||||
if (type === "pressure_switch") return readFixedSignedDefaults(reader, [0, 1, 220, 160], 4);
|
||||
if (type === "pressure_switch") return readFixedSignedDefaults(reader, [0, 0, 10, 220, 160, 360, 1080], 7);
|
||||
if (isRotatableSaveType(type)) return readFixedSignedDefaults(reader, [defaultAngleMilli(type), 0], 2);
|
||||
if (isServingFoodSaveType(type)) return readFixedSignedDefaults(reader, [row?.[3] || 0, row?.[3] || 0], 2);
|
||||
return readGenericExtra(reader);
|
||||
|
|
@ -1458,13 +1473,238 @@
|
|||
return items;
|
||||
}
|
||||
|
||||
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
|
||||
const ACHIEVEMENT_SPELL_BINARY_VERSION = 8;
|
||||
const ACHIEVEMENT_MASK_BYTES = 10;
|
||||
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17, 5, 14, 7]);
|
||||
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071, 30, 10000, 100]);
|
||||
|
||||
function finiteNumberOrNull(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function quantizedTenths(value = 0) { return Math.round((Number(value) || 0) * 10); }
|
||||
function unquantizedTenths(value = 0) { return (Number(value) || 0) / 10; }
|
||||
function varUintLength(value = 0) { return encodeVarUint(value).length; }
|
||||
function writePositiveDeltaList(writer, values = []) {
|
||||
const list = [...new Set((Array.isArray(values) ? values : []).map(value => Math.max(1, toSafeUInt(value))))].sort((a, b) => a - b);
|
||||
writer.u(list.length);
|
||||
let previous = 0;
|
||||
for (const value of list) { writer.u(value - previous); previous = value; }
|
||||
}
|
||||
function readPositiveDeltaList(reader) {
|
||||
const count = reader.u();
|
||||
const out = [];
|
||||
let previous = 0;
|
||||
for (let i = 0; i < count; i++) { previous += reader.u(); out.push(previous); }
|
||||
return out;
|
||||
}
|
||||
function achievementMaskBytesFromSpell(spell = null) {
|
||||
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
|
||||
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
|
||||
const version = Number(spell[0]);
|
||||
if (version !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version");
|
||||
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
|
||||
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0)));
|
||||
const extra = Math.max(0, Math.min(0xffff, Math.floor(Number(spell[3]) || 0)));
|
||||
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
|
||||
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
|
||||
bytes[8] = extra & 255;
|
||||
bytes[9] = (extra >>> 8) & 255;
|
||||
const baseMinute = toSafeUInt(spell[4]);
|
||||
const deltas = Array.isArray(spell[5]) ? spell[5].map(toSafeUInt) : [];
|
||||
const progress = Array.isArray(spell[6]) ? spell[6] : [];
|
||||
let unlockedCount = 0;
|
||||
for (const byte of bytes) {
|
||||
let value = byte;
|
||||
while (value) { unlockedCount += value & 1; value >>>= 1; }
|
||||
}
|
||||
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
|
||||
return { bytes, baseMinute, deltas, progress };
|
||||
}
|
||||
|
||||
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = []) {
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
|
||||
for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
|
||||
return [ACHIEVEMENT_SPELL_BINARY_VERSION, low >>> 0, high >>> 0, (Number(bytes[8]) || 0) + (Number(bytes[9]) || 0) * 256, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
|
||||
}
|
||||
function writeAchievementProgress(writer, values = [], bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
|
||||
const normalized = maxima.map((max, index) => Math.max(0, Math.min(max, toSafeUInt(values?.[index]))));
|
||||
let sparseMask = 0;
|
||||
let sparseBytes = Math.ceil(maxima.length / 8) + 1;
|
||||
normalized.forEach((value, index) => {
|
||||
if (!value) return;
|
||||
sparseMask |= 1 << index;
|
||||
sparseBytes += varUintLength(value);
|
||||
});
|
||||
const fixedBytes = Math.ceil(bits.reduce((sum, width) => sum + width, 0) / 8);
|
||||
const useSparse = sparseBytes < fixedBytes;
|
||||
writer.b(useSparse ? 1 : 0);
|
||||
if (useSparse) {
|
||||
writeBitFields(writer, [sparseMask], [maxima.length]);
|
||||
normalized.forEach((value, index) => { if (sparseMask & (1 << index)) writer.u(value); });
|
||||
} else {
|
||||
writeBitFields(writer, normalized, bits);
|
||||
}
|
||||
}
|
||||
function readAchievementProgress(reader, bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
|
||||
const sparse = Boolean(reader.b());
|
||||
if (!sparse) return readBitFields(reader, bits);
|
||||
const [mask] = readBitFields(reader, [maxima.length]);
|
||||
return maxima.map((max, index) => mask & (1 << index) ? Math.min(max, reader.u()) : 0);
|
||||
}
|
||||
function writeAchievementSpell(writer, spell = null) {
|
||||
const normalized = achievementMaskBytesFromSpell(spell);
|
||||
writer.raw(normalized.bytes);
|
||||
writer.u(normalized.baseMinute);
|
||||
for (const delta of normalized.deltas) writer.u(delta);
|
||||
writeAchievementProgress(writer, normalized.progress);
|
||||
}
|
||||
function readAchievementSpell(reader) {
|
||||
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
|
||||
let unlockedCount = 0;
|
||||
for (const byte of bytes) {
|
||||
let value = byte;
|
||||
while (value) { unlockedCount += value & 1; value >>>= 1; }
|
||||
}
|
||||
const baseMinute = reader.u();
|
||||
const deltas = [];
|
||||
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
|
||||
const progress = readAchievementProgress(reader, ACHIEVEMENT_PROGRESS_BITS, ACHIEVEMENT_PROGRESS_MAX);
|
||||
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
|
||||
}
|
||||
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
|
||||
const value = metadata && typeof metadata === "object" ? metadata : {};
|
||||
const w = Array.isArray(value.w) ? value.w : [];
|
||||
writer.b(ACHIEVEMENT_METADATA_BINARY_VERSION);
|
||||
writeBitFields(writer, [Array.isArray(value.s) ? 1 : 0], [1]);
|
||||
writePositiveDeltaList(writer, w[0]);
|
||||
writePositiveDeltaList(writer, w[1]);
|
||||
writeBitFields(writer, [
|
||||
Math.min(33, toSafeUInt(w[2])),
|
||||
Math.min(200, toSafeUInt(w[3])),
|
||||
Math.min(10, toSafeUInt(w[4])),
|
||||
], [6, 8, 4]);
|
||||
|
||||
const timeDefaults = [null, -1, null, 0, -1, -1];
|
||||
const timeValues = [w[5], w[6], w[7], w[8], w[9], w[10]];
|
||||
let timeMask = 0;
|
||||
const encodedTimes = [];
|
||||
timeValues.forEach((raw, index) => {
|
||||
const finite = finiteNumberOrNull(raw);
|
||||
const defaultValue = timeDefaults[index];
|
||||
const sameAsDefault = finite == null ? defaultValue == null : finite === defaultValue;
|
||||
if (sameAsDefault) return;
|
||||
timeMask |= 1 << index;
|
||||
encodedTimes.push(quantizedTenths(finite == null ? defaultValue || 0 : finite));
|
||||
});
|
||||
writeBitFields(writer, [timeMask], [timeDefaults.length]);
|
||||
encodedTimes.forEach(value => writer.s(value));
|
||||
|
||||
const tarRows = Array.isArray(value.t) ? value.t : [];
|
||||
const activeTarRows = [];
|
||||
for (let index = 0; index < Math.min(tarinaiCount, tarRows.length); index++) {
|
||||
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
|
||||
const feed = toSafeUInt(row[0]);
|
||||
const treatment = toSafeUInt(row[1]);
|
||||
const fightAt = finiteNumberOrNull(row[2]);
|
||||
const saunaAt = finiteNumberOrNull(row[3]);
|
||||
if (feed || treatment || fightAt != null || saunaAt != null) activeTarRows.push({ index, feed, treatment, fightAt, saunaAt });
|
||||
}
|
||||
writer.u(activeTarRows.length);
|
||||
let previousTarIndex = -1;
|
||||
for (const row of activeTarRows) { writer.u(row.index - previousTarIndex - 1); previousTarIndex = row.index; }
|
||||
for (const row of activeTarRows) { writer.u(row.feed); writer.u(row.treatment); }
|
||||
const tarFlags = [];
|
||||
for (const row of activeTarRows) tarFlags.push(row.fightAt != null ? 1 : 0, row.saunaAt != null ? 1 : 0);
|
||||
if (tarFlags.length) writeBitFields(writer, tarFlags, tarFlags.map(() => 1));
|
||||
for (const row of activeTarRows) {
|
||||
if (row.fightAt != null) writer.s(quantizedTenths(row.fightAt));
|
||||
if (row.saunaAt != null) writer.s(quantizedTenths(row.saunaAt));
|
||||
}
|
||||
|
||||
const itemRows = Array.isArray(value.i) ? value.i : [];
|
||||
const activeItemRows = [];
|
||||
for (let index = 0; index < Math.min(itemCount, itemRows.length); index++) {
|
||||
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
|
||||
const placed = Boolean(row[0]);
|
||||
const placedAt = finiteNumberOrNull(row[1]);
|
||||
if (placed || placedAt != null) activeItemRows.push({ index, placed, placedAt });
|
||||
}
|
||||
writer.u(activeItemRows.length);
|
||||
let previousItemIndex = -1;
|
||||
for (const row of activeItemRows) { writer.u(row.index - previousItemIndex - 1); previousItemIndex = row.index; }
|
||||
const itemFlags = [];
|
||||
for (const row of activeItemRows) itemFlags.push(row.placed ? 1 : 0, row.placedAt != null ? 1 : 0);
|
||||
if (itemFlags.length) writeBitFields(writer, itemFlags, itemFlags.map(() => 1));
|
||||
for (const row of activeItemRows) if (row.placedAt != null) writer.s(quantizedTenths(row.placedAt));
|
||||
|
||||
if (Array.isArray(value.s)) writeAchievementSpell(writer, value.s);
|
||||
}
|
||||
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0) {
|
||||
const version = reader.b();
|
||||
if (version !== ACHIEVEMENT_METADATA_BINARY_VERSION) throw new Error("unsupported achievement metadata schema");
|
||||
const [flags] = readBitFields(reader, [1]);
|
||||
const w = [readPositiveDeltaList(reader), readPositiveDeltaList(reader)];
|
||||
const [directFeed, robotClean, enemyAntKills] = readBitFields(reader, [6, 8, 4]);
|
||||
w.push(directFeed, robotClean, enemyAntKills);
|
||||
const timeDefaults = [null, -1, null, 0, -1, -1];
|
||||
const [timeMask] = readBitFields(reader, [timeDefaults.length]);
|
||||
for (let index = 0; index < timeDefaults.length; index++) {
|
||||
w.push(timeMask & (1 << index) ? unquantizedTenths(reader.s()) : timeDefaults[index]);
|
||||
}
|
||||
|
||||
const t = Array.from({ length: tarinaiCount }, () => [0, 0, null, null]);
|
||||
const tarCount = reader.u();
|
||||
const tarIndexes = [];
|
||||
let previousTarIndex = -1;
|
||||
for (let i = 0; i < tarCount; i++) {
|
||||
previousTarIndex += reader.u() + 1;
|
||||
if (previousTarIndex >= tarinaiCount) throw new Error("invalid achievement tarinai metadata index");
|
||||
tarIndexes.push(previousTarIndex);
|
||||
}
|
||||
const tarCounters = tarIndexes.map(() => [reader.u(), reader.u()]);
|
||||
const tarFlagWidths = Array.from({ length: tarCount * 2 }, () => 1);
|
||||
const tarFlags = tarFlagWidths.length ? readBitFields(reader, tarFlagWidths) : [];
|
||||
tarIndexes.forEach((index, rowIndex) => {
|
||||
const fightAt = tarFlags[rowIndex * 2] ? unquantizedTenths(reader.s()) : null;
|
||||
const saunaAt = tarFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
|
||||
t[index] = [tarCounters[rowIndex][0], tarCounters[rowIndex][1], fightAt, saunaAt];
|
||||
});
|
||||
|
||||
const i = Array.from({ length: itemCount }, () => [0, null]);
|
||||
const itemRowCount = reader.u();
|
||||
const itemIndexes = [];
|
||||
let previousItemIndex = -1;
|
||||
for (let row = 0; row < itemRowCount; row++) {
|
||||
previousItemIndex += reader.u() + 1;
|
||||
if (previousItemIndex >= itemCount) throw new Error("invalid achievement item metadata index");
|
||||
itemIndexes.push(previousItemIndex);
|
||||
}
|
||||
const itemFlagWidths = Array.from({ length: itemRowCount * 2 }, () => 1);
|
||||
const itemFlags = itemFlagWidths.length ? readBitFields(reader, itemFlagWidths) : [];
|
||||
itemIndexes.forEach((index, rowIndex) => {
|
||||
const placed = itemFlags[rowIndex * 2] ? 1 : 0;
|
||||
const placedAt = itemFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
|
||||
i[index] = [placed, placedAt];
|
||||
});
|
||||
const metadata = { w, t, i };
|
||||
if (flags & 1) metadata.s = readAchievementSpell(reader);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
|
||||
function summaryFromPayload(w = [], tarinaiCount = 0) {
|
||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
||||
const totalTime = (Number(w[3]) || 0) / 10;
|
||||
const day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
||||
return [day, tarinaiCount, fieldValue(w[0])];
|
||||
const totalTime = Math.max(0, (Number(w[3]) || 0) / 10);
|
||||
const explicitDays = Number(w[18]);
|
||||
const elapsedDays = Number.isFinite(explicitDays)
|
||||
? Math.max(0, Math.floor(explicitDays))
|
||||
: Math.max(0, Math.floor(totalTime / dayLength));
|
||||
return [elapsedDays + 1, tarinaiCount, fieldValue(w[0]), elapsedDays];
|
||||
}
|
||||
|
||||
function encodeBinarySnapshot(snapshot, stats = null) {
|
||||
|
|
@ -1489,6 +1729,7 @@
|
|||
tarinaiRows.forEach((row, index) => writer.category("tarinaiBasic", () => writeTarinaiPayload(writer, row || [], tarinaiMetas[index], tarCoordState, worldSeed, index)));
|
||||
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
||||
writeItemBlocks(writer, itemRows);
|
||||
writer.category("achievementMetadata", () => writeAchievementMetadata(writer, snapshot.g || null, tarinaiRows.length, itemRows.length));
|
||||
return writer.out();
|
||||
} finally {
|
||||
activeStringWriteMap = null;
|
||||
|
|
@ -1511,8 +1752,14 @@
|
|||
const tarinaiHeaders = readTarinaiBitPlane(reader, tCount);
|
||||
for (let i = 0; i < tCount; i++) t.push(readTarinaiPayload(reader, tarinaiHeaders[i], tarCoordState, String(w?.[8] || ""), i));
|
||||
const items = readItemBlocks(reader);
|
||||
let g = null;
|
||||
try {
|
||||
g = readAchievementMetadata(reader, tCount, items.length);
|
||||
} catch (_) {
|
||||
throw new Error("invalid achievement metadata in binary save");
|
||||
}
|
||||
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
|
||||
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items };
|
||||
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items, g };
|
||||
} finally {
|
||||
activeStringReadList = null;
|
||||
}
|
||||
|
|
@ -1595,6 +1842,8 @@
|
|||
global.TarinaiSaveCodec = {
|
||||
encodeSnapshot,
|
||||
decodeSnapshot,
|
||||
encodeBinarySnapshot,
|
||||
decodeBinarySnapshot,
|
||||
getLastEncodeStats,
|
||||
getLastDecodeStats,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: persistence/schema
|
||||
// Current-format identifiers. Schema 35 removes warp manual power and expands gate channels to nine.
|
||||
// Current-format identifiers. Saves are accepted only when their schema exactly matches this build.
|
||||
(function (global) {
|
||||
const ids = global.TarinaiDomainIds;
|
||||
const itemCatalog = global.TarinaiItemTypeCatalog;
|
||||
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
|
||||
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
|
||||
|
||||
const SNAPSHOT_VERSION = 30;
|
||||
const BINARY_SCHEMA_VERSION = 36;
|
||||
const SNAPSHOT_VERSION = 40;
|
||||
const BINARY_SCHEMA_VERSION = 53;
|
||||
|
||||
function moodValue(index, fallback = "relaxed") {
|
||||
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@
|
|||
if (!hash.startsWith("\u305f")) throw new Error("unsupported Japanese hash slot value");
|
||||
global.localStorage?.setItem(slotKey(slot), hash);
|
||||
if (meta) global.localStorage?.setItem(metaKey(slot), JSON.stringify({ ...meta, savedAt: Date.now() }));
|
||||
let filled = 0;
|
||||
for (let index = 1; index <= SLOT_COUNT; index += 1) {
|
||||
if (global.localStorage?.getItem(slotKey(index))) filled += 1;
|
||||
}
|
||||
global.TarinaiAchievements?.recordSaveSlotsFilled?.({ slot, filled, slotCount: SLOT_COUNT });
|
||||
}
|
||||
function readMeta(slot) {
|
||||
const raw = global.localStorage?.getItem(metaKey(slot));
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
const SLOT_COUNT = Storage.SLOT_COUNT;
|
||||
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
|
||||
|
||||
function createSnapshot(worldRef = global.world) {
|
||||
return Snapshot.createSnapshot(worldRef);
|
||||
function createSnapshot(worldRef = global.world, options = {}) {
|
||||
return Snapshot.createSnapshot(worldRef, options);
|
||||
}
|
||||
|
||||
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
|
||||
|
|
@ -53,60 +53,8 @@
|
|||
}
|
||||
|
||||
|
||||
function ensureLoadConfirmDialog() {
|
||||
let confirmDialog = document.getElementById("saveLoadConfirmDialog");
|
||||
if (confirmDialog) return confirmDialog;
|
||||
confirmDialog = document.createElement("div");
|
||||
confirmDialog.id = "saveLoadConfirmDialog";
|
||||
confirmDialog.className = "field-dialog save-confirm-dialog hidden";
|
||||
confirmDialog.setAttribute("role", "alertdialog");
|
||||
confirmDialog.setAttribute("aria-modal", "true");
|
||||
confirmDialog.setAttribute("aria-labelledby", "saveLoadConfirmTitle");
|
||||
confirmDialog.setAttribute("aria-describedby", "saveLoadConfirmMessage");
|
||||
confirmDialog.innerHTML = `
|
||||
<div class="field-dialog-panel save-confirm-panel">
|
||||
<h2 id="saveLoadConfirmTitle">ロード前の確認</h2>
|
||||
<p id="saveLoadConfirmMessage" class="field-dialog-lead save-confirm-message"></p>
|
||||
<p class="hint save-warning">現在の状態は上書きされます。必要であれば先にセーブしてください。</p>
|
||||
<div class="field-dialog-actions">
|
||||
<button class="btn" data-save-load-cancel type="button">キャンセル</button>
|
||||
<button class="btn primary" data-save-load-confirm type="button">ロードする</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(confirmDialog);
|
||||
return confirmDialog;
|
||||
}
|
||||
|
||||
function requestLoadConfirmation(message) {
|
||||
const confirmDialog = ensureLoadConfirmDialog();
|
||||
const messageEl = confirmDialog.querySelector("#saveLoadConfirmMessage");
|
||||
if (messageEl) messageEl.textContent = String(message || "保存した状態をロードします。");
|
||||
confirmDialog.classList.remove("hidden");
|
||||
const confirmBtn = confirmDialog.querySelector("[data-save-load-confirm]");
|
||||
const cancelBtn = confirmDialog.querySelector("[data-save-load-cancel]");
|
||||
queueMicrotask(() => confirmBtn?.focus?.());
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (accepted) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
confirmDialog.classList.add("hidden");
|
||||
confirmDialog.removeEventListener("click", onClick);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
resolve(Boolean(accepted));
|
||||
};
|
||||
const onClick = (event) => {
|
||||
if (event.target === confirmDialog || event.target.closest("[data-save-load-cancel]")) finish(false);
|
||||
else if (event.target.closest("[data-save-load-confirm]")) finish(true);
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); finish(false); }
|
||||
else if (event.key === "Enter" && document.activeElement === confirmBtn) { event.preventDefault(); finish(true); }
|
||||
};
|
||||
confirmDialog.addEventListener("click", onClick);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
cancelBtn?.setAttribute?.("aria-label", "ロードをキャンセル");
|
||||
});
|
||||
function requestConfirmation(options = {}) {
|
||||
return global.TarinaiGameDialogs?.confirm?.(options) || Promise.resolve(false);
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
|
|
@ -199,7 +147,11 @@
|
|||
}
|
||||
if (loadBtn) {
|
||||
const slot = Number(loadBtn.dataset.loadSlot);
|
||||
if (!await requestLoadConfirmation(`スロット${slot}をロードします。`)) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u30ed\u30fc\u30c9\u524d\u306e\u78ba\u8a8d",
|
||||
message: `\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`,
|
||||
confirmText: "\u30ed\u30fc\u30c9\u3059\u308b",
|
||||
})) return;
|
||||
try {
|
||||
await loadSlot(slot);
|
||||
closeDialog();
|
||||
|
|
@ -213,7 +165,12 @@
|
|||
}
|
||||
if (deleteBtn) {
|
||||
const slot = Number(deleteBtn.dataset.deleteSlot);
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u6d88\u3057\u307e\u3059\u304b\uff1f`)) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u306e\u524a\u9664",
|
||||
message: `\u30b9\u30ed\u30c3\u30c8${slot}\u306e\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u3092\u524a\u9664\u3057\u307e\u3059\u3002`,
|
||||
confirmText: "\u524a\u9664\u3059\u308b",
|
||||
danger: true,
|
||||
})) return;
|
||||
deleteSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.delete?.();
|
||||
|
|
@ -231,7 +188,7 @@
|
|||
exportBtn.textContent = "\u751f\u6210\u4e2d...";
|
||||
}
|
||||
try {
|
||||
const hash = await encodeSnapshot(createSnapshot(global.world));
|
||||
const hash = await encodeSnapshot(createSnapshot(global.world, { includeAchievements: true }));
|
||||
if (!hash || typeof hash !== "string") throw new Error("save text was not generated");
|
||||
if (area) {
|
||||
area.value = hash;
|
||||
|
|
@ -255,9 +212,13 @@
|
|||
const area = dialog.querySelector("#saveHashText");
|
||||
const text = area?.value || "";
|
||||
if (!text.trim()) { global.showToast?.("\u30ed\u30fc\u30c9\u3059\u308b\u546a\u6587\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
|
||||
if (!await requestLoadConfirmation("貼り付けた呪文をロードします。")) return;
|
||||
if (!await requestConfirmation({
|
||||
title: "\u546a\u6587\u306e\u30ed\u30fc\u30c9",
|
||||
message: "\u8cbc\u308a\u4ed8\u3051\u305f\u546a\u6587\u3092\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002\u73fe\u5728\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002",
|
||||
confirmText: "\u30ed\u30fc\u30c9\u3059\u308b",
|
||||
})) return;
|
||||
try {
|
||||
restoreSnapshot(await decodeSnapshot(text), global.world);
|
||||
const result = restoreSnapshot(await decodeSnapshot(text), global.world);
|
||||
closeDialog();
|
||||
global.showToast?.("\u546a\u6587\u304b\u3089\u30ed\u30fc\u30c9\u3057\u307e\u3057\u305f\u3002");
|
||||
} catch (error) {
|
||||
|
|
|
|||
217
js/seesaw_system.js
Normal file
217
js/seesaw_system.js
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"use strict";
|
||||
|
||||
// Two-creature leisure equipment. Seats are reserved independently and only
|
||||
// produce play/friendship while both ends are occupied.
|
||||
(function (global) {
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => Number.isFinite(Number(v)) ? Number(v) : fallback);
|
||||
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((v, a, b) => Math.max(a, Math.min(b, Number(v) || 0)));
|
||||
|
||||
function ensure(item) {
|
||||
if (!item) return null;
|
||||
if (!Array.isArray(item.seesawSeatIds)) item.seesawSeatIds = ["", ""];
|
||||
if (item.seesawSeatIds.length < 2) item.seesawSeatIds = [item.seesawSeatIds[0] || "", item.seesawSeatIds[1] || ""];
|
||||
item.angle = 0;
|
||||
item.seesawPhase = num(item.seesawPhase);
|
||||
item.seesawTilt = num(item.seesawTilt);
|
||||
return item;
|
||||
}
|
||||
|
||||
function seatWorld(item, index = 0) {
|
||||
ensure(item);
|
||||
const side = index === 1 ? 1 : -1;
|
||||
const reach = Math.max(48, num(item?.r, 60) * 0.94);
|
||||
return { x: num(item?.x) + reach * side, y: num(item?.y) - 2, index: index === 1 ? 1 : 0 };
|
||||
}
|
||||
|
||||
function actorById(worldRef, id) {
|
||||
if (!id) return null;
|
||||
return worldRef?.liveTarinaiById?.(id) || (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null;
|
||||
}
|
||||
|
||||
function release(tarinai, item = null) {
|
||||
const board = item || tarinai?.target;
|
||||
if (board?.type === "seesaw") {
|
||||
ensure(board);
|
||||
for (let i = 0; i < 2; i += 1) if (board.seesawSeatIds[i] === tarinai?.id) board.seesawSeatIds[i] = "";
|
||||
}
|
||||
if (tarinai) {
|
||||
tarinai.seesawItemId = "";
|
||||
tarinai.seesawSeatIndex = -1;
|
||||
tarinai.seesawWaitStartedAt = -Infinity;
|
||||
tarinai.seesawPlayStartedAt = -Infinity;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanReservations(item, worldRef) {
|
||||
ensure(item);
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const actor = actorById(worldRef, item.seesawSeatIds[i]);
|
||||
if (!actor || actor.dead || actor.seesawItemId !== item.id || actor.seesawSeatIndex !== i || actor.state !== "play_seesaw") item.seesawSeatIds[i] = "";
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailable(worldRef, tarinai, range = 560) {
|
||||
let best = null;
|
||||
let bestScore = Number(range) || 560;
|
||||
const list = worldRef?.itemsOfType?.("seesaw") || (worldRef?.items || []).filter(item => item?.type === "seesaw");
|
||||
for (const item of list) {
|
||||
if (!item || item.dead) continue;
|
||||
cleanReservations(item, worldRef);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const reserved = item.seesawSeatIds[index];
|
||||
if (reserved && reserved !== tarinai?.id) continue;
|
||||
const point = seatWorld(item, index);
|
||||
const d = Math.hypot(num(tarinai?.x) - point.x, num(tarinai?.y) - point.y);
|
||||
const hasPartner = Boolean(item.seesawSeatIds[1 - index]);
|
||||
const score = d - (hasPartner ? 90 : 0);
|
||||
if (score < bestScore) { bestScore = score; best = { item, seatIndex: index, point, hasPartner }; }
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function shouldSeek(worldRef, tarinai, needs = null) {
|
||||
const found = findAvailable(worldRef, tarinai, 620);
|
||||
if (!found) return false;
|
||||
const fulfill = num(needs?.fulfill ?? tarinai?.needs?.fulfill);
|
||||
const social = num(needs?.social ?? tarinai?.needs?.social);
|
||||
if (found.hasPartner) return Math.max(fulfill, social) >= 8;
|
||||
return fulfill >= 26 || social >= 40;
|
||||
}
|
||||
|
||||
function actionPriority(worldRef, tarinai) {
|
||||
const found = findAvailable(worldRef, tarinai, 620);
|
||||
if (!found) return 0;
|
||||
return found.hasPartner ? 74 : 34;
|
||||
}
|
||||
|
||||
function isPlaying(tarinai, worldRef = tarinai?.world) {
|
||||
const reservation = validReservation(tarinai, worldRef);
|
||||
if (!reservation) return false;
|
||||
const partnerId = reservation.item.seesawSeatIds[1 - reservation.index];
|
||||
const partner = actorById(worldRef, partnerId);
|
||||
if (!partner || partner.dead || partner.state !== "play_seesaw") return false;
|
||||
const ownPoint = reservation.point;
|
||||
const partnerPoint = seatWorld(reservation.item, 1 - reservation.index);
|
||||
const ownArrived = Math.hypot(num(tarinai.x) - ownPoint.x, num(tarinai.y) - ownPoint.y) <= Math.max(28, num(tarinai.radius, 20) * 1.25);
|
||||
const partnerArrived = Math.hypot(num(partner.x) - partnerPoint.x, num(partner.y) - partnerPoint.y) <= Math.max(28, num(partner.radius, 20) * 1.25);
|
||||
return ownArrived && partnerArrived;
|
||||
}
|
||||
|
||||
function reserve(item, tarinai, preferredIndex = null) {
|
||||
if (!item || item.dead || item.type !== "seesaw" || !tarinai || tarinai.dead) return -1;
|
||||
ensure(item);
|
||||
const candidates = preferredIndex === 0 || preferredIndex === 1 ? [preferredIndex, 1 - preferredIndex] : [0, 1];
|
||||
for (const index of candidates) {
|
||||
if (!item.seesawSeatIds[index] || item.seesawSeatIds[index] === tarinai.id) {
|
||||
item.seesawSeatIds[index] = tarinai.id;
|
||||
tarinai.seesawItemId = item.id || "";
|
||||
tarinai.seesawSeatIndex = index;
|
||||
tarinai.seesawWaitStartedAt = num(tarinai.world?.time, 0);
|
||||
tarinai.seesawPlayStartedAt = -Infinity;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function start(tarinai, worldRef, item = null) {
|
||||
const found = item ? { item, seatIndex: null } : findAvailable(worldRef, tarinai, 620);
|
||||
const target = found?.item;
|
||||
if (!target) return false;
|
||||
cleanReservations(target, worldRef);
|
||||
const index = reserve(target, tarinai, found.seatIndex);
|
||||
if (index < 0) return false;
|
||||
tarinai.setActionState?.("play_seesaw", { target, reason: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b", sleeping: false, wake: true });
|
||||
tarinai.target = target;
|
||||
tarinai.thought = "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b";
|
||||
return true;
|
||||
}
|
||||
|
||||
function validReservation(tarinai, worldRef) {
|
||||
if (!tarinai || tarinai.dead || tarinai.state !== "play_seesaw") return null;
|
||||
const item = worldRef?.itemById?.(tarinai.seesawItemId) || (worldRef?.items || []).find(value => value && !value.dead && value.id === tarinai.seesawItemId);
|
||||
if (!item || item.type !== "seesaw") return null;
|
||||
cleanReservations(item, worldRef);
|
||||
const index = tarinai.seesawSeatIndex === 1 ? 1 : 0;
|
||||
if (item.seesawSeatIds[index] !== tarinai.id) return null;
|
||||
return { item, index, point: seatWorld(item, index) };
|
||||
}
|
||||
|
||||
function tickAction(tarinai, worldRef) {
|
||||
const reservation = validReservation(tarinai, worldRef);
|
||||
if (!reservation) return false;
|
||||
const d = Math.hypot(num(tarinai.x) - reservation.point.x, num(tarinai.y) - reservation.point.y);
|
||||
tarinai.thought = d > Math.max(22, num(tarinai.radius, 20)) ? "\u30b7\u30fc\u30bd\u30fc\u3078\u5411\u304b\u3063\u3066\u3044\u308b" : (reservation.item.seesawSeatIds[1 - reservation.index] ? "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3067\u3044\u308b" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b");
|
||||
const now = num(worldRef?.time);
|
||||
if (Number.isFinite(tarinai.seesawPlayStartedAt) && tarinai.seesawPlayStartedAt > -Infinity) {
|
||||
if (now - tarinai.seesawPlayStartedAt >= 18) return "finished";
|
||||
} else {
|
||||
const dayLength = Math.max(1, num(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength, 120));
|
||||
if (now - num(tarinai.seesawWaitStartedAt, now) >= Math.max(30, dayLength * 0.5)) return "finished";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateWorld(worldRef, dt = 0.016) {
|
||||
if (!worldRef?.items) return false;
|
||||
const step = Math.max(0, Math.min(0.1, num(dt, 0.016)));
|
||||
const seesaws = worldRef.itemsOfType?.("seesaw") || worldRef.items.filter(item => item?.type === "seesaw");
|
||||
for (const item of seesaws) {
|
||||
if (!item || item.dead) continue;
|
||||
ensure(item); cleanReservations(item, worldRef);
|
||||
const actors = [actorById(worldRef, item.seesawSeatIds[0]), actorById(worldRef, item.seesawSeatIds[1])];
|
||||
const arrived = actors.map((actor, index) => {
|
||||
if (!actor) return false;
|
||||
const point = seatWorld(item, index);
|
||||
return Math.hypot(num(actor.x) - point.x, num(actor.y) - point.y) <= Math.max(28, num(actor.radius, 20) * 1.25);
|
||||
});
|
||||
const playing = Boolean(actors[0] && actors[1] && arrived[0] && arrived[1]);
|
||||
const pairKey = playing ? `${actors[0].id}|${actors[1].id}` : "";
|
||||
if (pairKey && item.seesawPairKey !== pairKey) {
|
||||
item.seesawPairKey = pairKey;
|
||||
const now = num(worldRef.time);
|
||||
actors[0].seesawPlayStartedAt = now;
|
||||
actors[1].seesawPlayStartedAt = now;
|
||||
} else if (!pairKey) item.seesawPairKey = "";
|
||||
if (playing) item.seesawPhase += step * 4.6;
|
||||
const targetTilt = playing ? Math.sin(item.seesawPhase) * 0.16 : 0;
|
||||
item.seesawTilt += (targetTilt - item.seesawTilt) * Math.min(1, step * 8);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const actor = actors[index];
|
||||
if (!actor || !arrived[index]) continue;
|
||||
const base = seatWorld(item, index);
|
||||
const side = index === 1 ? 1 : -1;
|
||||
const lift = Math.sin(item.seesawPhase) * 9 * side;
|
||||
actor.x = base.x;
|
||||
actor.y = base.y - 12 + (playing ? lift : 0);
|
||||
actor.vx = 0; actor.vy = 0;
|
||||
const actionText = playing ? "\u30b7\u30fc\u30bd\u30fc\u3092\u3057\u3066\u3044\u308b\u3002" : "\u76f8\u624b\u3092\u5f85\u3063\u3066\u3044\u308b";
|
||||
actor.thought = actionText;
|
||||
if (playing && typeof global.setBehaviorText === "function") {
|
||||
const behavior = global.currentTarinaiBehavior?.(actor);
|
||||
if (behavior?.actionId === "play_seesaw" && behavior?.reason !== actionText) {
|
||||
global.setBehaviorText(actor, { need: "fulfill", subNeed: "social_play", actionId: "play_seesaw", actionLabel: actionText, reasonText: actionText, target: item, phase: "perform", source: behavior?.source || "need" });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (playing) {
|
||||
const [a, b] = actors;
|
||||
a.adjustRelation?.(b, step * 0.70, -step * 0.025, "seesaw_play");
|
||||
b.adjustRelation?.(a, step * 0.70, -step * 0.025, "seesaw_play");
|
||||
if (typeof global.applyNeedSatisfaction === "function") {
|
||||
global.applyNeedSatisfaction(a, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw");
|
||||
global.applyNeedSatisfaction(b, { fulfill: step * 1.4, social: step * 0.55 }, "seesaw");
|
||||
} else {
|
||||
a.stress = clamp(num(a.stress) - step * 0.35, 0, 100);
|
||||
b.stress = clamp(num(b.stress) - step * 0.35, 0, 100);
|
||||
}
|
||||
item.seesawPlayTime = num(item.seesawPlayTime) + step;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiSeesawSystem = Object.freeze({ ensure, seatWorld, findAvailable, shouldSeek, actionPriority, isPlaying, reserve, release, start, tickAction, updateWorld, validReservation });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -4,12 +4,62 @@
|
|||
(function (global) {
|
||||
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
|
||||
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||
const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]);
|
||||
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]);
|
||||
const DETECTOR_METRICS = Object.freeze({
|
||||
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
|
||||
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
|
||||
stress_avg: { label: "\u5e73\u5747\u30b9\u30c8\u30ec\u30b9", defaultThreshold: 70 },
|
||||
sleep_avg: { label: "\u5e73\u5747\u7761\u7720\u6b32", defaultThreshold: 70 },
|
||||
low_hp_count: { label: "\u4f4eHP\u500b\u4f53\u6570", defaultThreshold: 1 },
|
||||
sick_count: { label: "\u75c5\u6c17\u500b\u4f53\u6570", defaultThreshold: 1 },
|
||||
temperature: { label: "\u6c17\u6e29", defaultThreshold: 15 },
|
||||
zunchi_count: { label: "\u305a\u3093\u3061\u6570", defaultThreshold: 1 },
|
||||
ant_count: { label: "\u30a2\u30ea\u6570", defaultThreshold: 1 },
|
||||
water_count: { label: "\u6c34\u6ef4\u6570", defaultThreshold: 1 },
|
||||
});
|
||||
const PRESSURE_TARGETS = Object.freeze({
|
||||
tarinai: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1, spatial: true },
|
||||
item: { label: "\u30a2\u30a4\u30c6\u30e0\u6570", defaultThreshold: 1, spatial: true },
|
||||
time: { label: "\u6642\u523b", defaultThreshold: 0, spatial: false },
|
||||
hunger_avg: DETECTOR_METRICS.hunger_avg,
|
||||
stress_avg: DETECTOR_METRICS.stress_avg,
|
||||
sleep_avg: DETECTOR_METRICS.sleep_avg,
|
||||
low_hp_count: DETECTOR_METRICS.low_hp_count,
|
||||
sick_count: DETECTOR_METRICS.sick_count,
|
||||
temperature: { ...DETECTOR_METRICS.temperature, spatial: false },
|
||||
zunchi_count: DETECTOR_METRICS.zunchi_count,
|
||||
ant_count: DETECTOR_METRICS.ant_count,
|
||||
water_count: DETECTOR_METRICS.water_count,
|
||||
});
|
||||
|
||||
function num(value, fallback = 0) {
|
||||
|
||||
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
});
|
||||
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
|
||||
function normalizeMinute(value, fallback = 0) {
|
||||
return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5));
|
||||
}
|
||||
function clamp(value, min, max) { return Math.max(min, Math.min(max, num(value, min))); }
|
||||
function currentMinuteOfDay(worldRef) {
|
||||
const dayLength = Math.max(1, num(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength, 120));
|
||||
const progress = typeof worldRef?.dayProgress === "function"
|
||||
? num(worldRef.dayProgress())
|
||||
: ((num(worldRef?.time) % dayLength) + dayLength) % dayLength / dayLength;
|
||||
return Math.max(0, Math.min(1439, Math.floor((((progress % 1) + 1) % 1) * 1440)));
|
||||
}
|
||||
function isMinuteInRange(minute, start, end) {
|
||||
const now = Math.max(0, Math.min(1439, Number(minute) | 0));
|
||||
const from = normalizeMinute(start, 360);
|
||||
const to = normalizeMinute(end, 1080);
|
||||
if (from === to) return true;
|
||||
return from < to ? now >= from && now < to : now >= from || now < to;
|
||||
}
|
||||
function normalizePressureTarget(value) {
|
||||
return PRESSURE_TARGETS[value] ? value : "tarinai";
|
||||
}
|
||||
function normalizeComparator(value) { return value === "lte" ? "lte" : "gte"; }
|
||||
function isWire(item) { return Boolean(item && !item.dead && WIRE_TYPES.has(item.type)); }
|
||||
|
||||
function emitEffect(worldRef, type, x, y, options = {}) {
|
||||
|
|
@ -41,73 +91,154 @@
|
|||
return found;
|
||||
}
|
||||
|
||||
function endpointSignalNode(endpoint, object, worldRef) {
|
||||
if (!endpoint || !object) return object || null;
|
||||
if (object.type !== "circuit_board") return object;
|
||||
const circuit = global.TarinaiCircuitBoardSystem;
|
||||
const portId = endpoint.portId || circuit?.nearestPort?.(object, endpoint.x, endpoint.y, 80)?.port?.portId || "";
|
||||
return circuit?.terminal?.(object, portId) || null;
|
||||
}
|
||||
|
||||
function isSignalConnectable(item) {
|
||||
return Boolean(item && !item.dead && (SIGNAL_SOURCE_TYPES.has(item.type) || DIRECT_SIGNAL_SINK_TYPES.has(item.type) || item.type === "circuit_board"));
|
||||
}
|
||||
|
||||
function endpointPoint(endpoint, worldRef) {
|
||||
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|
||||
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
|
||||
}
|
||||
|
||||
function countPressureTargets(item, worldRef) {
|
||||
function insideRect(item, target, width, height) {
|
||||
const tr = Math.max(0, num(target?.r ?? target?.radius));
|
||||
return Math.abs(num(target?.x) - num(item.x)) <= width * 0.5 + tr
|
||||
&& Math.abs(num(target?.y) - num(item.y)) <= height * 0.5 + tr;
|
||||
}
|
||||
|
||||
function detectorTarinai(item, worldRef, width, height) {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
||||
return (worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [])
|
||||
.filter(t => t && !t.dead && !worldRef.isTarinaiHiddenInNestBox?.(t) && insideRect(item, t, width, height));
|
||||
}
|
||||
function detectorItems(item, worldRef, width, height, type = "") {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
||||
return (worldRef.nearbyNonGrassItems?.(item.x, item.y, radius, true) || worldRef.items || [])
|
||||
.filter(it => it && !it.dead && it !== item && !LINK_TYPES.has(it.type)
|
||||
&& (!type || it.type === type) && insideRect(item, it, width, height));
|
||||
}
|
||||
function average(list, valueFor) {
|
||||
if (!list.length) return 0;
|
||||
return list.reduce((sum, value) => sum + num(valueFor(value)), 0) / list.length;
|
||||
}
|
||||
function detectorMetricValue(item, worldRef) {
|
||||
const width = clamp(item.pressureWidth || 220, 60, 840);
|
||||
const height = clamp(item.pressureHeight || 160, 60, 840);
|
||||
const halfW = width * 0.5;
|
||||
const halfH = height * 0.5;
|
||||
const radius = Math.hypot(halfW, halfH) + 48;
|
||||
const inside = target => {
|
||||
const tr = Math.max(0, num(target?.r ?? target?.radius));
|
||||
return Math.abs(num(target?.x) - num(item.x)) <= halfW + tr && Math.abs(num(target?.y) - num(item.y)) <= halfH + tr;
|
||||
};
|
||||
if (item.pressureTarget === "item") {
|
||||
const nearby = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [];
|
||||
let count = 0;
|
||||
for (const target of nearby) {
|
||||
if (!target || target.dead || target === item || LINK_TYPES.has(target.type)) continue;
|
||||
if (inside(target)) count += 1;
|
||||
}
|
||||
return count;
|
||||
const target = normalizePressureTarget(item.pressureTarget);
|
||||
if (target === "temperature") return num(worldRef.temperatureAt?.(item.x, item.y, null), global.CONFIG?.standardTemperature ?? 15);
|
||||
if (target === "ant_count") {
|
||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 32;
|
||||
return (worldRef.nearbyAnts?.(item.x, item.y, radius, true) || worldRef.ants || [])
|
||||
.filter(a => a && !a.dead && insideRect(item, a, width, height)).length;
|
||||
}
|
||||
const nearby = worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [];
|
||||
let count = 0;
|
||||
for (const target of nearby) {
|
||||
if (!target || target.dead || worldRef.isTarinaiHiddenInNestBox?.(target)) continue;
|
||||
if (inside(target)) count += 1;
|
||||
}
|
||||
return count;
|
||||
if (target === "item") return detectorItems(item, worldRef, width, height).length;
|
||||
if (target === "zunchi_count") return detectorItems(item, worldRef, width, height, "zunchi").length;
|
||||
if (target === "water_count") return detectorItems(item, worldRef, width, height, "water").length;
|
||||
const actors = detectorTarinai(item, worldRef, width, height);
|
||||
if (target === "tarinai") return actors.length;
|
||||
if (target === "hunger_avg") return average(actors, t => t.hunger);
|
||||
if (target === "stress_avg") return average(actors, t => t.stress);
|
||||
if (target === "sleep_avg") return average(actors, t => t.needs?.sleep ?? t.needRaw?.sleep ?? t.sleepPressure);
|
||||
if (target === "low_hp_count") return actors.filter(t => num(t.hp, num(t.maxHp, 100)) / Math.max(1, num(t.maxHp, 100)) <= 0.35).length;
|
||||
if (target === "sick_count") return actors.filter(t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease).length;
|
||||
return actors.length;
|
||||
}
|
||||
|
||||
function updatePressureSwitches(worldRef) {
|
||||
const minute = currentMinuteOfDay(worldRef);
|
||||
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
|
||||
if (!item || item.dead) continue;
|
||||
item.pressureTarget = item.pressureTarget === "item" ? "item" : "tarinai";
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0));
|
||||
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
|
||||
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
|
||||
item.pressureMin = clamp(num(item.pressureMin, defaultThreshold), -999, 999);
|
||||
item.pressureMax = clamp(num(item.pressureMax, 999), -999, 999);
|
||||
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
|
||||
item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
|
||||
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
|
||||
const count = countPressureTargets(item, worldRef);
|
||||
const active = count >= item.pressureThreshold;
|
||||
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
|
||||
item.pressureTimeEnd = normalizeMinute(item.pressureTimeEnd, 1080);
|
||||
let value = 0;
|
||||
let active = false;
|
||||
if (item.pressureTarget === "time") {
|
||||
active = isMinuteInRange(minute, item.pressureTimeStart, item.pressureTimeEnd);
|
||||
value = active ? 1 : 0;
|
||||
} else {
|
||||
value = detectorMetricValue(item, worldRef);
|
||||
active = value >= item.pressureMin && value <= item.pressureMax;
|
||||
}
|
||||
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
|
||||
item.pressureCount = count;
|
||||
item.pressureCount = value;
|
||||
item.pressureValue = value;
|
||||
item.pressureCurrentMinute = minute;
|
||||
item.signalActive = active;
|
||||
}
|
||||
}
|
||||
|
||||
function setEffectivePower(node, directDriven, directOn, worldRef) {
|
||||
const wasOn = Boolean(node?._achievementSignalWasOn);
|
||||
node._directSignalDriven = Boolean(directDriven);
|
||||
node._directSignalOn = Boolean(directDriven && directOn);
|
||||
node._achievementSignalWasOn = node._directSignalOn;
|
||||
if (!wasOn && node._directSignalOn) {
|
||||
global.TarinaiAchievements?.recordSignalActivation?.({ world: worldRef, target: node, type: node.type });
|
||||
if (node.type === "robot_cleaner") global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot: node, source: "signal-power" });
|
||||
}
|
||||
node._signalDriven = node._directSignalDriven;
|
||||
node._signalOn = node._directSignalOn;
|
||||
if (node.type === "gate_fence" && node._signalDriven) {
|
||||
const nextOpen = Boolean(node._signalOn);
|
||||
if (Boolean(node.gateOpen) !== nextOpen) {
|
||||
node.gateOpen = nextOpen;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markSpatialDirty?.("signal-gate-toggle");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSignalNetwork(worldRef) {
|
||||
const adjacency = new Map();
|
||||
const connected = new Set();
|
||||
const connectedNodes = new Set();
|
||||
const wires = [];
|
||||
const circuit = global.TarinaiCircuitBoardSystem;
|
||||
let wireGeometryMoved = false;
|
||||
const addEdge = (a, b) => {
|
||||
if (!a || !b || a === b) return;
|
||||
if (!adjacency.has(a)) adjacency.set(a, new Set());
|
||||
if (!adjacency.has(b)) adjacency.set(b, new Set());
|
||||
adjacency.get(a).add(b); adjacency.get(b).add(a);
|
||||
connected.add(a); connected.add(b);
|
||||
connectedNodes.add(a); connectedNodes.add(b);
|
||||
};
|
||||
const boards = [];
|
||||
for (const item of worldRef.items || []) {
|
||||
if (!item || item.dead) continue;
|
||||
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) {
|
||||
item._wireSignalDriven = false; item._wireSignalOn = false;
|
||||
item._directSignalDriven = false; item._directSignalOn = false;
|
||||
}
|
||||
if (item.type === "circuit_board") {
|
||||
circuit?.ensureConfig?.(item);
|
||||
item._circuitInputState = Object.create(null);
|
||||
if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState);
|
||||
boards.push(item);
|
||||
}
|
||||
}
|
||||
for (const wire of worldRef.items || []) {
|
||||
if (!isWire(wire)) continue;
|
||||
const pb = global.TarinaiPhysicsBodySystem;
|
||||
const endpointA = pb?.endpoint?.(wire, 0);
|
||||
const endpointB = pb?.endpoint?.(wire, 1);
|
||||
const a = endpointObject(endpointA, worldRef);
|
||||
const b = endpointObject(endpointB, worldRef);
|
||||
const objectA = endpointObject(endpointA, worldRef);
|
||||
const objectB = endpointObject(endpointB, worldRef);
|
||||
const nodeA = endpointSignalNode(endpointA, objectA, worldRef);
|
||||
const nodeB = endpointSignalNode(endpointB, objectB, worldRef);
|
||||
const pointA = endpointPoint(endpointA, worldRef);
|
||||
const pointB = endpointPoint(endpointB, worldRef);
|
||||
if (pointA && pointB) {
|
||||
|
|
@ -119,41 +250,83 @@
|
|||
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
|
||||
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
|
||||
}
|
||||
wires.push({ wire, a, b });
|
||||
if (a && b) addEdge(a, b);
|
||||
wires.push({ wire, a: objectA, b: objectB, nodeA, nodeB });
|
||||
if (nodeA && nodeB) addEdge(nodeA, nodeB);
|
||||
}
|
||||
|
||||
const components = [];
|
||||
const componentOf = new Map();
|
||||
const visited = new Set();
|
||||
for (const start of connected) {
|
||||
if (visited.has(start)) continue;
|
||||
const stack = [start];
|
||||
const component = [];
|
||||
let active = false;
|
||||
for (const startNode of connectedNodes) {
|
||||
if (visited.has(startNode)) continue;
|
||||
const stack = [startNode], nodes = [];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (!node || visited.has(node)) continue;
|
||||
visited.add(node); component.push(node);
|
||||
if (node.type === "pressure_switch" && node.signalActive) active = true;
|
||||
visited.add(node); nodes.push(node);
|
||||
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
|
||||
}
|
||||
const hasSource = component.some(node => node.type === "pressure_switch");
|
||||
for (const node of component) {
|
||||
const sink = node.type === "rotator" || node.type === "reciprocator" || node.type === "robot_cleaner";
|
||||
if (sink) {
|
||||
node._signalDriven = hasSource;
|
||||
node._signalOn = hasSource && active;
|
||||
const component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) };
|
||||
components.push(component);
|
||||
nodes.forEach(node => componentOf.set(node, component));
|
||||
}
|
||||
|
||||
// Board outputs can depend on other boards, so resolve the graph to a
|
||||
// fixed point. Unstable internal combinational cycles are forced OFF by the
|
||||
// board evaluator; external feedback is still bounded to avoid hanging the simulation.
|
||||
const maxCircuitPasses = Math.min(256, Math.max(16, boards.length * 2 + 4));
|
||||
for (let pass = 0; pass < maxCircuitPasses; pass += 1) {
|
||||
let changed = false;
|
||||
for (const component of components) {
|
||||
const active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
|
||||
if (active !== component.active) { component.active = active; changed = true; }
|
||||
}
|
||||
for (const board of boards) {
|
||||
const inputs = Object.create(null);
|
||||
for (const port of circuit?.ports?.(board) || []) {
|
||||
if (port.direction !== "input") continue;
|
||||
const terminal = circuit.terminal(board, port.portId);
|
||||
inputs[port.id] = Boolean(componentOf.get(terminal)?.active);
|
||||
}
|
||||
const before = JSON.stringify(board._circuitOutputState || {});
|
||||
circuit?.evaluate?.(board, inputs);
|
||||
if (before !== JSON.stringify(board._circuitOutputState || {})) changed = true;
|
||||
}
|
||||
for (const entry of wires) {
|
||||
if ((entry.a && component.includes(entry.a)) || (entry.b && component.includes(entry.b))) entry.wire.signalActive = hasSource && active;
|
||||
if (!changed) break;
|
||||
}
|
||||
for (const component of components) {
|
||||
component.active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
|
||||
for (const node of component.nodes) {
|
||||
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
|
||||
node._wireSignalDriven = component.hasSource;
|
||||
node._wireSignalOn = component.hasSource && component.active;
|
||||
setEffectivePower(node, component.hasSource, component.active, worldRef);
|
||||
}
|
||||
}
|
||||
for (const entry of wires) {
|
||||
const component = componentOf.get(entry.nodeA) || componentOf.get(entry.nodeB);
|
||||
entry.wire.signalActive = Boolean(component?.hasSource && component?.active);
|
||||
if (!entry.wire.signalActive || entry.a === entry.b) continue;
|
||||
let sourceNode = null, targetNode = null, sourceBoard = null, targetBoard = null;
|
||||
if (entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "output"
|
||||
&& entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "input") {
|
||||
sourceNode = entry.nodeA; targetNode = entry.nodeB; sourceBoard = entry.a; targetBoard = entry.b;
|
||||
} else if (entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "output"
|
||||
&& entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "input") {
|
||||
sourceNode = entry.nodeB; targetNode = entry.nodeA; sourceBoard = entry.b; targetBoard = entry.a;
|
||||
}
|
||||
if (!sourceNode || !targetNode || sourceBoard === targetBoard) continue;
|
||||
const sourcePortId = String(sourceNode.portId || "").replace(/^o:/, "");
|
||||
const targetPortId = String(targetNode.portId || "").replace(/^i:/, "");
|
||||
if (!sourceBoard?._circuitOutputState?.[sourcePortId] || !targetBoard?._circuitInputState?.[targetPortId]) continue;
|
||||
global.TarinaiAchievements?.recordCircuitBoardSignalTransfer?.(sourceBoard, targetBoard, entry.wire, { world: worldRef });
|
||||
}
|
||||
|
||||
for (const item of worldRef.items || []) {
|
||||
if (!item || item.dead) continue;
|
||||
if ((item.type === "rotator" || item.type === "reciprocator" || item.type === "robot_cleaner") && !connected.has(item)) {
|
||||
item._signalDriven = false;
|
||||
item._signalOn = false;
|
||||
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) {
|
||||
item._wireSignalDriven = false; item._wireSignalOn = false;
|
||||
setEffectivePower(item, false, false, worldRef);
|
||||
}
|
||||
if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false;
|
||||
}
|
||||
|
|
@ -164,12 +337,7 @@
|
|||
return wires;
|
||||
}
|
||||
|
||||
function distanceToSegment(px, py, ax, ay, bx, by) {
|
||||
const vx = bx - ax, vy = by - ay;
|
||||
const len2 = vx * vx + vy * vy || 1;
|
||||
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
|
||||
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
|
||||
}
|
||||
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
|
||||
|
||||
function shockTarget(worldRef, wire, target, now, { connected = false } = {}) {
|
||||
if (!target || target.dead) return false;
|
||||
|
|
@ -177,7 +345,9 @@
|
|||
if (now < num(wire._shockCooldowns.get(target), -Infinity)) return false;
|
||||
wire._shockCooldowns.set(target, now + 0.38);
|
||||
const isAnt = target.kind === "worker" || target.kind === "queen" || (target.r && !target.radius && Number.isFinite(Number(target.hp)));
|
||||
if (!isAnt) global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected });
|
||||
if (isAnt) {
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(target, wire, { world: worldRef, reason: "electric_wire" });
|
||||
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
|
||||
target.hpBarTimer = Math.max(num(target.hpBarTimer), 0.85);
|
||||
target.state = "panic";
|
||||
|
|
@ -186,11 +356,11 @@
|
|||
target.targetRef = null;
|
||||
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.55);
|
||||
} else {
|
||||
target.damage?.(2.4, connected ? "通電中の電線に接続された" : "通電中の電線に触れた");
|
||||
target.damage?.(2.4, connected ? "\u901a\u96fb\u4e2d\u306e\u96fb\u7dda\u306b\u63a5\u7d9a\u3055\u308c\u305f" : "\u901a\u96fb\u4e2d\u306e\u96fb\u7dda\u306b\u89e6\u308c\u305f");
|
||||
target.panicTimer = Math.max(num(target.panicTimer), 0.8);
|
||||
target.fearTimer = Math.max(num(target.fearTimer), 0.8);
|
||||
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.62);
|
||||
target.setActionState?.("panic", { target: null, reason: "感電している", wake: true, sleeping: false });
|
||||
target.setActionState?.("panic", { target: null, reason: "\u611f\u96fb\u3057\u3066\u3044\u308b", wake: true, sleeping: false });
|
||||
}
|
||||
emitEffect(worldRef, "electric_shock", target.x, target.y - Math.max(3, num(target.radius ?? target.r, 12) * 0.12), {
|
||||
size: Math.max(12, num(target.radius ?? target.r, 12) * 1.15),
|
||||
|
|
@ -203,6 +373,7 @@
|
|||
function damageFromWires(worldRef, wires) {
|
||||
const now = num(worldRef.time);
|
||||
for (const { wire, a, b } of wires) {
|
||||
global.TarinaiAchievements?.recordWirePowerState?.(wire, Boolean(wire.signalActive));
|
||||
if (!wire.signalActive) continue;
|
||||
// Living endpoints are directly energized even through insulated wire.
|
||||
if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true });
|
||||
|
|
@ -247,5 +418,14 @@
|
|||
updateWorld,
|
||||
powerOverride,
|
||||
isWire,
|
||||
isMinuteInRange,
|
||||
currentMinuteOfDay,
|
||||
isSignalConnectable,
|
||||
DIRECT_SIGNAL_SINK_TYPES,
|
||||
SIGNAL_SOURCE_TYPES,
|
||||
SENSOR_METRICS: DETECTOR_METRICS,
|
||||
DETECTOR_METRICS,
|
||||
PRESSURE_TARGETS,
|
||||
detectorMetricValue,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
110
js/sim_core.js
110
js/sim_core.js
|
|
@ -404,6 +404,10 @@ function relationDisplayName(worldRef, id) {
|
|||
|
||||
class Effect {
|
||||
constructor(type, x, y, options = {}) {
|
||||
this.reset(type, x, y, options);
|
||||
}
|
||||
|
||||
reset(type, x, y, options = {}) {
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
|
|
@ -415,6 +419,9 @@ class Effect {
|
|||
this.color = options.color || "#fff4a8";
|
||||
this.seed = Math.random() * 1000;
|
||||
this.text = options.text || "";
|
||||
this._effectPooled = false;
|
||||
this._effectUpdatedFrame = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
|
|
@ -667,7 +674,18 @@ class SpatialGrid {
|
|||
this.itemCells = new Map();
|
||||
this.staticItemCells = new Map();
|
||||
this.dynamicItemCells = new Map();
|
||||
// Grass is passive terrain/food. Keep a second item index without grass so
|
||||
// hot physics/AI/mechanical queries do not pay for large grass populations.
|
||||
this.staticNonGrassItemCells = new Map();
|
||||
this.dynamicNonGrassItemCells = new Map();
|
||||
// Grass is queried frequently by grass-only systems (comfort, planting,
|
||||
// fire spread). Keep one dedicated static index so those reads avoid both
|
||||
// the full item buckets and global grass type arrays.
|
||||
this.grassCells = new Map();
|
||||
this.linkRenderCells = new Map();
|
||||
this.tarinaiCells = new Map();
|
||||
this._indexedTarinai = new Set();
|
||||
this._tarinaiSyncStamp = 0;
|
||||
this.antCells = new Map();
|
||||
this.obstacleCells = new Map();
|
||||
this.foodCells = new Map();
|
||||
|
|
@ -690,6 +708,10 @@ class SpatialGrid {
|
|||
this.itemCells.clear();
|
||||
this.staticItemCells.clear();
|
||||
this.dynamicItemCells.clear();
|
||||
this.staticNonGrassItemCells.clear();
|
||||
this.dynamicNonGrassItemCells.clear();
|
||||
this.grassCells.clear();
|
||||
this.linkRenderCells.clear();
|
||||
this.obstacleCells.clear();
|
||||
this.foodCells.clear();
|
||||
this.hazardCells.clear();
|
||||
|
|
@ -703,9 +725,64 @@ class SpatialGrid {
|
|||
|
||||
|
||||
clearTarinai() {
|
||||
for (const t of this._indexedTarinai || []) {
|
||||
if (t) { delete t._spatialTarinaiCellKey; delete t._spatialTarinaiSyncStamp; }
|
||||
}
|
||||
this._indexedTarinai?.clear?.();
|
||||
this.tarinaiCells.clear();
|
||||
}
|
||||
|
||||
removeFromCell(map, key, entity) {
|
||||
if (!map || key === undefined || key === null || !entity) return false;
|
||||
const bucket = map.get(key);
|
||||
if (!bucket) return false;
|
||||
const index = bucket.indexOf(entity);
|
||||
if (index < 0) return false;
|
||||
const last = bucket.pop();
|
||||
if (index < bucket.length) bucket[index] = last;
|
||||
if (bucket.length === 0) map.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
syncTarinai(tarinai = []) {
|
||||
const stamp = this._tarinaiSyncStamp = (this._tarinaiSyncStamp || 0) + 1;
|
||||
let changed = 0;
|
||||
let liveCount = 0;
|
||||
for (const t of tarinai || []) {
|
||||
if (!t) continue;
|
||||
t._spatialTarinaiSyncStamp = stamp;
|
||||
const oldKey = t._spatialTarinaiCellKey;
|
||||
if (t.dead) {
|
||||
if (oldKey !== undefined) changed += this.removeFromCell(this.tarinaiCells, oldKey, t) ? 1 : 0;
|
||||
delete t._spatialTarinaiCellKey;
|
||||
this._indexedTarinai.delete(t);
|
||||
continue;
|
||||
}
|
||||
liveCount += 1;
|
||||
const newKey = this.keyFor(t.x || 0, t.y || 0);
|
||||
if (oldKey === newKey) continue;
|
||||
if (oldKey !== undefined) this.removeFromCell(this.tarinaiCells, oldKey, t);
|
||||
let bucket = this.tarinaiCells.get(newKey);
|
||||
if (!bucket) { bucket = []; this.tarinaiCells.set(newKey, bucket); }
|
||||
bucket.push(t);
|
||||
t._spatialTarinaiCellKey = newKey;
|
||||
this._indexedTarinai.add(t);
|
||||
changed += 1;
|
||||
}
|
||||
// Only scan the index set when entities were actually removed from the list.
|
||||
if (this._indexedTarinai.size > liveCount) {
|
||||
for (const t of Array.from(this._indexedTarinai)) {
|
||||
if (t && !t.dead && t._spatialTarinaiSyncStamp === stamp) continue;
|
||||
const key = t?._spatialTarinaiCellKey;
|
||||
if (key !== undefined) this.removeFromCell(this.tarinaiCells, key, t);
|
||||
if (t) delete t._spatialTarinaiCellKey;
|
||||
this._indexedTarinai.delete(t);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
clearAnts() {
|
||||
this.antCells.clear();
|
||||
}
|
||||
|
|
@ -825,12 +902,22 @@ class SpatialGrid {
|
|||
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
|
||||
}
|
||||
|
||||
addLinkRenderItem(it) {
|
||||
if (!it || it.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(it.type || "")) return;
|
||||
const bounds = this.boundsForTrait(it, "render-link");
|
||||
if (bounds) this.addAabb(this.linkRenderCells, it, bounds);
|
||||
else this.add(this.linkRenderCells, it);
|
||||
}
|
||||
|
||||
classifyItem(it) {
|
||||
if (!it || it.dead) return;
|
||||
const dynamic = this.isDynamicItem(it);
|
||||
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
|
||||
this.add(target, it);
|
||||
if (it.type === "grass") this.add(this.grassCells, it);
|
||||
else this.add(dynamic ? this.dynamicNonGrassItemCells : this.staticNonGrassItemCells, it);
|
||||
this.add(this.itemCells, it);
|
||||
this.addLinkRenderItem(it);
|
||||
this.addItemToTraitCells(it, dynamic);
|
||||
}
|
||||
|
||||
|
|
@ -842,25 +929,36 @@ class SpatialGrid {
|
|||
|
||||
rebuildStaticItems(items, opts = {}) {
|
||||
this.staticItemCells.clear();
|
||||
this.staticNonGrassItemCells.clear();
|
||||
this.grassCells.clear();
|
||||
this.staticObstacleCells.clear();
|
||||
this.staticFoodCells.clear();
|
||||
this.staticHazardCells.clear();
|
||||
for (const it of items || []) {
|
||||
if (!it || it.dead || this.isDynamicItem(it)) continue;
|
||||
this.add(this.staticItemCells, it);
|
||||
if (it.type === "grass") this.add(this.grassCells, it);
|
||||
else this.add(this.staticNonGrassItemCells, it);
|
||||
this.addItemToTraitCells(it, false);
|
||||
}
|
||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||
}
|
||||
|
||||
rebuildLinkRenderItems(items) {
|
||||
this.linkRenderCells.clear();
|
||||
for (const it of items || []) this.addLinkRenderItem(it);
|
||||
}
|
||||
|
||||
rebuildDynamicItems(items, opts = {}) {
|
||||
this.dynamicItemCells.clear();
|
||||
this.dynamicNonGrassItemCells.clear();
|
||||
this.dynamicObstacleCells.clear();
|
||||
this.dynamicFoodCells.clear();
|
||||
this.dynamicHazardCells.clear();
|
||||
for (const it of items || []) {
|
||||
if (!it || it.dead || !this.isDynamicItem(it)) continue;
|
||||
this.add(this.dynamicItemCells, it);
|
||||
if (it.type !== "grass") this.add(this.dynamicNonGrassItemCells, it);
|
||||
this.addItemToTraitCells(it, true);
|
||||
}
|
||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||
|
|
@ -893,8 +991,16 @@ class SpatialGrid {
|
|||
}
|
||||
|
||||
rebuildTarinai(tarinai) {
|
||||
this.tarinaiCells.clear();
|
||||
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
|
||||
this.clearTarinai();
|
||||
for (const t of tarinai || []) {
|
||||
if (!t || t.dead) continue;
|
||||
const key = this.keyFor(t.x || 0, t.y || 0);
|
||||
let bucket = this.tarinaiCells.get(key);
|
||||
if (!bucket) { bucket = []; this.tarinaiCells.set(key, bucket); }
|
||||
bucket.push(t);
|
||||
t._spatialTarinaiCellKey = key;
|
||||
this._indexedTarinai.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
rebuildAnts(ants = []) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,14 @@
|
|||
// horizontal oval water items spawned by rain; it intentionally does not
|
||||
// share the rain overlay renderer.
|
||||
if (global.TarinaiWeatherSystem.shouldDropRainWater(worldRef, dt)) {
|
||||
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef);
|
||||
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef, { rainDrop: true });
|
||||
const sand = worldRef.toiletSandAt?.(drop.x, drop.y) || null;
|
||||
if (sand) {
|
||||
sand.toiletFlash = 1;
|
||||
sand.awakeUntil = Math.max(sand.awakeUntil || 0, (worldRef.time || 0) + 0.8);
|
||||
worldRef.markTerrainDirtyAt?.(sand.x, sand.y, Math.max(64, (sand.r || 48) * 1.72), "toilet-sand-rain-absorb");
|
||||
return;
|
||||
}
|
||||
worldRef.addItem?.(drop, "rain-water");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
const updatePolicy = global.TarinaiCreatureUpdatePolicy;
|
||||
|
||||
function inc(map, key, n = 1) {
|
||||
if (!map) return;
|
||||
const k = String(key || "none");
|
||||
map[k] = (map[k] || 0) + n;
|
||||
}
|
||||
|
|
@ -16,6 +17,80 @@
|
|||
return Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().creatureBackgroundBudget || 36));
|
||||
}
|
||||
|
||||
function profileNumber(name, fallback) {
|
||||
const value = Number(global.TarinaiPerf?.performanceProfile?.()[name]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function resetMechanicalCrowdPressure(worldRef) {
|
||||
const pressure = worldRef._mechanicalCrowdPressure || (worldRef._mechanicalCrowdPressure = { ids: new Set(), sources: [], active: false, count: 0, mechanical: 0, frame: 0 });
|
||||
pressure.ids.clear();
|
||||
pressure.sources.length = 0;
|
||||
pressure.active = false;
|
||||
pressure.count = 0;
|
||||
pressure.mechanical = 0;
|
||||
pressure.detailedContacts = 0;
|
||||
pressure.degradedContacts = 0;
|
||||
pressure.skippedContacts = 0;
|
||||
pressure.contactBudget = 0;
|
||||
pressure.frame = (pressure.frame || 0) + 1;
|
||||
pressure.profile = global.TarinaiPerf?.simulationOptimizationTier?.() || "balanced";
|
||||
return pressure;
|
||||
}
|
||||
|
||||
function hasMechanicalPressureSources(worldRef) {
|
||||
const counts = worldRef?.itemCounts || {};
|
||||
return Boolean((counts.rotator || 0) + (counts.reciprocator || 0) + (counts.poison_block || 0));
|
||||
}
|
||||
|
||||
function mechanicalItemActive(item, worldRef) {
|
||||
if (!item || item.dead) return false;
|
||||
const pb = global.TarinaiPhysicsBodySystem;
|
||||
const body = pb?.ensureBody?.(item, worldRef, { syncFromLegacy: false }) || item.physicsBody || null;
|
||||
const velocity = body?.velocity || {};
|
||||
const motor = body?.motor || {};
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
if (item.type === "rotator") return Number(mech?.motionLevel?.(item) || 0) > 0.55;
|
||||
if (item.type === "reciprocator") {
|
||||
const drive = (motor.powered !== false ? Math.max(0, Number(motor.speed || item.railMotorSpeed || 0) || 0) : 0) + Math.abs(Number(velocity.linear || item.slideSpeed || 0) || 0);
|
||||
return drive > 0.08;
|
||||
}
|
||||
if (item.type === "poison_block") return Boolean(global.TarinaiMechanicalSystem?.passiveItemAwake?.(item));
|
||||
return false;
|
||||
}
|
||||
|
||||
function prepareMechanicalCrowdPressure(worldRef, visibleRect) {
|
||||
const pressure = resetMechanicalCrowdPressure(worldRef);
|
||||
if (!hasMechanicalPressureSources(worldRef)) return pressure;
|
||||
pressure.contactBudget = Math.max(1, profileNumber("pressureObstacleContactBudget", 10));
|
||||
if (!worldRef?.itemsOfType || !worldRef?.nearbyTarinai) return pressure;
|
||||
const mech = global.TarinaiMechanicalSystem;
|
||||
if (!mech?.reach) return pressure;
|
||||
const types = ["rotator", "reciprocator", "poison_block"];
|
||||
let checked = 0;
|
||||
for (const type of types) {
|
||||
for (const item of worldRef.itemsOfType(type) || []) {
|
||||
if (!item || item.dead || !mechanicalItemActive(item, worldRef)) continue;
|
||||
checked += 1;
|
||||
pressure.mechanical += 1;
|
||||
pressure.sources.push(item);
|
||||
const radius = Math.max(120, (mech.reach(item) || item.r || 80) + 140);
|
||||
const candidates = worldRef.nearbyTarinai(item.x || 0, item.y || 0, radius, true) || [];
|
||||
for (const t of candidates) {
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
if (visibleRect && !global.TarinaiGeometry?.pointInRect?.(t.x || 0, t.y || 0, visibleRect)) continue;
|
||||
pressure.ids.add(t.id || t.familyKey || t);
|
||||
}
|
||||
if (checked >= 8) break;
|
||||
}
|
||||
if (checked >= 8) break;
|
||||
}
|
||||
pressure.count = pressure.ids.size;
|
||||
pressure.active = pressure.count >= 10;
|
||||
pressure.intensity = Math.min(1, pressure.count / 44);
|
||||
return pressure;
|
||||
}
|
||||
|
||||
function consumesBackgroundBudget(cadence) {
|
||||
if (!cadence || cadence.urgent || cadence.visible) return false;
|
||||
return true;
|
||||
|
|
@ -27,6 +102,11 @@
|
|||
const visibleRect = h.updateVisibleWorldRect(worldRef, 160);
|
||||
const nearVisibleRect = h.updateVisibleWorldRect(worldRef, 520);
|
||||
const now = worldRef.time || 0;
|
||||
const pressure = prepareMechanicalCrowdPressure(worldRef, visibleRect);
|
||||
const pressureActive = Boolean(pressure?.active);
|
||||
const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000;
|
||||
const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000;
|
||||
const collectDiagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true;
|
||||
const stats = {
|
||||
total: 0,
|
||||
full: 0,
|
||||
|
|
@ -38,15 +118,27 @@
|
|||
visible: 0,
|
||||
near: 0,
|
||||
far: 0,
|
||||
lanes: {},
|
||||
states: {},
|
||||
smoothStates: {},
|
||||
skippedStates: {},
|
||||
lanes: collectDiagnostics ? {} : null,
|
||||
states: collectDiagnostics ? {} : null,
|
||||
smoothStates: collectDiagnostics ? {} : null,
|
||||
skippedStates: collectDiagnostics ? {} : null,
|
||||
sleepPhysical: 0,
|
||||
passivePhysical: 0,
|
||||
fullBudget: backgroundFullBudgetFor(worldRef),
|
||||
fullBudgetUsed: 0,
|
||||
fullBudgetSkips: 0,
|
||||
visibleFullBudget,
|
||||
visibleFullUsed: 0,
|
||||
visibleFullSkips: 0,
|
||||
visibleMotionBudget,
|
||||
visibleMotionUsed: 0,
|
||||
visibleMotionSkips: 0,
|
||||
mechanicalPressure: pressure?.count || 0,
|
||||
mechanicalPressureActive: pressureActive,
|
||||
mechanicalContactBudget: pressure?.contactBudget || 0,
|
||||
mechanicalDetailedContacts: pressure?.detailedContacts || 0,
|
||||
mechanicalDegradedContacts: pressure?.degradedContacts || 0,
|
||||
mechanicalSkippedContacts: pressure?.skippedContacts || 0,
|
||||
offscreenSkipped: 0,
|
||||
};
|
||||
const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || "";
|
||||
|
|
@ -57,7 +149,25 @@
|
|||
// after all visible bodies have been advanced.
|
||||
worldRef.deferSpatialRebuildMode = "mobile";
|
||||
try {
|
||||
for (const t of worldRef.tarinai) {
|
||||
const creatureList = worldRef.tarinai || [];
|
||||
const creatureCount = creatureList.length;
|
||||
const perfProfile = global.TarinaiPerf?.performanceProfile?.() || {};
|
||||
// High-population AI/environment budgets are consumed in a rotating order.
|
||||
// Every creature is still visited each frame, but the first candidates for
|
||||
// expensive scheduled work change without a queue, sort, or per-frame hash.
|
||||
const rotateScheduledWork = perfProfile.simulationThrottled !== false && creatureCount >= 80;
|
||||
let creatureIndex = rotateScheduledWork
|
||||
? (Math.max(0, Math.floor(worldRef._creatureScheduledWorkCursor || 0)) % Math.max(1, creatureCount))
|
||||
: 0;
|
||||
if (rotateScheduledWork) {
|
||||
const cursorStep = Math.max(1, Math.min(creatureCount, Math.floor(Number(perfProfile.aiBudget || 10)) || 10));
|
||||
worldRef._creatureScheduledWorkCursor = (creatureIndex + cursorStep) % creatureCount;
|
||||
} else {
|
||||
worldRef._creatureScheduledWorkCursor = 0;
|
||||
}
|
||||
for (let creatureVisited = 0; creatureVisited < creatureCount; creatureVisited += 1) {
|
||||
if (creatureIndex >= creatureCount) creatureIndex = 0;
|
||||
const t = creatureList[creatureIndex++];
|
||||
if (!t || t.dead) continue;
|
||||
stats.total += 1;
|
||||
const behaviorId = updatePolicy.behaviorId(t);
|
||||
|
|
@ -91,6 +201,14 @@
|
|||
t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70);
|
||||
}
|
||||
if (now >= t._nextLowFreqUpdateAt) {
|
||||
if (cadence.visible && stats.visibleFullUsed >= stats.visibleFullBudget) {
|
||||
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `visible-creature-budget:${Math.floor(now * 5)}`);
|
||||
t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.20 + jitter * 0.25));
|
||||
stats.visibleFullSkips += 1;
|
||||
stats.skipped += 1;
|
||||
inc(stats.skippedStates, behaviorId);
|
||||
continue;
|
||||
}
|
||||
if (usesBackgroundBudget && stats.fullBudgetUsed >= stats.fullBudget) {
|
||||
const jitter = stableUnit(t.id || t.familyKey || Math.random(), `creature-budget:${Math.floor(now * 4)}`);
|
||||
t._nextLowFreqUpdateAt = now + Math.max(dt, interval * (0.18 + jitter * 0.18));
|
||||
|
|
@ -105,11 +223,19 @@
|
|||
t._updateAccum = 0;
|
||||
t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22);
|
||||
stats.full += 1;
|
||||
if (cadence.visible) stats.visibleFullUsed += 1;
|
||||
if (usesBackgroundBudget) stats.fullBudgetUsed += 1;
|
||||
global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
|
||||
} else if (cadence.smoothMotion) {
|
||||
if (cadence.visible && stats.visibleMotionUsed >= stats.visibleMotionBudget) {
|
||||
stats.visibleMotionSkips += 1;
|
||||
stats.skipped += 1;
|
||||
inc(stats.skippedStates, behaviorId);
|
||||
continue;
|
||||
}
|
||||
if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) {
|
||||
stats.smooth += 1;
|
||||
if (cadence.visible) stats.visibleMotionUsed += 1;
|
||||
inc(stats.smoothStates, behaviorId);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -120,7 +246,10 @@
|
|||
} finally {
|
||||
worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1);
|
||||
worldRef.deferSpatialRebuildMode = prevSpatialDeferMode;
|
||||
worldRef._creatureUpdateStats = stats;
|
||||
stats.mechanicalDetailedContacts = pressure?.detailedContacts || 0;
|
||||
stats.mechanicalDegradedContacts = pressure?.degradedContacts || 0;
|
||||
stats.mechanicalSkippedContacts = pressure?.skippedContacts || 0;
|
||||
worldRef._creatureUpdateStats = collectDiagnostics ? stats : null;
|
||||
if (end) end();
|
||||
}
|
||||
const bedConflictInterval = 3.2;
|
||||
|
|
@ -132,12 +261,19 @@
|
|||
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
|
||||
}
|
||||
const endSpatial = global.TarinaiPerf.begin("update.tarinai.spatial") || null;
|
||||
const tarinaiMoved = h.markSpatialDirtyIfMoved(worldRef, worldRef.tarinai, "tarinai-moved", 0.25);
|
||||
if (tarinaiMoved || worldRef.spatialDirty) {
|
||||
// Maintain the Tarinai grid incrementally. The same O(N) pass that used to
|
||||
// detect movement now moves only entities that crossed a cell boundary,
|
||||
// avoiding a second O(N) clear-and-rebuild pass every active frame.
|
||||
const tarinaiCellChanges = worldRef.spatial?.syncTarinai?.(worldRef.tarinai || []) || 0;
|
||||
if (tarinaiCellChanges > 0) worldRef.spatialVersion = (worldRef.spatialVersion || 0) + 1;
|
||||
if (worldRef.spatialDirty && (worldRef.spatialStaticItemsDirty || worldRef.spatialDynamicItemsDirty || worldRef.spatialAntDirty)) {
|
||||
if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-tarinai-update");
|
||||
else worldRef.ensureSpatial?.("post-tarinai-update");
|
||||
worldRef.deferredSpatialDirtyReason = "";
|
||||
} else if (worldRef.spatialTarinaiDirty) {
|
||||
worldRef.spatialTarinaiDirty = false;
|
||||
worldRef.spatialDirty = Boolean(worldRef.spatialStaticItemsDirty || worldRef.spatialDynamicItemsDirty || worldRef.spatialAntDirty);
|
||||
}
|
||||
worldRef.deferredSpatialDirtyReason = "";
|
||||
if (endSpatial) endSpatial();
|
||||
const endBallInteractions = global.TarinaiPerf.begin("update.ballInteractions") || null;
|
||||
worldRef.limitBallChasers();
|
||||
|
|
@ -154,7 +290,15 @@
|
|||
if (fastTarinai?.length) {
|
||||
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
|
||||
const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null;
|
||||
try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); }
|
||||
try {
|
||||
const profile = global.TarinaiPerf?.performanceProfile?.() || {};
|
||||
worldRef.resolveTarinaiHighSpeedCollisions(dt, {
|
||||
fastSources: fastTarinai,
|
||||
threshold: collisionSpeedThreshold,
|
||||
maxSources: profile.collisionSourceBudget,
|
||||
maxImpacts: profile.collisionImpactBudget,
|
||||
});
|
||||
}
|
||||
finally { if (endCollision) endCollision(); }
|
||||
} else {
|
||||
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
|
||||
|
|
|
|||
|
|
@ -17,28 +17,34 @@
|
|||
for (let i = 0; i < effects.length; i += 1) {
|
||||
const effect = effects[i];
|
||||
if (effect && !effect.dead) effects[write++] = effect;
|
||||
else if (effect) worldRef.releaseEffect?.(effect);
|
||||
}
|
||||
effects.length = write;
|
||||
const compacted = before - write;
|
||||
const cap = Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().effectCap || 220));
|
||||
if (effects.length <= cap) return { compacted, dropped: 0 };
|
||||
let needDrop = Math.max(0, effects.length - cap);
|
||||
if (!needDrop) return { compacted, dropped: 0 };
|
||||
|
||||
const drop = effects
|
||||
.map((effect, index) => ({
|
||||
effect,
|
||||
index,
|
||||
score: effectPriority(effect) * 10 + Math.max(0, Math.min(1, Number(effect.life || 0) / Math.max(0.001, Number(effect.maxLife || effect.life || 1)))),
|
||||
}))
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.slice(0, effects.length - cap);
|
||||
const dropSet = new Set(drop.map(entry => entry.index));
|
||||
const dropMask = new Uint8Array(effects.length);
|
||||
for (let priority = 1; priority <= 3 && needDrop > 0; priority += 1) {
|
||||
for (let i = 0; i < effects.length && needDrop > 0; i += 1) {
|
||||
if (dropMask[i] || effectPriority(effects[i]) !== priority) continue;
|
||||
dropMask[i] = 1;
|
||||
needDrop -= 1;
|
||||
}
|
||||
}
|
||||
write = 0;
|
||||
let dropped = 0;
|
||||
for (let i = 0; i < effects.length; i += 1) {
|
||||
if (!dropSet.has(i)) effects[write++] = effects[i];
|
||||
const effect = effects[i];
|
||||
if (dropMask[i]) {
|
||||
dropped += 1;
|
||||
worldRef.releaseEffect?.(effect);
|
||||
} else effects[write++] = effect;
|
||||
}
|
||||
effects.length = write;
|
||||
worldRef._effectPressureDroppedTotal = (worldRef._effectPressureDroppedTotal || 0) + dropSet.size;
|
||||
return { compacted, dropped: dropSet.size };
|
||||
worldRef._effectPressureDroppedTotal = (worldRef._effectPressureDroppedTotal || 0) + dropped;
|
||||
return { compacted, dropped };
|
||||
}
|
||||
|
||||
function effectUpdateBudgetFor(total) {
|
||||
|
|
@ -61,37 +67,54 @@
|
|||
const total = effects.length;
|
||||
const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
|
||||
worldRef._effectUpdateFrameId = frameId;
|
||||
const budget = effectUpdateBudgetFor(total);
|
||||
if (budget >= total) {
|
||||
for (const ef of effects) updateEffect(ef, dt, frameId, 1);
|
||||
worldRef._effectUpdateStats = { total, beforePressure, updated: total, skipped: 0, budget: total, compacted: pressure.compacted, dropped: pressure.dropped, batched: false };
|
||||
return;
|
||||
|
||||
let lightweight = 0;
|
||||
const budgeted = [];
|
||||
for (const effect of effects) {
|
||||
if (!effect || effect.dead) continue;
|
||||
if (effect.type === "ring") {
|
||||
effect.life -= dt;
|
||||
effect._effectUpdatedFrame = frameId;
|
||||
lightweight += 1;
|
||||
} else {
|
||||
budgeted.push(effect);
|
||||
}
|
||||
}
|
||||
const runScale = Math.min(3.0, Math.max(1, total / Math.max(1, budget)));
|
||||
let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % total;
|
||||
|
||||
const active = budgeted.length;
|
||||
const budget = effectUpdateBudgetFor(active);
|
||||
let updated = 0;
|
||||
let scanned = 0;
|
||||
while (updated < budget && scanned < total) {
|
||||
const ef = effects[cursor];
|
||||
cursor = (cursor + 1) % total;
|
||||
scanned += 1;
|
||||
if (!ef || ef.dead) continue;
|
||||
updateEffect(ef, dt, frameId, runScale);
|
||||
updated += 1;
|
||||
let runScale = 1;
|
||||
if (budget >= active) {
|
||||
for (const ef of budgeted) {
|
||||
if (updateEffect(ef, dt, frameId, 1)) updated += 1;
|
||||
}
|
||||
} else if (active > 0) {
|
||||
runScale = Math.min(3.0, Math.max(1, active / Math.max(1, budget)));
|
||||
let cursor = Math.max(0, Math.floor(worldRef._effectUpdateCursor || 0)) % active;
|
||||
let scanned = 0;
|
||||
while (updated < budget && scanned < active) {
|
||||
const ef = budgeted[cursor];
|
||||
cursor = (cursor + 1) % active;
|
||||
scanned += 1;
|
||||
if (!ef || ef.dead) continue;
|
||||
updateEffect(ef, dt, frameId, runScale);
|
||||
updated += 1;
|
||||
}
|
||||
worldRef._effectUpdateCursor = cursor;
|
||||
}
|
||||
worldRef._effectUpdateCursor = cursor;
|
||||
worldRef._effectUpdateStats = {
|
||||
total,
|
||||
beforePressure,
|
||||
updated,
|
||||
skipped: Math.max(0, total - updated),
|
||||
budget,
|
||||
runScale: Number(runScale.toFixed(2)),
|
||||
compacted: pressure.compacted,
|
||||
dropped: pressure.dropped,
|
||||
droppedTotal: worldRef._effectPressureDroppedTotal || 0,
|
||||
batched: true,
|
||||
};
|
||||
|
||||
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||
const spawn = worldRef._effectSpawnStats || {};
|
||||
worldRef._effectUpdateStats = {
|
||||
total, beforePressure, updated: updated + lightweight, lightweight,
|
||||
skipped: Math.max(0, active - updated), budget,
|
||||
runScale: Number(runScale.toFixed(2)), compacted: pressure.compacted, dropped: pressure.dropped,
|
||||
droppedTotal: worldRef._effectPressureDroppedTotal || 0, batched: budget < active,
|
||||
spawnRequested: spawn.requested || 0, offscreenCulled: spawn.offscreenCulled || 0,
|
||||
poolEligible: spawn.poolEligible || 0, pooled: spawn.pooled || 0,
|
||||
};
|
||||
} else worldRef._effectUpdateStats = null;
|
||||
}
|
||||
|
||||
global.TarinaiEffectsSimulationSystem = Object.freeze({
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@
|
|||
function updateClock(worldRef, dt) {
|
||||
const previousDay = worldRef.day || 1;
|
||||
worldRef.time += dt;
|
||||
worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1;
|
||||
worldRef.elapsedDays = Math.max(0, Math.floor(worldRef.time / CONFIG.dayLength));
|
||||
worldRef.day = worldRef.elapsedDays + 1;
|
||||
if (worldRef.day !== previousDay) {
|
||||
for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} };
|
||||
global.TarinaiAchievements?.evaluateEvent?.(worldRef, "season", { previousDay, day: worldRef.day });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,8 +45,10 @@
|
|||
}
|
||||
|
||||
function prepareSpatialFrame(worldRef) {
|
||||
worldRef.spatialRebuildsThisFrame = 0;
|
||||
worldRef.spatialDirtyMarksThisFrame = 0;
|
||||
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||
worldRef.spatialRebuildsThisFrame = 0;
|
||||
worldRef.spatialDirtyMarksThisFrame = 0;
|
||||
}
|
||||
worldRef.ensureSpatial?.("update-start");
|
||||
worldRef.beginFramePerformanceBudgets?.();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,75 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: simulation/system-maintenance
|
||||
// Owns periodic compaction, counts refresh, grass limit enforcement,
|
||||
// out-of-bounds cleanup, and draw-list invalidation.
|
||||
// Owns low-frequency cleanup and cache maintenance. Heavy sweeps are deliberately
|
||||
// staggered so they do not all land on the same animation frame.
|
||||
(function (global) {
|
||||
function initializeSchedule(worldRef) {
|
||||
if (worldRef._maintenanceScheduleReady) return;
|
||||
const now = Number(worldRef.time || 0) || 0;
|
||||
worldRef._maintenanceScheduleReady = true;
|
||||
worldRef._nextStructureDependencyCheckAt = now + 0.85;
|
||||
worldRef._nextItemCompactAt = now + 1.65;
|
||||
worldRef._nextAntCompactAt = now + 3.35;
|
||||
worldRef._nextEffectCountRefreshAt = now + 2.15;
|
||||
worldRef._nextFamilyPruneAt = now + 9.1;
|
||||
if (!Number.isFinite(worldRef.nextGrassLimitCheckAt) || worldRef.nextGrassLimitCheckAt <= now) worldRef.nextGrassLimitCheckAt = now + 1.25;
|
||||
if (!Number.isFinite(worldRef.nextFireCompactAt) || worldRef.nextFireCompactAt <= now) worldRef.nextFireCompactAt = now + 0.45;
|
||||
if (!Number.isFinite(worldRef.nextGrassBedExpiryCheckAt) || worldRef.nextGrassBedExpiryCheckAt <= now) worldRef.nextGrassBedExpiryCheckAt = now + 2.85;
|
||||
if (!Number.isFinite(worldRef.outOfBoundsCheckAt)) worldRef.outOfBoundsCheckAt = now - 1.55;
|
||||
}
|
||||
|
||||
function runMaintenance(worldRef, dt, itemCountBefore) {
|
||||
worldRef.compactTimer += dt;
|
||||
const compactInterval = 2.5;
|
||||
initializeSchedule(worldRef);
|
||||
const now = Number(worldRef.time || 0) || 0;
|
||||
let itemsCompacted = false;
|
||||
if (worldRef.compactTimer >= compactInterval) {
|
||||
itemsCompacted = worldRef.compactItems();
|
||||
worldRef.compactTarinai();
|
||||
worldRef.compactAnts?.();
|
||||
worldRef.compactTimer = 0;
|
||||
|
||||
// Link/plushie reconciliation is a safety net. Deletions already reconcile
|
||||
// dependencies immediately, so a periodic pass is sufficient during normal play.
|
||||
if (now >= (worldRef._nextStructureDependencyCheckAt || 0)) {
|
||||
worldRef._nextStructureDependencyCheckAt = now + 0.85;
|
||||
const relevantCount = (worldRef.itemCounts?.plushie || 0)
|
||||
+ (worldRef.itemCounts?.rope || 0) + (worldRef.itemCounts?.rod || 0)
|
||||
+ (worldRef.itemCounts?.spring || 0) + (worldRef.itemCounts?.wire || 0)
|
||||
+ (worldRef.itemCounts?.insulated_wire || 0);
|
||||
if (relevantCount > 0 || worldRef.itemBucketsDirty) global.TarinaiStructureLifecycle?.maintainDependencies?.(worldRef);
|
||||
}
|
||||
|
||||
// Keep the existing creature cleanup cadence separate; item/ant sweeps are
|
||||
// offset so large arrays are not compacted on the same frame.
|
||||
worldRef.compactTimer += dt;
|
||||
if (worldRef.compactTimer >= 2.5) {
|
||||
worldRef.compactTarinai();
|
||||
worldRef.compactTimer = 0;
|
||||
}
|
||||
if (now >= (worldRef._nextItemCompactAt || 0)) {
|
||||
worldRef._nextItemCompactAt = now + 3.7;
|
||||
itemsCompacted = worldRef.compactItems();
|
||||
}
|
||||
if (now >= (worldRef._nextAntCompactAt || 0)) {
|
||||
worldRef._nextAntCompactAt = now + 4.3;
|
||||
worldRef.compactAnts?.();
|
||||
}
|
||||
if (worldRef.familyPrunePending && now >= (worldRef._nextFamilyPruneAt || 0)) {
|
||||
worldRef._nextFamilyPruneAt = now + 8.0;
|
||||
worldRef.familyPrunePending = false;
|
||||
worldRef.pruneExtinctFamilies?.();
|
||||
}
|
||||
|
||||
worldRef.countsTimer += dt;
|
||||
const countsInterval = 2.75;
|
||||
const itemCountChanged = worldRef.items.length !== itemCountBefore || worldRef.items.length !== (worldRef._lastMaintenanceItemCount ?? worldRef.items.length);
|
||||
const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length);
|
||||
const countsDue = worldRef.countsTimer >= countsInterval;
|
||||
if (countsDue || itemsCompacted || itemCountChanged || effectCountChanged || worldRef.itemBucketsDirty) {
|
||||
if (countsDue || itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) worldRef.updateItemCounts();
|
||||
if (countsDue || itemsCompacted || effectCountChanged) worldRef.updateEffectCounts();
|
||||
if (itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) {
|
||||
worldRef.updateItemCounts();
|
||||
worldRef._lastMaintenanceItemCount = worldRef.items.length;
|
||||
worldRef._lastMaintenanceEffectCount = worldRef.effects.length;
|
||||
worldRef.countsTimer = 0;
|
||||
}
|
||||
if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) {
|
||||
worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0;
|
||||
if (effectCountChanged || now >= (worldRef._nextEffectCountRefreshAt || 0)) {
|
||||
worldRef._nextEffectCountRefreshAt = now + 4.6;
|
||||
worldRef.updateEffectCounts();
|
||||
worldRef._lastMaintenanceEffectCount = worldRef.effects.length;
|
||||
}
|
||||
|
||||
if (now >= (worldRef.nextGrassLimitCheckAt || 0)) {
|
||||
worldRef.nextGrassLimitCheckAt = now + 3.0;
|
||||
const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0;
|
||||
if (removedGrass > 0) {
|
||||
worldRef.compactItems();
|
||||
|
|
@ -36,27 +77,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
if ((worldRef.time || 0) >= (worldRef.nextFireCompactAt || 0)) {
|
||||
worldRef.nextFireCompactAt = (worldRef.time || 0) + 0.8;
|
||||
if (now >= (worldRef.nextFireCompactAt || 0)) {
|
||||
worldRef.nextFireCompactAt = now + 0.8;
|
||||
const compactedFires = global.TarinaiItemDynamicToolSystem?.compactWorldFires?.(worldRef) || 0;
|
||||
if (compactedFires > 0) {
|
||||
itemsCompacted = true;
|
||||
worldRef.updateItemCounts();
|
||||
}
|
||||
if (compactedFires > 0) worldRef.updateItemCounts();
|
||||
}
|
||||
|
||||
if ((worldRef.time || 0) >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
|
||||
worldRef.nextGrassBedExpiryCheckAt = (worldRef.time || 0) + 5.0;
|
||||
const lifespan = Math.max(1, CONFIG.dayLength || 120); // \u30B2\u30FC\u30E0\u518524\u6642\u9593
|
||||
if (now >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
|
||||
worldRef.nextGrassBedExpiryCheckAt = now + 5.0;
|
||||
const lifespan = Math.max(1, CONFIG.dayLength || 120);
|
||||
let removedBeds = 0;
|
||||
const grassBeds = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grass_bed") : (worldRef.items || []);
|
||||
for (const it of grassBeds || []) {
|
||||
if (!it || it.dead || it.type !== "grass_bed") continue;
|
||||
if (!Number.isFinite(Number(it.createdAt))) it.createdAt = worldRef.time || 0;
|
||||
if ((worldRef.time || 0) - Number(it.createdAt || 0) < lifespan) continue;
|
||||
if (!Number.isFinite(Number(it.createdAt))) it.createdAt = now;
|
||||
if (now - Number(it.createdAt || 0) < lifespan) continue;
|
||||
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, it, {
|
||||
reason: "grass-bed-expired",
|
||||
userReason: "\u304B\u3093\u305F\u3093\u30D9\u30C3\u30C9\u304C\u53E4\u304F\u306A\u3063\u3066\u6D88\u3048\u305F",
|
||||
userReason: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u304c\u53e4\u304f\u306a\u3063\u3066\u6d88\u3048\u305f",
|
||||
wake: false,
|
||||
panicOwner: false,
|
||||
});
|
||||
|
|
@ -66,17 +104,14 @@
|
|||
worldRef.compactItems();
|
||||
worldRef.updateItemCounts();
|
||||
worldRef.markSpatialDirty?.("grass-bed-expired");
|
||||
worldRef.markTerrainDirty?.("grass-bed-expired");
|
||||
}
|
||||
}
|
||||
|
||||
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) {
|
||||
worldRef.outOfBoundsCheckAt = worldRef.time;
|
||||
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= now) {
|
||||
worldRef.outOfBoundsCheckAt = now;
|
||||
worldRef.sanitizeOutOfBounds();
|
||||
}
|
||||
|
||||
// Render builds the visible stack from spatial cells every frame; the old
|
||||
// drawListDirty periodic invalidation is no longer a render input.
|
||||
worldRef.drawSortTimer = 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
return Number.isFinite(n) ? n / scale : fallback;
|
||||
}
|
||||
|
||||
function birthHash32(seed = "") {
|
||||
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
|
||||
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
|
||||
if (match) {
|
||||
const parsed = parseInt(match[1], 36);
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
});
|
||||
function compactBirthDescriptor(t = null, fallbackSerial = 1) {
|
||||
const seed = String(t?.birthSeed || t?.familyKey || t?.id || "");
|
||||
const parsed = global.TarinaiSeedFactory?.serialFromBirthSeed?.(seed) || 0;
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
function clonePlain(value, depth = 0) {
|
||||
function cloneSnapshotPlain(value, depth = 0) {
|
||||
if (value == null) return value;
|
||||
const type = typeof value;
|
||||
if (type === "number") return Number.isFinite(value) ? value : 0;
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
if (Array.isArray(value)) {
|
||||
const out = [];
|
||||
for (const v of value) {
|
||||
const c = clonePlain(v, depth + 1);
|
||||
const c = cloneSnapshotPlain(v, depth + 1);
|
||||
if (c !== undefined) out.push(c);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
const out = {};
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
if (DEFAULT_SKIP.has(key)) continue;
|
||||
const c = clonePlain(v, depth + 1);
|
||||
const c = cloneSnapshotPlain(v, depth + 1);
|
||||
if (c !== undefined) out[key] = c;
|
||||
}
|
||||
return out;
|
||||
|
|
@ -167,7 +167,12 @@
|
|||
function compactWorld(worldRef) {
|
||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
||||
const worldTick10 = q((Math.max(1, worldRef?.day || 1) - 1) * dayLength + (Number(worldRef?.time) || 0), 10);
|
||||
const rawTime = Math.max(0, Number(worldRef?.time) || 0);
|
||||
const elapsedDays = Number.isFinite(Number(worldRef?.elapsedDays))
|
||||
? Math.max(0, Math.floor(Number(worldRef.elapsedDays) || 0))
|
||||
: Math.max(0, Math.floor(rawTime / dayLength));
|
||||
const timeOfDay = ((rawTime % dayLength) + dayLength) % dayLength;
|
||||
const worldTick10 = q(elapsedDays * dayLength + timeOfDay, 10);
|
||||
return [
|
||||
enumIndex(FIELD_IDS, worldRef?.fieldType || "garden"),
|
||||
enumIndex(GROUND_IDS, worldRef?.groundType || "soil"),
|
||||
|
|
@ -181,20 +186,48 @@
|
|||
q(worldRef?.birthSerial, 1, 0),
|
||||
q(worldRef?.tarinaiPopulationLimit, 1, 0),
|
||||
q(worldRef?.objectLimit, 1, 0),
|
||||
[...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
[...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b),
|
||||
q(worldRef?.achievementPlayerPlacementCount, 1, 0),
|
||||
q(worldRef?.achievementLastInterventionAt, 10, 0),
|
||||
q(worldRef?.achievementNoDeathStartAt, 10, -10),
|
||||
q(worldRef?.achievementDirectFeedCount, 1, 0),
|
||||
elapsedDays,
|
||||
q(worldRef?.achievementManualTarinaiAddedCount, 1, 0),
|
||||
];
|
||||
}
|
||||
function applyCompactWorld(worldRef, arr = []) {
|
||||
function applyCompactWorld(worldRef, arr = [], meta = []) {
|
||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
||||
const totalTime = u(arr[3], 10, 0);
|
||||
const encodedTotalTime = Math.max(0, u(arr[3], 10, 0));
|
||||
const hasEncodedElapsedDays = arr[18] !== null && arr[18] !== undefined && Number.isFinite(Number(arr[18]));
|
||||
const hasMetaElapsedDays = meta[3] !== null && meta[3] !== undefined && Number.isFinite(Number(meta[3]));
|
||||
const hasMetaDay = meta[0] !== null && meta[0] !== undefined && Number.isFinite(Number(meta[0]));
|
||||
const encodedElapsedDays = hasEncodedElapsedDays
|
||||
? Math.max(0, Math.floor(Number(arr[18]) || 0))
|
||||
: (hasMetaElapsedDays
|
||||
? Math.max(0, Math.floor(Number(meta[3]) || 0))
|
||||
: (hasMetaDay
|
||||
? Math.max(0, Math.floor((Number(meta[0]) || 1) - 1))
|
||||
: Math.max(0, Math.floor(encodedTotalTime / dayLength / 2))));
|
||||
const timeOfDay = ((encodedTotalTime % dayLength) + dayLength) % dayLength;
|
||||
const totalTime = encodedElapsedDays * dayLength + timeOfDay;
|
||||
worldRef.fieldType = enumValue(FIELD_IDS, arr[0], "garden");
|
||||
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
|
||||
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
|
||||
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
|
||||
worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0));
|
||||
worldRef.objectLimit = Math.max(0, u(arr[11], 1, 0));
|
||||
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
||||
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
|
||||
worldRef.tarinaiPopulationLimit = worldRef.normalizeColonyLimit?.(u(arr[10], 1, 0)) ?? Math.min(300, Math.max(0, u(arr[10], 1, 0)));
|
||||
worldRef.objectLimit = worldRef.normalizeColonyLimit?.(u(arr[11], 1, 0)) ?? Math.min(300, Math.max(0, u(arr[11], 1, 0)));
|
||||
worldRef.achievementNaturalSlaveGenerations = Array.isArray(arr[12]) ? [...new Set(arr[12].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
|
||||
worldRef.achievementNaturalKingGenerations = Array.isArray(arr[13]) ? [...new Set(arr[13].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [];
|
||||
worldRef.achievementPlayerPlacementCount = Math.max(0, u(arr[14], 1, 0));
|
||||
worldRef.achievementLastInterventionAt = Math.max(0, u(arr[15], 10, totalTime));
|
||||
worldRef.achievementNoDeathStartAt = u(arr[16], 10, -1);
|
||||
worldRef.achievementDirectFeedCount = Math.max(0, u(arr[17], 1, 0));
|
||||
worldRef.achievementManualTarinaiAddedCount = Math.min(100, Math.max(0, u(arr[19], 1, 0)));
|
||||
worldRef.time = Math.max(0, totalTime);
|
||||
worldRef.elapsedDays = encodedElapsedDays;
|
||||
worldRef.day = worldRef.elapsedDays + 1;
|
||||
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||
worldRef.deadCount = u(arr[7], 1, 0);
|
||||
worldRef.liveIdNext = 1;
|
||||
|
|
@ -205,6 +238,90 @@
|
|||
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || { id: moodId, label: moodId, effects: { personality: {} } };
|
||||
worldRef.setGroundType?.(enumValue(GROUND_IDS, arr[1], "soil"), { silent: true, force: true });
|
||||
}
|
||||
function finiteOrNull(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function compactAchievementMetadata(worldRef, tarinaiLive = [], itemRecords = [], options = {}) {
|
||||
const isUnlocked = id => Boolean(global.TarinaiAchievements?.isUnlocked?.(id));
|
||||
const needSlaveGenerations = !isUnlocked("natural_zunchi_slave_5_generations");
|
||||
const needKingGenerations = !isUnlocked("natural_tarinai_king_3_generations");
|
||||
const needDirectFeed = !isUnlocked("direct_feed_33");
|
||||
const needRobotClean = !isUnlocked("robot_cleaner_100");
|
||||
const needEnemyFriend = !isUnlocked("enemy_enemy_friend");
|
||||
const needSelfSufficient = !isUnlocked("self_sufficient");
|
||||
const needIdleObserver = !isUnlocked("idle_observer_5_minutes");
|
||||
const needSafeColony = !isUnlocked("safe_colony_25_5_minutes");
|
||||
const needHappyColony = !isUnlocked("colony_happy");
|
||||
const needOverprotective = !isUnlocked("overprotective");
|
||||
const needSauna = !isUnlocked("sauna_cold_plunge");
|
||||
const needQuickDelete = !isUnlocked("unplanned_city_30");
|
||||
const needMinimalist = !isUnlocked("minimalist_happy");
|
||||
const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;
|
||||
const metadata = {
|
||||
w: [
|
||||
needSlaveGenerations ? [...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
|
||||
needKingGenerations ? [...new Set((worldRef?.achievementNaturalKingGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
|
||||
needDirectFeed ? Math.max(0, Math.floor(Number(worldRef?.achievementDirectFeedCount || 0) || 0)) : 0,
|
||||
needRobotClean ? Math.max(0, Math.floor(Number(worldRef?.achievementRobotCleanCount || 0) || 0)) : 0,
|
||||
needEnemyFriend ? Math.max(0, Math.floor(Number(worldRef?.achievementEnemyAntKills || 0) || 0)) : 0,
|
||||
needEnemyFriend ? finiteOrNull(worldRef?.achievementLastTarinaiDamageAt) : null,
|
||||
needSelfSufficient ? finiteOrNull(worldRef?.achievementSelfSufficientStartAt) : -1,
|
||||
needSelfSufficient ? finiteOrNull(worldRef?.achievementLastDirectFeedAt) : null,
|
||||
needIdleObserver ? finiteOrNull(worldRef?.achievementLastInterventionAt) : 0,
|
||||
needSafeColony ? finiteOrNull(worldRef?.achievementNoDeathStartAt) : -1,
|
||||
needHappyColony ? finiteOrNull(worldRef?.achievementHappyStartAt) : -1,
|
||||
],
|
||||
t: tarinaiLive.map(t => [
|
||||
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectFeedCount || 0) || 0)) : 0,
|
||||
needOverprotective ? Math.max(0, Math.floor(Number(t?._achievementDirectTreatmentCount || 0) || 0)) : 0,
|
||||
null,
|
||||
needSauna ? finiteOrNull(t?._achievementSaunaHotAt) : null,
|
||||
]),
|
||||
i: itemRecords.map(rec => [
|
||||
needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0,
|
||||
needQuickDelete ? finiteOrNull(rec?.item?._achievementPlacedAt) : null,
|
||||
]),
|
||||
};
|
||||
if (options.includeAchievements) {
|
||||
const spellState = global.TarinaiAchievements?.exportSpellState?.();
|
||||
if (Array.isArray(spellState)) metadata.s = spellState;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
function applyAchievementMetadata(worldRef, metadata = null) {
|
||||
if (!worldRef || !metadata || typeof metadata !== "object") return false;
|
||||
const w = Array.isArray(metadata.w) ? metadata.w : [];
|
||||
worldRef.achievementNaturalSlaveGenerations = Array.isArray(w[0]) ? [...new Set(w[0].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalSlaveGenerations || []);
|
||||
worldRef.achievementNaturalKingGenerations = Array.isArray(w[1]) ? [...new Set(w[1].map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : (worldRef.achievementNaturalKingGenerations || []);
|
||||
worldRef.achievementDirectFeedCount = Math.max(0, Math.floor(Number(w[2] || 0) || 0));
|
||||
worldRef.achievementRobotCleanCount = Math.max(0, Math.floor(Number(w[3] || 0) || 0));
|
||||
worldRef.achievementEnemyAntKills = Math.max(0, Math.floor(Number(w[4] || 0) || 0));
|
||||
worldRef.achievementLastTarinaiDamageAt = w[5] != null && Number.isFinite(Number(w[5])) ? Number(w[5]) : -Infinity;
|
||||
worldRef.achievementSelfSufficientStartAt = w[6] != null && Number.isFinite(Number(w[6])) ? Number(w[6]) : -1;
|
||||
worldRef.achievementLastDirectFeedAt = w[7] != null && Number.isFinite(Number(w[7])) ? Number(w[7]) : -Infinity;
|
||||
worldRef.achievementLastInterventionAt = w[8] != null && Number.isFinite(Number(w[8])) ? Number(w[8]) : Math.max(0, Number(worldRef.time || 0) || 0);
|
||||
worldRef.achievementNoDeathStartAt = w[9] != null && Number.isFinite(Number(w[9])) ? Number(w[9]) : -1;
|
||||
worldRef.achievementHappyStartAt = w[10] != null && Number.isFinite(Number(w[10])) ? Number(w[10]) : -1;
|
||||
const tarRows = Array.isArray(metadata.t) ? metadata.t : [];
|
||||
(worldRef.tarinai || []).forEach((t, index) => {
|
||||
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
|
||||
t._achievementDirectFeedCount = Math.max(0, Math.floor(Number(row[0] || 0) || 0));
|
||||
t._achievementDirectTreatmentCount = Math.max(0, Math.floor(Number(row[1] || 0) || 0));
|
||||
t._achievementFightMochiAt = null;
|
||||
t._achievementFightMochiSerial = 0;
|
||||
t._achievementFightMochiWorld = null;
|
||||
t._achievementSaunaHotAt = row[3] != null && Number.isFinite(Number(row[3])) ? Number(row[3]) : null;
|
||||
});
|
||||
const itemRows = Array.isArray(metadata.i) ? metadata.i : [];
|
||||
(worldRef.items || []).forEach((item, index) => {
|
||||
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
|
||||
item._achievementPlayerPlaced = Boolean(row[0]);
|
||||
item._achievementPlacedAt = row[1] != null && Number.isFinite(Number(row[1])) ? Number(row[1]) : null;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function compactRelationships(t, tarinaiIndex) {
|
||||
const out = [];
|
||||
for (const [id, rel] of Object.entries(t.relationships || {})) {
|
||||
|
|
@ -241,7 +358,7 @@
|
|||
const parentIdx = (t.parents || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
|
||||
const timers = [
|
||||
q(t.reproductionTimer, 10), q(t.fightCooldown, 10), q(t.loveMochiTimer, 10), q(t.fightMochiTimer, 10),
|
||||
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100, 100)
|
||||
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100, 100), q(t.itemEffectTimers?.sedative, 10)
|
||||
];
|
||||
const modes = [enumIndex(POWER_MODE_IDS, t.powerItemMode || ""), enumIndex(SIZE_MODE_IDS, t.sizeItemMode || ""), enumIndex(LIFE_MODE_IDS, t.lifeItemMode || "")];
|
||||
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
|
||||
|
|
@ -301,7 +418,7 @@
|
|||
const timers = Array.isArray(row[13]) ? row[13] : [];
|
||||
t.reproductionTimer = u(timers[0], 10); t.fightCooldown = u(timers[1], 10); t.loveMochiTimer = u(timers[2], 10); t.fightMochiTimer = u(timers[3], 10);
|
||||
t.zunchiDiseaseSeverity = u(timers[4], 10); t.zunchiStain = u(timers[5], 10);
|
||||
t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[6], 10), ammo: u(timers[7], 10) };
|
||||
t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[6], 10), ammo: u(timers[7], 10), sedative: u(timers[9], 10) };
|
||||
t.mercuryLifeMultiplier = u(timers[8], 100, t.mercuryLifeMultiplier || 1);
|
||||
const modes = Array.isArray(row[14]) ? row[14] : [];
|
||||
t.powerItemMode = enumValue(POWER_MODE_IDS, modes[0], "");
|
||||
|
|
@ -323,6 +440,7 @@
|
|||
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
|
||||
if (type === "signboard") return [item.text || ""];
|
||||
if (type === "robot_cleaner") return [Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask ?? 3) | 0)), item.robotCleanerHighSpeed ? 1 : 0];
|
||||
if (type === "circuit_board") return [JSON.stringify(global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || item.circuitConfig || {})];
|
||||
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) {
|
||||
const packed = global.TarinaiPhysicsBodySystem.compactItemExtra(item, tarinaiIndex, itemIndex);
|
||||
if (packed) return packed;
|
||||
|
|
@ -333,7 +451,7 @@
|
|||
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
|
||||
if (type === "ant_corpse") return [q(item.decayTimer, 10)];
|
||||
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)];
|
||||
if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1];
|
||||
if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1, q(item.stickyBombPassCount, 1)];
|
||||
if (type === "fire") return [q(item.flameLife, 10)];
|
||||
if (type === "nest_box") return [q(item.burnTimer, 10), q(item.nestFireEvacuationTimer, 10), item.burning ? 1 : 0, item.nestFireEvacuated ? 1 : 0];
|
||||
if (global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
|
||||
|
|
@ -346,7 +464,18 @@
|
|||
q(item.fanSwingPhase || 0, 1000),
|
||||
q(item.fanBodyAngle ?? item.angle ?? 0, 1000),
|
||||
];
|
||||
if (type === "pressure_switch") return [item.pressureTarget === "item" ? 1 : 0, Math.max(1, Math.min(999, Number(item.pressureThreshold || 1) | 0)), Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0)), Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0))];
|
||||
if (type === "pressure_switch") {
|
||||
const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"];
|
||||
return [
|
||||
Math.max(0, targets.indexOf(item.pressureTarget || "tarinai")),
|
||||
q(item.pressureMin ?? 1, 10),
|
||||
q(item.pressureMax ?? 300, 10),
|
||||
Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0)),
|
||||
Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0)),
|
||||
Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0)),
|
||||
Math.max(0, Math.min(1435, Number(item.pressureTimeEnd ?? 1080) | 0)),
|
||||
];
|
||||
}
|
||||
if (global.isRotatableItemType(type)) return [q(item.angle, 1000, q(global.defaultItemAngle(type), 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
|
||||
if (global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
|
||||
return [];
|
||||
|
|
@ -378,8 +507,8 @@
|
|||
const def = global.StructureRegistry?.get?.(type);
|
||||
if (def) {
|
||||
item.label = def.label;
|
||||
item.roles = clonePlain(def.roles) || item.roles || {};
|
||||
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
|
||||
item.roles = cloneSnapshotPlain(def.roles) || item.roles || {};
|
||||
item.needEffects = cloneSnapshotPlain(def.needEffects) || item.needEffects || {};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -411,7 +540,7 @@
|
|||
} else if (type === "firecracker" || type === "flame_firecracker") {
|
||||
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
|
||||
} else if (type === "sticky_bomb") {
|
||||
item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || "";
|
||||
item.fuseTimer = u(extra[0], 10, item.fuseTimer || 12); item.fuseMax = Math.max(item.fuseMax || 12, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || ""; item.stickyBombPassCount = Math.max(0, u(extra[2], 1, 0));
|
||||
} else if (type === "fire") {
|
||||
item.flameLife = u(extra[0], 10, item.flameLife || 6);
|
||||
} else if (type === "nest_box") {
|
||||
|
|
@ -425,6 +554,11 @@
|
|||
}
|
||||
} else if (global.isPinType(type)) {
|
||||
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
|
||||
} else if (type === "circuit_board") {
|
||||
let config = {};
|
||||
try { config = JSON.parse(String(extra[0] || "{}")); } catch (_) { config = {}; }
|
||||
global.TarinaiCircuitBoardSystem?.applySerializedConfig?.(item, config);
|
||||
global.TarinaiCircuitBoardSystem?.evaluate?.(item);
|
||||
} else if (type === "fan") {
|
||||
const fallback = global.defaultItemAngle(type);
|
||||
item.angle = global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback);
|
||||
|
|
@ -435,10 +569,15 @@
|
|||
item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
|
||||
item.fanBodyAngle = global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle);
|
||||
} else if (type === "pressure_switch") {
|
||||
item.pressureTarget = Number(extra[0]) === 1 ? "item" : "tarinai";
|
||||
item.pressureThreshold = Math.max(1, Math.min(999, Number(extra[1] || 1) | 0));
|
||||
item.pressureWidth = Math.max(60, Math.min(840, Number(extra[2] || 220) | 0));
|
||||
item.pressureHeight = Math.max(60, Math.min(840, Number(extra[3] || 160) | 0));
|
||||
const targets = ["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"];
|
||||
item.pressureTarget = targets[Number(extra[0]) | 0] || "tarinai";
|
||||
item.pressureMin = u(extra[1], 10, 1);
|
||||
item.pressureMax = u(extra[2], 10, 300);
|
||||
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
|
||||
item.pressureWidth = Math.max(60, Math.min(840, Number(extra[3] || 220) | 0));
|
||||
item.pressureHeight = Math.max(60, Math.min(840, Number(extra[4] || 160) | 0));
|
||||
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(extra[5] ?? 360) | 0));
|
||||
item.pressureTimeEnd = Math.max(0, Math.min(1435, Number(extra[6] ?? 1080) | 0));
|
||||
item.signalActive = false;
|
||||
} else if (global.isRotatableItemType(type)) {
|
||||
const fallback = global.defaultItemAngle(type);
|
||||
|
|
@ -448,7 +587,7 @@
|
|||
item.foodServingsRemaining = u(extra[0], 10, item.amount || 1); item.foodServingsMax = u(extra[1], 10, item.foodServingsRemaining || item.amount || 1); item.amount = item.foodServingsRemaining; global.updateServingFoodVisualSize(item);
|
||||
}
|
||||
}
|
||||
function createSnapshot(worldRef = global.world) {
|
||||
function createSnapshot(worldRef = global.world, options = {}) {
|
||||
if (!worldRef) throw new Error("world is not ready");
|
||||
const tarinaiLive = (worldRef.tarinai || [])
|
||||
.filter(t => t && !t.dead)
|
||||
|
|
@ -471,10 +610,11 @@
|
|||
return {
|
||||
v: SNAPSHOT_VERSION,
|
||||
a: "tj1",
|
||||
m: [worldRef.day || 1, tarinaiLive.length, worldRef.fieldType || "garden"],
|
||||
m: [worldRef.day || 1, tarinaiLive.length, worldRef.fieldType || "garden", Math.max(0, Math.floor(Number(worldRef.elapsedDays ?? ((Number(worldRef.day || 1) || 1) - 1)) || 0))],
|
||||
w: compactWorld(worldRef),
|
||||
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
|
||||
i: itemRows,
|
||||
g: compactAchievementMetadata(worldRef, tarinaiLive, itemRecords, options),
|
||||
};
|
||||
}
|
||||
function restoreSnapshot(snapshot, worldRef = global.world) {
|
||||
|
|
@ -491,7 +631,7 @@
|
|||
worldRef.selected = null;
|
||||
worldRef.events = global.TarinaiEvents || null;
|
||||
worldRef.liveTarinai = new Map();
|
||||
applyCompactWorld(worldRef, snapshot.w || []);
|
||||
applyCompactWorld(worldRef, snapshot.w || [], snapshot.m || []);
|
||||
|
||||
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
||||
for (let idx = 0; idx < tarRows.length; idx++) {
|
||||
|
|
@ -559,10 +699,28 @@
|
|||
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
|
||||
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; delete t.__savedStateInfo;
|
||||
}
|
||||
applyAchievementMetadata(worldRef, snapshot.g || null);
|
||||
const updateNeedsRuntime = global.TarinaiNeedsRuntime?.updateNeeds;
|
||||
if (typeof updateNeedsRuntime === "function") {
|
||||
for (const t of worldRef.tarinai) updateNeedsRuntime(t, worldRef, 0);
|
||||
}
|
||||
|
||||
// Velocity is not serialized. Start restored bodies from a neutral physical
|
||||
// state, but do not grant a timed collision/damage immunity period.
|
||||
delete worldRef._restoreSettleUntil;
|
||||
for (const t of worldRef.tarinai) {
|
||||
if (!t || t.dead) continue;
|
||||
t.vx = 0;
|
||||
t.vy = 0;
|
||||
t.impulseVx = 0;
|
||||
t.impulseVy = 0;
|
||||
t.prevX = t.x;
|
||||
t.prevY = t.y;
|
||||
t._lastPhysicalVelocityX = 0;
|
||||
t._lastPhysicalVelocityY = 0;
|
||||
t.lastVelocityShockDamageAt = -999;
|
||||
t.lastPhysicalCollisionDamageAt = -999;
|
||||
}
|
||||
worldRef.liveIdNext = Math.max(1, worldRef.tarinai.length + 1);
|
||||
worldRef.liveIdSerial = Math.max(1, worldRef.tarinai.length + 1);
|
||||
worldRef.birthSerial = Math.max(Number(worldRef.birthSerial) || 0, ...worldRef.tarinai.map(t => Number(t.birthSerial) || 0));
|
||||
|
|
@ -576,15 +734,20 @@
|
|||
worldRef.relationNotices = {};
|
||||
worldRef.resolvedFightIds = {};
|
||||
worldRef.eventCounters = {};
|
||||
worldRef.rebuildTarinaiCountCache?.("restore");
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.enforceGrassLimit?.("load-grass-limit");
|
||||
worldRef.compactItems?.();
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.updateEffectCounts?.();
|
||||
global.TarinaiAchievements?.evaluateEvent?.(worldRef, "restore", { source: "snapshot" });
|
||||
worldRef.rebuildSpatial?.(true);
|
||||
worldRef.markTerrainDirty?.("load");
|
||||
worldRef.clampCamera?.();
|
||||
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION };
|
||||
const achievementImport = snapshot.g?.s
|
||||
? global.TarinaiAchievements?.importSpellState?.(snapshot.g.s) || { included: true, unlockedAdded: 0, progressChanged: false }
|
||||
: { included: false, unlockedAdded: 0, progressChanged: false };
|
||||
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION, achievementImport };
|
||||
}
|
||||
|
||||
global.TarinaiSnapshot = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const CONNECTION_ITEM_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
||||
|
||||
function isLiveLooseItem(item) {
|
||||
return Boolean(item && !item.dead && !(Number.isFinite(Number(item.amount)) && Number(item.amount) <= 0));
|
||||
}
|
||||
|
||||
function markWorldAfterStructureChange(worldRef, reason = "structure-change", item = null, radius = 40) {
|
||||
if (!worldRef) return;
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
@ -132,36 +138,143 @@
|
|||
}
|
||||
|
||||
|
||||
function removeDependentLinks(worldRef, target, reason = "support-removed") {
|
||||
if (!worldRef || !target || !target.id) return 0;
|
||||
function endpointTarget(worldRef, endpoint) {
|
||||
if (!worldRef || !endpoint) return null;
|
||||
const cached = endpoint._ref;
|
||||
if (endpoint.kind === "tarinai") {
|
||||
const token = endpoint.liveToken ?? null;
|
||||
const found = worldRef.liveTarinaiById?.(endpoint.id, token)
|
||||
|| worldRef.tarinaiById?.get?.(endpoint.id)
|
||||
|| (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id)
|
||||
|| null;
|
||||
if (!found || found.dead) return null;
|
||||
if (token != null && Number(found.liveToken ?? token) !== Number(token)) return null;
|
||||
return found;
|
||||
}
|
||||
if (endpoint.kind === "ant") {
|
||||
const found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null;
|
||||
return found && !found.dead ? found : null;
|
||||
}
|
||||
let found = worldRef.itemById?.(endpoint.id) || null;
|
||||
if (found) return isLiveLooseItem(found) ? found : null;
|
||||
found = (worldRef.items || []).find(item => item && item.id === endpoint.id) || null;
|
||||
if (found) return isLiveLooseItem(found) ? found : null;
|
||||
if (cached && (!endpoint.id || cached.id === endpoint.id) && isLiveLooseItem(cached)) return cached;
|
||||
return null;
|
||||
}
|
||||
|
||||
function connectionItems(worldRef) {
|
||||
if (!worldRef) return [];
|
||||
if (typeof worldRef.itemsOfType !== "function") return (worldRef.items || []).filter(item => isLiveLooseItem(item) && CONNECTION_ITEM_TYPES.has(item.type));
|
||||
const out = [];
|
||||
for (const type of CONNECTION_ITEM_TYPES) {
|
||||
for (const item of worldRef.itemsOfType(type) || []) if (isLiveLooseItem(item)) out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function removeOrphanedLinks(worldRef, reason = "link-endpoint-gone") {
|
||||
if (!worldRef?.items) return 0;
|
||||
const pb = global.TarinaiPhysicsBodySystem || null;
|
||||
if (!pb?.endpoint) return 0;
|
||||
let removed = 0;
|
||||
const removedIds = new Set([target.id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const link of worldRef.items || []) {
|
||||
if (!link || link.dead || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(link.type)) continue;
|
||||
const a = pb.endpoint(link, 0);
|
||||
const b = pb.endpoint(link, 1);
|
||||
const hit = (a && a.kind === "item" && removedIds.has(a.id)) || (b && b.kind === "item" && removedIds.has(b.id));
|
||||
if (!hit) continue;
|
||||
link.amount = 0;
|
||||
link.hp = 0;
|
||||
removedIds.add(link.id);
|
||||
removed += 1;
|
||||
changed = true;
|
||||
const links = connectionItems(worldRef);
|
||||
const linksById = new Map(links.map(link => [link.id, link]));
|
||||
const dependents = new Map();
|
||||
const queue = [];
|
||||
const queued = new Set();
|
||||
const enqueue = link => {
|
||||
if (!link || queued.has(link.id)) return;
|
||||
queued.add(link.id);
|
||||
queue.push(link);
|
||||
};
|
||||
for (const link of links) {
|
||||
let orphaned = false;
|
||||
for (const endpoint of [pb.endpoint(link, 0), pb.endpoint(link, 1)]) {
|
||||
const target = endpointTarget(worldRef, endpoint);
|
||||
if (!target) {
|
||||
orphaned = true;
|
||||
continue;
|
||||
}
|
||||
if (endpoint?.kind === "item" && linksById.has(target.id)) {
|
||||
if (!dependents.has(target.id)) dependents.set(target.id, []);
|
||||
dependents.get(target.id).push(link);
|
||||
}
|
||||
}
|
||||
if (orphaned) enqueue(link);
|
||||
}
|
||||
if (removed) {
|
||||
let removed = 0;
|
||||
let queueIndex = 0;
|
||||
while (queueIndex < queue.length) {
|
||||
const link = queue[queueIndex++];
|
||||
if (!isLiveLooseItem(link)) continue;
|
||||
if (!markLooseItemGone(link, reason)) continue;
|
||||
removed += 1;
|
||||
for (const dependent of dependents.get(link.id) || []) enqueue(dependent);
|
||||
}
|
||||
let pendingCleared = false;
|
||||
const pending = worldRef.pendingLinkEndpoint || null;
|
||||
if (pending && !endpointTarget(worldRef, pending)) {
|
||||
worldRef.pendingLinkEndpoint = null;
|
||||
pendingCleared = true;
|
||||
}
|
||||
if (removed || pendingCleared) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markItemBucketsDirty?.("dependent-links-removed");
|
||||
worldRef.markSpatialDirty?.("dependent-links-removed");
|
||||
worldRef.markItemBucketsDirty?.(reason);
|
||||
worldRef.markSpatialDirty?.(reason);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function reconcilePlushies(worldRef, reason = "plushie-attachment-reconcile") {
|
||||
if (!worldRef?.items) return { restored: 0, removed: 0 };
|
||||
let restored = 0;
|
||||
let removed = 0;
|
||||
const source = worldRef.itemsOfType?.("plushie") || worldRef.items;
|
||||
for (const plushie of source || []) {
|
||||
if (!plushie || plushie.dead || !plushie.isStructure || plushie.type !== "plushie") continue;
|
||||
if (plushie.playerHeld || plushie._heldByPlayer || Number(plushie.plushieFlingTimer || 0) > 0) continue;
|
||||
const owner = worldRef.liveTarinaiById?.(plushie.ownerId)
|
||||
|| (worldRef.tarinai || []).find(t => t && !t.dead && t.id === plushie.ownerId)
|
||||
|| null;
|
||||
if (!plushie.ownerId || !owner || owner.dead) {
|
||||
if (markStructureGone(plushie, "plushie-owner-gone")) removed += 1;
|
||||
plushie.carriedById = "";
|
||||
plushie.onHead = false;
|
||||
continue;
|
||||
}
|
||||
if (plushie.carriedById === owner.id && plushie.onHead) continue;
|
||||
const nextX = Number(owner.x || 0) || 0;
|
||||
const nextY = (Number(owner.y || 0) || 0) - Math.max(22, (Number(owner.radius || 24) || 24) * 1.02);
|
||||
plushie.ownerId = owner.id;
|
||||
plushie.carriedById = owner.id;
|
||||
plushie.onHead = true;
|
||||
plushie.x = nextX;
|
||||
plushie.y = nextY;
|
||||
plushie.vx = 0;
|
||||
plushie.vy = 0;
|
||||
plushie.spinVelocity = 0;
|
||||
restored += 1;
|
||||
}
|
||||
if (restored || removed) {
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.markItemBucketsDirty?.(reason);
|
||||
worldRef.markSpatialDirty?.(reason);
|
||||
}
|
||||
return { restored, removed };
|
||||
}
|
||||
|
||||
function maintainDependencies(worldRef) {
|
||||
const linksRemoved = removeOrphanedLinks(worldRef, "link-endpoint-gone");
|
||||
const plushies = reconcilePlushies(worldRef, "plushie-attachment-reconcile");
|
||||
return { linksRemoved, plushiesRestored: plushies.restored, plushiesRemoved: plushies.removed };
|
||||
}
|
||||
|
||||
|
||||
function removeDependentLinks(worldRef, target, reason = "support-removed") {
|
||||
if (!worldRef || !target || !target.id) return 0;
|
||||
return removeOrphanedLinks(worldRef, reason);
|
||||
}
|
||||
|
||||
function deleteStructure(worldRef, structure, opts = {}) {
|
||||
if (!structure || structure.dead) return false;
|
||||
const reason = opts.reason || "structure-deleted";
|
||||
|
|
@ -169,6 +282,7 @@
|
|||
const ownerPanic = shouldPanicOwnerOnStructureGone(structure, reason);
|
||||
const changed = markStructureGone(structure, reason);
|
||||
if (!changed) return false;
|
||||
worldRef.noteItemInactive?.(structure, reason);
|
||||
removeDependentLinks(worldRef, structure, reason);
|
||||
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
|
||||
markWorldAfterStructureChange(worldRef, reason);
|
||||
|
|
@ -182,6 +296,7 @@
|
|||
if (user?.target === bed) user.target = null;
|
||||
const changed = markStructureGone(bed, "grass-bed-wake-used");
|
||||
if (!changed) return false;
|
||||
worldRef.noteItemInactive?.(bed, "grass-bed-wake-used");
|
||||
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -192,6 +307,7 @@
|
|||
if (item.isStructure) return deleteStructure(worldRef, item, { reason, userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner });
|
||||
const changed = markLooseItemGone(item, reason);
|
||||
if (!changed) return false;
|
||||
worldRef.noteItemInactive?.(item, reason);
|
||||
removeDependentLinks(worldRef, item, reason);
|
||||
markWorldAfterStructureChange(worldRef, reason, item, Math.max(36, Number(item.r || item.radius || 24) + 18));
|
||||
return true;
|
||||
|
|
@ -200,6 +316,9 @@
|
|||
const api = Object.freeze({
|
||||
consumeGrassBedOnWake,
|
||||
deleteItem,
|
||||
maintainDependencies,
|
||||
reconcilePlushies,
|
||||
removeOrphanedLinks,
|
||||
triggerOwnedStructureDestroyedPanic,
|
||||
});
|
||||
global.TarinaiStructureLifecycle = api;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function clonePlain(value) {
|
||||
return JSON.parse(JSON.stringify(value || {}));
|
||||
}
|
||||
const clonePlain = global.TarinaiCoreHelpers?.clonePlain || (value => JSON.parse(JSON.stringify(value || {})));
|
||||
|
||||
class TarinaiStructure {
|
||||
constructor(definition, owner, x, y, world) {
|
||||
|
|
@ -64,14 +62,14 @@
|
|||
this.plushieTrailTimer = Math.max(0, (this.plushieTrailTimer || 0) - dt);
|
||||
if (worldRef?.effects && typeof Effect !== "undefined" && this.plushieTrailTimer <= 0) {
|
||||
this.plushieTrailTimer = 0.045;
|
||||
worldRef.effects.push(new Effect("ring", this.x, this.y, { size: 10 + Math.random() * 8, life: 0.16, color: "rgba(190,142,92,0.28)" }));
|
||||
worldRef.spawnEffect("ring", this.x, this.y, { size: 10 + Math.random() * 8, life: 0.16, color: "rgba(190,142,92,0.28)" });
|
||||
}
|
||||
const margin = 96;
|
||||
if (this.plushieFlingTimer <= 0 || this.x < -margin || this.x > (worldRef?.w || 0) + margin || this.y < -margin || this.y > (worldRef?.h || 0) + margin) {
|
||||
this.dead = true;
|
||||
this.hp = 0;
|
||||
this.amount = 0;
|
||||
global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, { type: "fling_out", name: "画面外" });
|
||||
global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, { type: "fling_out", name: "\u753b\u9762\u5916" });
|
||||
global.TarinaiStructureLifecycle?.triggerOwnedStructureDestroyedPanic?.(worldRef, this, null, "plushie-fling-out");
|
||||
worldRef?.markItemBucketsDirty?.("plushie-fling-out");
|
||||
worldRef?.markSpatialDirty?.("plushie-fling-out");
|
||||
|
|
@ -210,8 +208,8 @@
|
|||
if (typeof applyNeedShock === "function") applyNeedShock(owner, { fulfill: 12, safety: 4 }, this);
|
||||
}
|
||||
if (world?.effects && typeof Effect !== "undefined") {
|
||||
world.effects.push(new Effect("ring", this.x, this.y, { size: 30, life: 0.22, color: "rgba(190,142,92,0.38)" }));
|
||||
world.effects.push(new Effect("fall", this.x, this.y, { size: 18, life: 0.28, color: "rgba(190,142,92,0.28)" }));
|
||||
world.spawnEffect("ring", this.x, this.y, { size: 30, life: 0.22, color: "rgba(190,142,92,0.38)" });
|
||||
world.spawnEffect("fall", this.x, this.y, { size: 18, life: 0.28, color: "rgba(190,142,92,0.28)" });
|
||||
}
|
||||
world?.markItemBucketsDirty?.("plushie-fling");
|
||||
world?.markSpatialDirty?.("plushie-fling");
|
||||
|
|
@ -314,7 +312,7 @@
|
|||
|
||||
function damageNearbyStructures(worldRef, x, y, radius, amount, breaker = null) {
|
||||
let hit = 0;
|
||||
for (const structure of worldRef?.nearbyItems?.(x, y, radius + 80) || worldRef?.items || []) {
|
||||
for (const structure of worldRef?.nearbyNonGrassItems?.(x, y, radius + 80) || worldRef?.items || []) {
|
||||
if (!structure?.isStructure || structure.dead) continue;
|
||||
const d = distXY(x, y, structure.x, structure.y);
|
||||
const p = clamp(1 - d / Math.max(1, radius + (structure.r || 12)), 0, 1);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
{ id: "items_and_ants", run(worldRef, ctx) { ctx.mobile = phase("updateItemsAndAnts")?.(worldRef, ctx.dt) || null; } },
|
||||
{ id: "effects", run(worldRef, ctx) { phase("updateEffects")?.(worldRef, ctx.dt); } },
|
||||
{ id: "creatures", run(worldRef, ctx) { phase("updateCreatures")?.(worldRef, ctx.dt); } },
|
||||
{ id: "seesaws", run(worldRef, ctx) { global.TarinaiSeesawSystem?.updateWorld?.(worldRef, ctx.dt); } },
|
||||
{ id: "maintenance", run(worldRef, ctx) { phase("runMaintenance")?.(worldRef, ctx.dt, ctx.mobile?.itemCountBefore ?? (worldRef.items || []).length); } },
|
||||
{ id: "phase_events", run(worldRef) { phase("emitPhaseChange")?.(worldRef); } },
|
||||
{ id: "ambient_tarinai", run(worldRef, ctx) { phase("spawnAmbientTarinai")?.(worldRef, ctx.dt); } },
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
this.itemEffectTimers = {
|
||||
laxative: Math.max(0, Number(effectTimers.laxative || opts.laxativeTimer || 0) || 0),
|
||||
ammo: Math.max(0, Number(effectTimers.ammo || opts.ammoTimer || 0) || 0),
|
||||
sedative: Math.max(0, Number(effectTimers.sedative || opts.sedativeTimer || 0) || 0),
|
||||
};
|
||||
this.laxativePoopTimer = Number.isFinite(opts.laxativePoopTimer) ? opts.laxativePoopTimer : 3;
|
||||
this.ammoBumpCooldown = opts.ammoBumpCooldown || 0;
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ function createTemperatureComfortActionSpec() {
|
|||
: ((target.type === "nest_box" || target.type === "pipe") ? target : null);
|
||||
if (shelter && (shelter.type === "nest_box" || shelter.type === "pipe")) {
|
||||
if (typeof t.enterNestBox === "function" && t.enterNestBox(shelter, dt, { purpose: "temperature" })) {
|
||||
t.thought = shelter.type === "pipe" ? "土管の中で気温をしのいでいる" : "巣箱の中で気温をしのいでいる";
|
||||
t.thought = shelter.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u6c17\u6e29\u3092\u3057\u306e\u3044\u3067\u3044\u308b" : "\u5de3\u7bb1\u306e\u4e2d\u3067\u6c17\u6e29\u3092\u3057\u306e\u3044\u3067\u3044\u308b";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -334,6 +334,7 @@ function createPanicEscapeActionSpec() {
|
|||
},
|
||||
start(t, world, ctx = {}) {
|
||||
const threat = ctx.target ?? this.selectTarget(t, world, ctx);
|
||||
if (threat && threat !== t) t.lastPanicThreat = threat;
|
||||
t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false });
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.6);
|
||||
return true;
|
||||
|
|
@ -343,6 +344,43 @@ function createPanicEscapeActionSpec() {
|
|||
}
|
||||
|
||||
|
||||
|
||||
function createCrowdEscapeActionSpec() {
|
||||
return {
|
||||
id: "crowd_escape",
|
||||
need: "social",
|
||||
subNeed: "crowd",
|
||||
state: "wander",
|
||||
label: "\u6df7\u96d1\u304b\u3089\u5c11\u3057\u96e2\u308c\u3088\u3046\u3068\u3057\u3066\u3044\u308b",
|
||||
phrase: "\u6df7\u96d1\u304b\u3089\u5c11\u3057\u96e2\u308c\u305f",
|
||||
weight: 5,
|
||||
selectTarget(t, world) { return t.target?.crowdEscape ? t.target : findSocialCrowdEscapeTarget(t, world); },
|
||||
canStart(t, world) {
|
||||
const sample = t?.socialCrowdSample || null;
|
||||
const now = Number(world?.time || 0) || 0;
|
||||
const age = sample ? now - (Number(sample.at || 0) || 0) : Infinity;
|
||||
const crowd = Number(t?.socialReasonParts?.crowd || 0) || 0;
|
||||
return age <= 3.5 && crowd >= 18 && Number(sample?.count || 0) >= Number(sample?.upper || Infinity);
|
||||
},
|
||||
start(t, world, ctx = {}) {
|
||||
const target = ctx.target ?? this.selectTarget(t, world, ctx);
|
||||
if (!target) return false;
|
||||
t.setActionState?.("wander", {
|
||||
target,
|
||||
reason: SOCIAL_CROWD_ESCAPE_REASON,
|
||||
wake: true,
|
||||
sleeping: false,
|
||||
actionId: "crowd_escape",
|
||||
actionLabel: this.label,
|
||||
need: "social",
|
||||
subNeed: "crowd",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
tick: updateCrowdEscapeBehavior,
|
||||
};
|
||||
}
|
||||
|
||||
function createApproachFriendActionSpec() {
|
||||
return {
|
||||
id: "approach_friend",
|
||||
|
|
@ -535,6 +573,22 @@ const TARINAI_ACTION_DEFINITIONS = [
|
|||
createApproachMateActionSpec(),
|
||||
createFightRivalActionSpec(),
|
||||
createIntimidateEnemyActionSpec(),
|
||||
{
|
||||
id: "play_seesaw",
|
||||
need: "fulfill",
|
||||
subNeed: "social_play",
|
||||
state: "play_seesaw",
|
||||
label: "\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u307c\u3046\u3068\u3057\u3066\u3044\u308b",
|
||||
phrase: "\u4e00\u7dd2\u306b\u30b7\u30fc\u30bd\u30fc\u3067\u904a\u3093\u3060",
|
||||
weight: 31,
|
||||
timerBacked: true,
|
||||
selectTarget(t, world) { return globalThis.TarinaiSeesawSystem?.findAvailable?.(world, t, 620)?.item || null; },
|
||||
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
|
||||
start(t, world, ctx = {}) { return Boolean(globalThis.TarinaiSeesawSystem?.start?.(t, world, ctx.target ?? this.selectTarget(t, world, ctx))); },
|
||||
tick(t, world) { return globalThis.TarinaiSeesawSystem?.tickAction?.(t, world) ?? false; },
|
||||
finish(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return true; },
|
||||
fail(t) { globalThis.TarinaiSeesawSystem?.release?.(t); return false; },
|
||||
},
|
||||
{
|
||||
id: "play",
|
||||
need: "fulfill",
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@
|
|||
|
||||
(function (global) {
|
||||
const registry = new Map();
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object"));
|
||||
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function hasOwnFn(obj, key) {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 };
|
||||
const fearMul = opts.fearMultiplier ?? profile.fear ?? 1;
|
||||
const threat = opts.threat ?? opts.target ?? null;
|
||||
if (threat && threat !== this && (threat.id || threat.type || threat.kind || threat.name)) this.lastPanicThreat = threat;
|
||||
const safetyShock = Math.max(40, (opts.fearTimer ?? opts.fear ?? 0.55) * 32 * fearMul, Number(opts.stress || 0) * 1.8);
|
||||
const healthShock = Math.max(0, Number(opts.hurtTimer || 0) > 0 ? 45 : 0);
|
||||
if (typeof applyNeedShock === "function") {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@
|
|||
(function (global) {
|
||||
const Tarinai = global.Tarinai;
|
||||
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const isObject = global.TarinaiCoreHelpers?.isPlainObject || (value => Boolean(value && typeof value === "object" && !Array.isArray(value)));
|
||||
const finiteOr = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback) => {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
});
|
||||
|
||||
function nowFor(tarinai = null) {
|
||||
return finiteOr(tarinai?.world?.time, 0);
|
||||
|
|
@ -82,9 +78,7 @@
|
|||
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
|
||||
}
|
||||
|
||||
function isEffectCauseText(text = "") {
|
||||
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
||||
}
|
||||
const isEffectCauseText = global.TarinaiCoreHelpers?.isEffectCauseText || (text => /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, "")));
|
||||
|
||||
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
|
||||
if (!isObject(input)) return null;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function fulfillPrimaryReason(tarinai, action = null) {
|
|||
if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044";
|
||||
if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b";
|
||||
if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044";
|
||||
if (actionId === "play_seesaw") return "\u904a\u3073\u305f\u3044";
|
||||
if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b";
|
||||
if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b";
|
||||
|
||||
|
|
@ -31,9 +32,22 @@ function needPhraseForReason(need, tarinai = null, action = null) {
|
|||
if (need === "social" && action?.subNeed === "family") return "\u5bb6\u65cf\u304c\u6c17\u306b\u306a\u308b";
|
||||
if (need === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b";
|
||||
if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044";
|
||||
if (need === "social" && action?.subNeed === "crowd") return "\u4ed6\u306e\u305f\u308a\u306a\u3044\u304c\u591a\u3059\u304e\u3066\u843d\u3061\u7740\u304b\u306a\u3044";
|
||||
return TARINAI_NEED_PHRASES[need] || need;
|
||||
}
|
||||
|
||||
|
||||
function panicThreatLabel(tarinai) {
|
||||
const threat = tarinai?.lastPanicThreat || tarinai?.lastNeedShockBreaker || null;
|
||||
if (!threat || threat === tarinai || threat.dead) return "";
|
||||
const catalog = globalThis.TEXT_CATALOG || (typeof window !== "undefined" ? window.TEXT_CATALOG : null);
|
||||
let label = String(threat.name || "").trim();
|
||||
if (!label && catalog?.targetLabel) label = String(catalog.targetLabel(threat) || "").trim();
|
||||
if (!label && threat.type && typeof toolLabel === "function") label = String(toolLabel(threat.type) || "").trim();
|
||||
if (!label || label === "\u76f8\u624b" || label === "\u4f4d\u7f6e" || label === "\u4e0d\u660e") return "";
|
||||
return label.replace(/[\u3002\uff01\uff1f]+$/, "");
|
||||
}
|
||||
|
||||
function actionTextForReason(action = null, tarinai = null) {
|
||||
const behavior = currentTarinaiBehavior(tarinai);
|
||||
const id = action?.id || behavior?.actionId || "";
|
||||
|
|
@ -50,9 +64,8 @@ function actionTextForReason(action = null, tarinai = null) {
|
|||
}
|
||||
|
||||
function isEffectCauseText(text = "") {
|
||||
return typeof globalThis.isTarinaiEffectCauseText === "function"
|
||||
? globalThis.isTarinaiEffectCauseText(text)
|
||||
: /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
||||
return Boolean(globalThis.TarinaiCoreHelpers?.isEffectCauseText?.(text)
|
||||
?? globalThis.isTarinaiEffectCauseText?.(text));
|
||||
}
|
||||
|
||||
function forcedCausePhrase(source = "", fallback = "") {
|
||||
|
|
@ -102,7 +115,7 @@ function causePhraseForReason(chosenNeed, action, tarinai = null, options = {})
|
|||
|
||||
function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) {
|
||||
if (action?.id === "sunbath" && (options?.leisure || tarinai?.sunbathLeisure || currentTarinaiBehavior(tarinai)?.data?.leisure)) {
|
||||
return "気分がいいので、日光浴している。";
|
||||
return "\u6c17\u5206\u304c\u3044\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002";
|
||||
}
|
||||
if (action?.id === "seek_comfort_temperature") {
|
||||
const felt = tarinai?.world?.feltTemperatureFor?.(tarinai) ?? world?.temperatureAt?.(tarinai?.x, tarinai?.y, tarinai) ?? tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15);
|
||||
|
|
@ -168,6 +181,11 @@ function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
|
|||
}
|
||||
const b = behavior || (currentTarinaiBehavior(tarinai)) || null;
|
||||
if (!b) return "";
|
||||
const panicActionId = String(b.specId || b.actionId || "");
|
||||
if (panicActionId === "panic_escape" || tarinai?.state === "panic") {
|
||||
const threatLabel = panicThreatLabel(tarinai);
|
||||
if (threatLabel) return `${threatLabel}\u304c\u6016\u304f\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`;
|
||||
}
|
||||
const stored = String(b.text || b.reason || "").trim();
|
||||
if (stored && options.regenerate !== true) return stored;
|
||||
const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color =
|
|||
t.eatTimer = Math.max(t.eatTimer || 0, 1.0);
|
||||
t.eatCooldown = rand(0.96, 1.12);
|
||||
t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16);
|
||||
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal");
|
||||
if (foodType !== "first_aid" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal");
|
||||
t.lastMealColor = color;
|
||||
if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) {
|
||||
const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y };
|
||||
|
|
@ -77,21 +77,25 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
|
|||
const eating = t.consumeFoodServing?.(target);
|
||||
if (!(eating > 0)) return false;
|
||||
const isParam = isParamEffectItemType(foodType);
|
||||
const hpOnlyMedicine = foodType === "first_aid";
|
||||
const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam;
|
||||
const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7";
|
||||
const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", first_aid: "#d24852", sedative: "#5c8eae", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7";
|
||||
finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color);
|
||||
const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8;
|
||||
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief);
|
||||
t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46)));
|
||||
t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
|
||||
if (!hpOnlyMedicine) {
|
||||
const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8;
|
||||
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief);
|
||||
t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46)));
|
||||
t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
|
||||
}
|
||||
if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" });
|
||||
globalThis.TarinaiAchievements?.recordConsumableUse?.({ world, tarinai: t, item: target, type: foodType || target.type, source: "eating" });
|
||||
if (foodType === "sweet") {
|
||||
applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 });
|
||||
if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
}
|
||||
const fromDuplicator = isDuplicatorFoodSource(target);
|
||||
t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating));
|
||||
if (!hpOnlyMedicine) t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating));
|
||||
if (fromDuplicator) {
|
||||
t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2);
|
||||
t.behaviorLockTimer = Math.max(0, Math.min(t.behaviorLockTimer || 0, 0.18));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const d = contact.distance;
|
|||
tarinai.isTarinaiChampion = false;
|
||||
tarinai.tarinaiChampionSince = 0;
|
||||
tarinai.becomeZunchiSlave?.({ locked: true, forcedItem: true });
|
||||
tarinai.world?.syncTarinaiCountEntry?.(tarinai);
|
||||
tarinai.bubble("\u9396", 2.4, "rgba(74,64,56,0.82)");
|
||||
tarinai.world?.log?.(`${tarinai.name}\u306F${itemLabel}\u306B\u89E6\u308C\u3066\u305A\u3093\u3061\u3069\u308C\u3044\u306B\u306A\u3063\u305F\u3002`, "event", { participants: [tarinai], sound: false });
|
||||
} else {
|
||||
|
|
@ -28,6 +29,7 @@ const d = contact.distance;
|
|||
tarinai.bubble("\u738B", 2.4, "rgba(166,112,22,0.84)");
|
||||
tarinai.world?.log?.(`${tarinai.name}\u306F${itemLabel}\u306B\u89E6\u308C\u3066\u305F\u308A\u306A\u3044\u738B\u306B\u306A\u3063\u305F\u3002`, "event", { participants: [tarinai], sound: false });
|
||||
}
|
||||
global.TarinaiAchievements?.recordConsumableUse?.({ world: tarinai.world, tarinai, item: it, type: it.type, source: "contact" });
|
||||
global.TarinaiStructureLifecycle?.deleteItem?.(tarinai.world, it, { reason: `consumed-${it.type}`, userReason: `${itemLabel}\u304C\u4F7F\u308F\u308C\u305F`, wake: false, panicOwner: false });
|
||||
tarinai.world?.recordFamily?.(tarinai);
|
||||
tarinai.world?.markFamilyTreeDirty?.(`consume-${it.type}`);
|
||||
|
|
|
|||
167
js/tarinai_direct_feeding_system.js
Normal file
167
js/tarinai_direct_feeding_system.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use strict";
|
||||
|
||||
// Direct player-to-Tarinai feeding. Placement clicks and pinched-item drops
|
||||
// share this path so item effects, feedback, and consumption stay consistent.
|
||||
(function (global) {
|
||||
const FOOD_TOOL_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice"]);
|
||||
const MEDICINE_TOOL_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
||||
const HEART_FOOD_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "zunda_juice"]);
|
||||
|
||||
function itemType(itemOrType = "") {
|
||||
return String(typeof itemOrType === "object" ? itemOrType?.type || "" : itemOrType || "");
|
||||
}
|
||||
|
||||
function isDirectFeedType(itemOrType = "") {
|
||||
const type = itemType(itemOrType);
|
||||
return FOOD_TOOL_TYPES.has(type) || MEDICINE_TOOL_TYPES.has(type);
|
||||
}
|
||||
|
||||
function isFoodGift(itemOrType = "") {
|
||||
return HEART_FOOD_TYPES.has(itemType(itemOrType));
|
||||
}
|
||||
|
||||
function hasConsumableAmount(item = null) {
|
||||
if (!item || item.dead || !isDirectFeedType(item)) return false;
|
||||
const type = itemType(item);
|
||||
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) {
|
||||
if (Number.isFinite(Number(item.foodServingsRemaining))) return Number(item.foodServingsRemaining) > 0;
|
||||
return Number(item.amount ?? 1) > 0;
|
||||
}
|
||||
return Number(item.amount ?? 1) > 0;
|
||||
}
|
||||
|
||||
function findTargetAt(worldRef, x, y) {
|
||||
if (!worldRef || !Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) return null;
|
||||
const px = Number(x);
|
||||
const py = Number(y);
|
||||
const list = worldRef.tarinai || [];
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const t = list[i];
|
||||
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
const hit = typeof t.contains === "function"
|
||||
? t.contains(px, py)
|
||||
: (typeof global.distXY === "function" && global.distXY(px, py, t.x, t.y) <= Math.max(20, Number(t.radius || 22) * 1.35));
|
||||
if (hit) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function roleFor(item = null) {
|
||||
const type = itemType(item);
|
||||
if (type === "water") return "drink";
|
||||
if (MEDICINE_TOOL_TYPES.has(type) || type === "zunda_juice") return "medicine";
|
||||
return "food";
|
||||
}
|
||||
|
||||
function spawnFoodGiftHearts(tarinai, nutrition = 0) {
|
||||
const worldRef = tarinai?.world;
|
||||
if (!worldRef?.effects || typeof global.TarinaiEffect !== "function") return false;
|
||||
const count = 10 + Math.min(4, Math.max(0, Math.round((Number(nutrition) || 0) / 7)));
|
||||
const radius = Math.max(18, Number(tarinai.radius || 22) || 22);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const side = i % 2 ? 1 : -1;
|
||||
worldRef.effects.push(new global.TarinaiEffect("heart",
|
||||
tarinai.x + side * global.rand(radius * 0.06, radius * 0.82),
|
||||
tarinai.y - global.rand(radius * 0.38, radius * 1.72), {
|
||||
vx: side * global.rand(9, 30) + global.rand(-9, 9),
|
||||
vy: global.rand(-76, -30),
|
||||
size: global.rand(9, 17),
|
||||
life: global.rand(0.82, 1.36),
|
||||
color: "rgba(240,91,135,0.94)",
|
||||
}));
|
||||
}
|
||||
worldRef.effects.push(new global.TarinaiEffect("ring", tarinai.x, tarinai.y, {
|
||||
size: Math.max(24, radius * 1.35), life: 0.28, color: "rgba(240,91,135,0.42)",
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyFoodGiftPersonality(tarinai, nutrition = 0) {
|
||||
if (!tarinai || typeof tarinai.adjustPersonality !== "function") return false;
|
||||
const amount = Math.max(0, Number(nutrition) || 0);
|
||||
// A single gift intentionally outweighs ordinary pointer contact, which
|
||||
// changes openness gradually per second while the cursor remains nearby.
|
||||
const openness = 0.018 + Math.min(0.010, amount * 0.0004);
|
||||
const sociability = 0.010 + Math.min(0.006, amount * 0.00024);
|
||||
const changedOpen = tarinai.adjustPersonality("openness", openness, "\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f") || 0;
|
||||
const changedSocial = tarinai.adjustPersonality("sociability", sociability, "\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f") || 0;
|
||||
return Math.abs(changedOpen) + Math.abs(changedSocial) > 0;
|
||||
}
|
||||
|
||||
function removeGivenWorldItem(worldRef, item, label) {
|
||||
if (!worldRef || !item || !(worldRef.items || []).includes(item)) return false;
|
||||
if (global.TarinaiStructureLifecycle?.deleteItem) {
|
||||
return Boolean(global.TarinaiStructureLifecycle.deleteItem(worldRef, item, {
|
||||
reason: "direct-feed-consumed",
|
||||
userReason: `${label}\u304c\u76f4\u63a5\u4e0e\u3048\u3089\u308c\u305f`,
|
||||
wake: false,
|
||||
panicOwner: false,
|
||||
}));
|
||||
}
|
||||
item.amount = 0;
|
||||
item.dead = true;
|
||||
worldRef.markItemBucketsDirty?.("direct-feed-consumed");
|
||||
worldRef.markSpatialDirty?.("direct-feed-consumed");
|
||||
return true;
|
||||
}
|
||||
|
||||
function give(worldRef, tarinai, item, options = {}) {
|
||||
if (!worldRef || !tarinai || tarinai.dead || !hasConsumableAmount(item)) return null;
|
||||
if (typeof global.consumeBehaviorTarget !== "function") return null;
|
||||
const type = itemType(item);
|
||||
const label = typeof global.toolLabel === "function" ? global.toolLabel(type) : type;
|
||||
const healthNeedBefore = Number(tarinai.needs?.health);
|
||||
const consumed = global.consumeBehaviorTarget(tarinai, worldRef, item, roleFor(item), 0.18);
|
||||
if (!consumed) return null;
|
||||
const nutrition = global.TarinaiItemRegistry?.food?.nutrition?.(type, 0) ?? 0;
|
||||
|
||||
tarinai.target = null;
|
||||
tarinai.targetKey = "";
|
||||
tarinai.sleeping = false;
|
||||
tarinai.goodMode = "smile";
|
||||
tarinai.moodLiftTimer = Math.max(Number(tarinai.moodLiftTimer || 0) || 0, 1.2);
|
||||
tarinai.foodReactTimer = Math.max(Number(tarinai.foodReactTimer || 0) || 0, 0.55);
|
||||
tarinai.surpriseTimer = Math.max(Number(tarinai.surpriseTimer || 0) || 0, 0.28);
|
||||
tarinai.thought = `${label}\u3092\u3082\u3089\u3063\u305f`;
|
||||
|
||||
const foodGift = isFoodGift(type);
|
||||
if (foodGift) {
|
||||
tarinai.affection = Number(tarinai.affection || 0) + 1.8 + Math.max(0, Number(nutrition) || 0) * 0.035;
|
||||
applyFoodGiftPersonality(tarinai, nutrition);
|
||||
spawnFoodGiftHearts(tarinai, nutrition);
|
||||
tarinai.bubble?.("\u2661", 2.2, "rgba(190,82,132,0.80)");
|
||||
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
|
||||
} else {
|
||||
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);
|
||||
const healthNeedAfter = Number(tarinai.needs?.health);
|
||||
const beneficialRecovery = Number.isFinite(healthNeedBefore) && Number.isFinite(healthNeedAfter) && healthNeedAfter < healthNeedBefore - 0.0001;
|
||||
global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, beneficialRecovery, source: options.source || "direct" });
|
||||
}
|
||||
|
||||
const sourceLabel = options.source === "pinch" ? "\u624b\u6e21\u3057\u3067" : "\u76f4\u63a5";
|
||||
worldRef.log?.(`${tarinai.name}\u306b${label}\u3092${sourceLabel}\u4e0e\u3048\u305f\u3002`, "food", {
|
||||
participants: [tarinai], eventType: "direct_feed", sound: false,
|
||||
});
|
||||
|
||||
const removed = options.removeItem !== false ? removeGivenWorldItem(worldRef, item, label) : false;
|
||||
if (!removed && (worldRef.items || []).includes(item)) {
|
||||
item.vx = 0;
|
||||
item.vy = 0;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
}
|
||||
worldRef.hoverGiveTarget = null;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.recordFamily?.(tarinai);
|
||||
if (global.uiCache) global.uiCache.selectedSnapshot = "";
|
||||
return { tarinai, item, type, label, foodGift, removed };
|
||||
}
|
||||
|
||||
global.TarinaiDirectFeedingSystem = Object.freeze({
|
||||
isDirectFeedType,
|
||||
isFoodGift,
|
||||
hasConsumableAmount,
|
||||
findTargetAt,
|
||||
give,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -70,20 +70,21 @@
|
|||
emitZunchiDiseaseEffect(dt) {
|
||||
if (!this.zunchiDisease || !this.world?.effects) return;
|
||||
if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (0.48 + (this.zunchiDiseaseSeverity || 0) * 0.62), this)) {
|
||||
this.world.effects.push(new Effect("zunchi_miasma", this.x + deterministicRange(this.world, "zunchi-miasma-x", -this.radius * 0.45, this.radius * 0.45, this), this.y - this.radius * deterministicRange(this.world, "zunchi-miasma-y", 0.05, 0.72, this), {
|
||||
this.world.spawnEffect("zunchi_miasma", this.x + deterministicRange(this.world, "zunchi-miasma-x", -this.radius * 0.45, this.radius * 0.45, this), this.y - this.radius * deterministicRange(this.world, "zunchi-miasma-y", 0.05, 0.72, this), {
|
||||
vx: deterministicRange(this.world, "zunchi-miasma-vx", -5, 5, this),
|
||||
vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this),
|
||||
life: deterministicRange(this.world, "zunchi-miasma-life", 1.6, 2.8, this),
|
||||
size: deterministicRange(this.world, "zunchi-miasma-size", 12, 26, this) * (1.0 + (this.zunchiDiseaseSeverity || 0) * 0.55),
|
||||
color: "rgba(16,78,28,0.70)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateZunchiDisease(dt) {
|
||||
this.zunchiStain = clamp(this.zunchiStain || 0, 0, 100);
|
||||
this.zunchiDiseaseCooldown = Math.max(0, (this.zunchiDiseaseCooldown || 0) - dt);
|
||||
if (this.world?.weather === "light_rain") {
|
||||
const shelteredFromRain = globalThis.TarinaiRainShelterSystem?.isSheltered?.(this.world, this.x, this.y, this) || false;
|
||||
if (this.world?.weather === "light_rain" && !shelteredFromRain) {
|
||||
this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100);
|
||||
if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006);
|
||||
}
|
||||
|
|
@ -283,7 +284,7 @@
|
|||
if (!isNestContainerItem(box) || this.dead) return false;
|
||||
const alreadyInside = this.insideNestBoxId === box.id;
|
||||
if (!alreadyInside && box?.type === "nest_box" && globalThis.TarinaiItemDynamicToolSystem?.nestBoxFireActive?.(box)) {
|
||||
return this.abortNestBoxEntry?.(box, "巣箱が燃えていて入れない") || false;
|
||||
return this.abortNestBoxEntry?.(box, "\u5de3\u7bb1\u304c\u71c3\u3048\u3066\u3044\u3066\u5165\u308c\u306a\u3044") || false;
|
||||
}
|
||||
const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
|
||||
let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
|
||||
|
|
@ -575,8 +576,11 @@
|
|||
if (this.dead) return false;
|
||||
const already = Boolean(this.burning || (this.burnTimer || 0) > 0.02);
|
||||
if (already && !force) return false;
|
||||
const ignitedDuringBirthRitual = !already && Boolean((this.birthRitualTimer || 0) > 0.04 || this.state === "birth_ritual");
|
||||
const text = globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
|
||||
this.burning = true;
|
||||
const dangerSource = source?.roles?.danger ? source : (source?._achievementDangerSource?.roles?.danger ? source._achievementDangerSource : null);
|
||||
if (dangerSource) this._achievementDangerSource = dangerSource;
|
||||
this.burnTimer = Math.max(Number(this.burnTimer || 0) || 0, 5.2 + deterministicRange(this.world, "tarinai-burn-duration", 0, 2.8, this, source || null));
|
||||
this.firePanicTimer = 0;
|
||||
this.lastPanicCause = "burning";
|
||||
|
|
@ -598,6 +602,7 @@
|
|||
this.bubble?.("\u3042\u3061\u3085\u3044\uff01", 1.35, "rgba(214,82,38,0.90)", { force: true });
|
||||
}
|
||||
this.recordChangeCause?.("\u708e\u4e0a", "\u708e\u4e0a");
|
||||
if (ignitedDuringBirthRitual) globalThis.TarinaiAchievements?.recordIgnition?.({ world: this.world, tarinai: this, source });
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -605,6 +610,7 @@
|
|||
if (!this.burning && !(this.burnTimer > 0)) return false;
|
||||
this.burning = false;
|
||||
this.burnTimer = 0;
|
||||
this._achievementDangerSource = null;
|
||||
this.firePanicTimer = Math.max(this.firePanicTimer || 0, 0.25);
|
||||
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.35);
|
||||
if (reason) this.thought = reason;
|
||||
|
|
@ -629,19 +635,19 @@
|
|||
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, currentNest.x, currentNest.y, Math.max(this.radius || 18, (currentNest.r || 42) * 1.15));
|
||||
if (water) {
|
||||
globalThis.TarinaiItemDynamicToolSystem?.consumeWaterForFire?.(this.world, water, Math.max(10, (this.radius || 18) * 0.55));
|
||||
this.extinguishFire("水で消火された");
|
||||
this.extinguishFire("\u6c34\u3067\u6d88\u706b\u3055\u308c\u305f");
|
||||
return;
|
||||
}
|
||||
const mul = globalThis.TarinaiItemDynamicToolSystem?.fireTempMultiplier?.(this.world, currentNest.x, currentNest.y, this) || 1;
|
||||
if (deterministicChance(this.world, "tarinai-nest-fire-extinguish", dt * 0.014 * mul, this, currentNest)) {
|
||||
this.extinguishFire("自然に火が消えた");
|
||||
this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
|
||||
return;
|
||||
}
|
||||
if (this.burnTimer <= 0.01) this.extinguishFire("自然に火が消えた");
|
||||
if (this.burnTimer <= 0.01) this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
|
||||
return;
|
||||
}
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 6.2, safety: dt * 3.0 });
|
||||
if (typeof this.damage === "function") this.damage(dt * 3.8, "炎上");
|
||||
if (typeof this.damage === "function") this.damage(dt * 3.8, "\u708e\u4e0a", { source: this._achievementDangerSource || null });
|
||||
else this.energy = clamp((this.energy || 0) - dt * 0.12, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100));
|
||||
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.22);
|
||||
const water = globalThis.TarinaiItemDynamicToolSystem?.contactWaterDrop?.(this.world, this.x, this.y, Math.max(this.radius || 18, 16));
|
||||
|
|
@ -668,8 +674,8 @@
|
|||
if (!ant || ant.dead) continue;
|
||||
if (distXY(this.x, this.y, ant.x, ant.y) <= contactRadius + Math.max(ant.r || 4, 4)) globalThis.TarinaiItemDynamicToolSystem?.igniteAntByFire?.(ant, this, this.world);
|
||||
}
|
||||
for (const it of [...(this.world?.nearbyItems?.(this.x, this.y, contactRadius + 32, true) || [])]) {
|
||||
if (!it || it.dead || it.type !== "grass") continue;
|
||||
for (const it of [...(this.world?.nearbyGrass?.(this.x, this.y, contactRadius + 32, true) || [])]) {
|
||||
if (!it || it.dead) continue;
|
||||
if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt);
|
||||
}
|
||||
if (this.burnTimer <= 0.01) this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
(function (global) {
|
||||
function updateCandidate(tarinai, ctx, it, dt = 0) {
|
||||
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
|
||||
if (it.type === "duplicator" && global.TarinaiSignalSystem?.powerOverride?.(it) === false) return false;
|
||||
const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
|
||||
|
||||
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });
|
||||
|
|
@ -58,12 +59,16 @@ if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" ||
|
|||
tarinai.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`);
|
||||
tarinai.vx *= Math.pow(0.25, dt * 60);
|
||||
tarinai.vy *= Math.pow(0.25, dt * 60);
|
||||
const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, 0.82);
|
||||
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief);
|
||||
tarinai.recoverHealth(eating * 0.24);
|
||||
tarinai.affection += eating * 0.04;
|
||||
const hpOnlyMedicine = foodType === "first_aid";
|
||||
if (!hpOnlyMedicine) {
|
||||
const hungerRelief = tarinai.foodHungerReliefFor(foodType || it.type, eating, 0.82);
|
||||
tarinai.applyFoodHungerRelief ? tarinai.applyFoodHungerRelief(hungerRelief) : (tarinai.hunger -= hungerRelief);
|
||||
tarinai.recoverHealth(eating * 0.24);
|
||||
tarinai.affection += eating * 0.04;
|
||||
}
|
||||
const mealColors = {
|
||||
love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff",
|
||||
first_aid: "#d24852", sedative: "#5c8eae",
|
||||
protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b",
|
||||
mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f",
|
||||
};
|
||||
|
|
@ -87,7 +92,7 @@ if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" ||
|
|||
tarinai.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (tarinai.lastMealColor || "#41aa66"))));
|
||||
}
|
||||
const fromDuplicator = isDuplicatorFoodSource(it);
|
||||
tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
|
||||
if (!hpOnlyMedicine) tarinai.makePoop(normalPoopUnitsFor(foodType || it.type, eating));
|
||||
if (fromDuplicator) tarinai.mealCooldownUntil = Math.max(tarinai.mealCooldownUntil || 0, tarinai.world.time + 1.2);
|
||||
if (tarinai.world.time > tarinai.nextEatSound) { audio.eat(); tarinai.nextEatSound = tarinai.world.time + 0.42; }
|
||||
if (tarinai.world.time - tarinai.lastLog > 9 && Math.random() < 0.02) {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
isServingFoodItem(it) {
|
||||
if (it?.type === "duplicator" && it.storedFoodType) return true;
|
||||
if (it?.type === "duplicator" && it.storedFoodType) return global.TarinaiSignalSystem?.powerOverride?.(it) !== false;
|
||||
return Boolean(it && isServingFoodType(it.type));
|
||||
},
|
||||
foodItemHasServingLeft(it) {
|
||||
if (!it || it.dead) return false;
|
||||
if (it.type === "duplicator") return Boolean(it.storedFoodType);
|
||||
if (it.type === "duplicator") return global.TarinaiSignalSystem?.powerOverride?.(it) !== false && Boolean(it.storedFoodType);
|
||||
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
|
||||
this.ensureFoodServings(it);
|
||||
return (it.foodServingsRemaining || 0) > 0;
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
const max = foodServingsForSize(it.toolSize || "medium");
|
||||
if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max;
|
||||
if (!Number.isFinite(it.foodServingsRemaining)) {
|
||||
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
|
||||
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, first_aid: 58, sedative: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
|
||||
const ratio = clamp((it.amount ?? original) / original, 0.05, 1);
|
||||
it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio));
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
},
|
||||
consumeFoodServing(it, nutrition) {
|
||||
if (it?.type === "duplicator") {
|
||||
if (!it.storedFoodType) return 0;
|
||||
if (!it.storedFoodType || global.TarinaiSignalSystem?.powerOverride?.(it) === false) return 0;
|
||||
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
|
||||
it.usedCount = (it.usedCount || 0) + 1;
|
||||
return value;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,54 @@
|
|||
const Tarinai = global.Tarinai;
|
||||
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js");
|
||||
|
||||
function relationIdSetFor(tarinai) {
|
||||
const ids = new Set(Object.keys(tarinai?.relationships || {}));
|
||||
for (const other of tarinai?.world?.tarinai || []) {
|
||||
if (!other || other === tarinai || !other.id) continue;
|
||||
if (other.relationships?.[tarinai.id]) ids.add(other.id);
|
||||
const relationPeerCache = new WeakMap();
|
||||
|
||||
function relationPeerEntry(tarinai) {
|
||||
const relationships = tarinai?.relationships || {};
|
||||
let entry = relationPeerCache.get(tarinai);
|
||||
if (!entry || entry.relationships !== relationships) {
|
||||
const keys = Object.keys(relationships);
|
||||
entry = { relationships, ids: new Set(keys), ownCount: keys.length, incomingBuilt: false };
|
||||
relationPeerCache.set(tarinai, entry);
|
||||
}
|
||||
return ids;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function dropRelationPeerId(tarinai, id) {
|
||||
if (!tarinai || !id) return false;
|
||||
const relationships = tarinai.relationships && typeof tarinai.relationships === "object" ? tarinai.relationships : null;
|
||||
const entry = relationPeerCache.get(tarinai);
|
||||
let removed = false;
|
||||
if (relationships && Object.prototype.hasOwnProperty.call(relationships, id)) {
|
||||
delete relationships[id];
|
||||
removed = true;
|
||||
}
|
||||
if (entry) {
|
||||
entry.ids.delete(id);
|
||||
if (removed) entry.ownCount = Math.max(0, (entry.ownCount || 0) - 1);
|
||||
}
|
||||
if (removed) tarinai.relationCache = null;
|
||||
return removed;
|
||||
}
|
||||
|
||||
function lazyDropDeadRelationPeer(tarinai, id) {
|
||||
const pending = tarinai?.world?._deadTarinaiIdsPendingCleanup;
|
||||
if (!(pending instanceof Set) || pending.size === 0 || !pending.has(id)) return false;
|
||||
if (tarinai.world?.liveTarinaiById?.(id)) return false;
|
||||
dropRelationPeerId(tarinai, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function relationIdSetFor(tarinai) {
|
||||
const entry = relationPeerEntry(tarinai);
|
||||
if (!entry.incomingBuilt) {
|
||||
for (const other of tarinai?.world?.tarinai || []) {
|
||||
if (!other || other.dead || other === tarinai || !other.id) continue;
|
||||
if (other.relationships?.[tarinai.id]) entry.ids.add(other.id);
|
||||
}
|
||||
entry.incomingBuilt = true;
|
||||
}
|
||||
return entry.ids;
|
||||
}
|
||||
|
||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
|
|
@ -169,6 +210,7 @@
|
|||
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
|
||||
this.focusPulseTimer = Math.max(this.focusPulseTimer || 0, 1.0);
|
||||
if (boosted.length) this.recordChangeCause?.("\u305F\u308A\u306A\u3044\u738B\u8005", boosted.join("\u30FB"), { value: +1 });
|
||||
this.world?.syncTarinaiCountEntry?.(this);
|
||||
this.world?.recordFamily?.(this);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -188,11 +230,30 @@
|
|||
|
||||
relationTo(id) {
|
||||
if (!id) return relationDefaults();
|
||||
const pendingDead = this.world?._deadTarinaiIdsPendingCleanup;
|
||||
if (pendingDead instanceof Set && pendingDead.size > 0 && pendingDead.has(id) && !this.world?.liveTarinaiById?.(id)) {
|
||||
dropRelationPeerId(this, id);
|
||||
return relationDefaults();
|
||||
}
|
||||
if (!this.relationships) this.relationships = {};
|
||||
if (!this.relationships[id]) this.relationships[id] = relationDefaults();
|
||||
if (!this.relationships[id]) {
|
||||
this.relationships[id] = relationDefaults();
|
||||
const entry = relationPeerEntry(this);
|
||||
entry.ids.add(id);
|
||||
entry.ownCount = (entry.ownCount || 0) + 1;
|
||||
const other = this.world?.liveTarinaiById?.(id) || null;
|
||||
if (other) relationPeerEntry(other).ids.add(this.id);
|
||||
}
|
||||
return this.relationships[id];
|
||||
},
|
||||
|
||||
pruneDeadRelationshipRefs(deadIds) {
|
||||
if (!(deadIds instanceof Set) || deadIds.size === 0) return 0;
|
||||
let removed = 0;
|
||||
for (const id of deadIds) if (dropRelationPeerId(this, id)) removed += 1;
|
||||
return removed;
|
||||
},
|
||||
|
||||
adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") {
|
||||
if (!other || !other.id || other === this) return;
|
||||
const rel = this.relationTo(other.id);
|
||||
|
|
@ -217,9 +278,12 @@
|
|||
let bestId = null;
|
||||
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
|
||||
for (const id of relationIdSetFor(this)) {
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
|
||||
const other = this.world?.liveTarinaiById?.(id) || null;
|
||||
if (liveOnly && (!other || id === this.id)) {
|
||||
if (!other) lazyDropDeadRelationPeer(this, id);
|
||||
continue;
|
||||
}
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
const reverse = kind === "fear" ? null : (other?.relationships?.[this.id] || null);
|
||||
const score = kind === "fear" ? (rel.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
||||
if (score > bestScore) { bestId = id; bestScore = score; }
|
||||
|
|
@ -239,11 +303,14 @@
|
|||
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
|
||||
let count = 0;
|
||||
for (const id of relationIdSetFor(this)) {
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
const other = this.world?.liveTarinaiById?.(id) || null;
|
||||
if (liveOnly && (!other || id === this.id)) {
|
||||
if (!other) lazyDropDeadRelationPeer(this, id);
|
||||
continue;
|
||||
}
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
const reverse = other?.relationships?.[this.id] || null;
|
||||
if (Math.max(rel.affinity || 0, reverse?.affinity || 0) <= minScore) continue;
|
||||
if (liveOnly && (!other || id === this.id)) continue;
|
||||
count += 1;
|
||||
}
|
||||
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
|
||||
|
|
@ -264,12 +331,15 @@
|
|||
}
|
||||
let best = null, bestScore = minScore;
|
||||
for (const id of relationIdSetFor(this)) {
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
const t = this.world.liveTarinaiById?.(id) || null;
|
||||
if (!t) {
|
||||
lazyDropDeadRelationPeer(this, id);
|
||||
continue;
|
||||
}
|
||||
const rel = this.relationships?.[id] || relationDefaults();
|
||||
const reverse = t?.relationships?.[this.id] || null;
|
||||
const score = Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
||||
if (score <= bestScore) continue;
|
||||
if (!t) continue;
|
||||
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||
best = t;
|
||||
bestScore = score;
|
||||
|
|
@ -286,7 +356,10 @@
|
|||
if (this.world.time - when > maxAge) continue;
|
||||
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
|
||||
const t = this.world.liveTarinaiById?.(id) || null;
|
||||
if (!t) continue;
|
||||
if (!t) {
|
||||
lazyDropDeadRelationPeer(this, id);
|
||||
continue;
|
||||
}
|
||||
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||
if (when > bestT) { best = t; bestT = when; }
|
||||
}
|
||||
|
|
@ -341,7 +414,7 @@
|
|||
|
||||
normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); },
|
||||
|
||||
damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); },
|
||||
damage(amount, reason = "", options = {}) { return HEALTH.applyDamage(this, amount, reason, options); },
|
||||
|
||||
wakeFromDamage(reason = "") {
|
||||
if (this.dead) return false;
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@
|
|||
|
||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
ensureItemEffectState() {
|
||||
if (!this.itemEffectTimers || typeof this.itemEffectTimers !== "object") this.itemEffectTimers = { laxative: 0, ammo: 0 };
|
||||
if (!this.itemEffectTimers || typeof this.itemEffectTimers !== "object") this.itemEffectTimers = { laxative: 0, ammo: 0, sedative: 0 };
|
||||
if (!Number.isFinite(this.itemEffectTimers.laxative)) this.itemEffectTimers.laxative = 0;
|
||||
if (!Number.isFinite(this.itemEffectTimers.ammo)) this.itemEffectTimers.ammo = 0;
|
||||
if (!Number.isFinite(this.itemEffectTimers.sedative)) this.itemEffectTimers.sedative = 0;
|
||||
if (!Number.isFinite(this.laxativePoopTimer)) this.laxativePoopTimer = 3;
|
||||
if (!Number.isFinite(this.ammoBumpCooldown)) this.ammoBumpCooldown = 0;
|
||||
if (this.powerItemMode !== "protein" && this.powerItemMode !== "niteropu") this.powerItemMode = "";
|
||||
|
|
@ -204,7 +205,7 @@
|
|||
},
|
||||
|
||||
diseaseChance(base = 0) {
|
||||
const b = Math.max(0, Number(base) || 0);
|
||||
const b = Math.max(0, Number(base) || 0) / 3;
|
||||
if (this.powerItemMode === "protein") return b * (EFFECT_REGISTRY?.modifier?.("protein", "diseaseChanceMultiplier", 0.5) ?? 0.5);
|
||||
if (this.powerItemMode === "niteropu") return b * (EFFECT_REGISTRY?.modifier?.("niteropu", "diseaseChanceMultiplier", 2.0) ?? 2.0);
|
||||
return b;
|
||||
|
|
@ -223,6 +224,7 @@
|
|||
let value = this.hasItemEffect("ammo") ? (EFFECT_REGISTRY?.modifier?.("ammo", "speedMultiplier", 10) ?? 10) : 1;
|
||||
if (this.sizeItemMode === "giant_drug") value *= EFFECT_REGISTRY?.modifier?.("giant_drug", "speedMultiplier", 1.5) ?? 1.5;
|
||||
if (this.sizeItemMode === "dwarf_drug") value *= EFFECT_REGISTRY?.modifier?.("dwarf_drug", "speedMultiplier", 0.5) ?? 0.5;
|
||||
if (this.hasItemEffect("sedative")) value *= EFFECT_REGISTRY?.modifier?.("sedative", "speedMultiplier", 0.72) ?? 0.72;
|
||||
return value;
|
||||
},
|
||||
|
||||
|
|
@ -278,6 +280,7 @@
|
|||
if (Math.random() < step * 0.72) this.spawnPowerItemEffect(this.powerItemMode, this.powerItemMode === "protein" ? 0.32 : 0.28);
|
||||
}
|
||||
if (this.hasItemEffect("ammo") && Math.random() < step * 3.0) this.spawnPowerItemEffect("ammo", 0.32);
|
||||
if (this.hasItemEffect("sedative") && Math.random() < step * 0.9) this.spawnPowerItemEffect("sedative", 0.22);
|
||||
if ((this.sizeItemMode === "giant_drug" || this.sizeItemMode === "dwarf_drug") && Math.random() < step * 1.4) this.spawnPowerItemEffect(this.sizeItemMode, 0.24);
|
||||
},
|
||||
|
||||
|
|
@ -291,8 +294,8 @@
|
|||
const side = this.facingDir ? this.facingDir() : (this.facing || 1);
|
||||
const x = this.x - side * this.radius * rand(0.72, 0.96);
|
||||
const y = this.y + this.radius * rand(0.25, 0.48);
|
||||
this.world.spawnZunchi?.(x, y);
|
||||
this.poopCount = (this.poopCount || 0) + 1;
|
||||
this.world.spawnZunchi?.(x, y);
|
||||
this.digest = Math.max(0, (this.digest || 0) - 3.0);
|
||||
const now = this.world?.time || 0;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 1.2, effectColor("laxative"), { force: true });
|
||||
|
|
@ -314,13 +317,13 @@
|
|||
const type = kind === "niteropu" ? "zunchi_miasma" : (kind === "ammo" ? "fall" : (kind === "protein" ? "fall" : "ring"));
|
||||
const startY = kind === "protein" ? this.y + this.radius * rand(0.1, 0.55) : (kind === "niteropu" ? this.y - this.radius * rand(0.1, 0.70) : this.y + Math.sin(angle) * r * 0.55);
|
||||
const vy = kind === "protein" ? -rand(38, 82) * intensity : (kind === "niteropu" ? rand(26, 68) * intensity : Math.sin(angle) * rand(8, 26) * intensity - rand(10, 28) * (kind === "giant_drug" || kind === "mercury" ? 1 : 0.35));
|
||||
this.world.effects.push(new Effect(type, this.x + Math.cos(angle) * r, startY, {
|
||||
this.world.spawnEffect(type, this.x + Math.cos(angle) * r, startY, {
|
||||
vx: (kind === "protein" || kind === "niteropu") ? rand(-18, 18) * intensity : Math.cos(angle) * rand(8, 34) * intensity,
|
||||
vy,
|
||||
size: rand(5, 11) * (0.75 + intensity * 0.30),
|
||||
life: rand(0.42, 0.90),
|
||||
color,
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -361,6 +364,7 @@
|
|||
const push = clamp(speed * 1.7, 180, 620);
|
||||
ant.vx = (ant.vx || 0) + px * push;
|
||||
ant.vy = (ant.vy || 0) + py * push;
|
||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(ant, this, { world: this.world, reason: "tarinai_ammo_collision" });
|
||||
ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? 32) - 4);
|
||||
ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.2);
|
||||
this.world.spawnFallEffect?.(ant.x, ant.y, 0.65);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const NORMAL_POOP_MEAL_THRESHOLD = 4;
|
|||
|
||||
function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) {
|
||||
const type = String(foodType || "");
|
||||
if (type === "laxative" || options.laxative === true) return 0;
|
||||
if (type === "laxative" || type === "first_aid" || options.laxative === true) return 0;
|
||||
if (isServingFoodType(type)) return 1;
|
||||
const value = Math.max(0, Number(amount) || 0);
|
||||
return value / 18;
|
||||
|
|
@ -32,7 +32,7 @@ function effectiveFoodRoleMatches(item, role) {
|
|||
function roleMatches(item, role) {
|
||||
if (!item || item.dead) return false;
|
||||
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false;
|
||||
if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role);
|
||||
if (item.type === "duplicator") return globalThis.TarinaiSignalSystem?.powerOverride?.(item) !== false && effectiveFoodRoleMatches(item, role);
|
||||
if (item.roles?.[role]) return true;
|
||||
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
|
||||
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed";
|
||||
|
|
@ -106,7 +106,7 @@ function foodPriorityScore(tarinai, item, distance = 0) {
|
|||
|
||||
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
|
||||
if (!tarinai || !world) return false;
|
||||
const source = world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || [];
|
||||
const source = world.nearbyFood?.(tarinai.x, tarinai.y, maxDist, true) || world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || [];
|
||||
for (const it of source) {
|
||||
if (!it || it.dead) continue;
|
||||
const type = effectiveFoodTypeForItem(it);
|
||||
|
|
@ -171,12 +171,18 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
|
|||
};
|
||||
if (Array.isArray(primary)) for (const it of primary) addCandidate(it);
|
||||
|
||||
// 食料・水・薬は、生成直後/移動直後の spatial cache 漏れにも耐える。
|
||||
// item type buckets が使える場合は role ごとの型だけを見ることで、
|
||||
// crowded world で毎回 world.items 全体を足す経路を避ける。
|
||||
// \u98df\u6599\u30fb\u6c34\u30fb\u85ac\u306f\u3001\u751f\u6210\u76f4\u5f8c/\u79fb\u52d5\u76f4\u5f8c\u306e spatial cache \u6f0f\u308c\u306b\u3082\u8010\u3048\u308b\u3002
|
||||
// item type buckets \u304c\u4f7f\u3048\u308b\u5834\u5408\u306f role \u3054\u3068\u306e\u578b\u3060\u3051\u3092\u898b\u308b\u3053\u3068\u3067\u3001
|
||||
// crowded world \u3067\u6bce\u56de world.items \u5168\u4f53\u3092\u8db3\u3059\u7d4c\u8def\u3092\u907f\u3051\u308b\u3002
|
||||
if (foodLikeRole) {
|
||||
if (world?.itemsOfType) {
|
||||
for (const type of foodRoleSearchTypes(role)) for (const it of world.itemsOfType(type) || []) addCandidate(it);
|
||||
for (const type of foodRoleSearchTypes(role)) {
|
||||
// Grass is static and already represented by nearbyFood. Re-adding the
|
||||
// entire global grass bucket here made every food-target search O(all
|
||||
// grass), which was especially costly around 100 grass plants.
|
||||
if (type === "grass" && world?.nearbyFood) continue;
|
||||
for (const it of world.itemsOfType(type) || []) addCandidate(it);
|
||||
}
|
||||
} else if (Array.isArray(world?.items)) {
|
||||
for (const it of world.items) addCandidate(it);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,60 @@
|
|||
// Layer: entity-runtime/tarinai/update-system
|
||||
// Owns local family/friend/grass/nest comfort environment effects.
|
||||
(function (global) {
|
||||
function parasolFootprint(item = null) {
|
||||
if (!item || item.dead || item.type !== "rain_shelter") return null;
|
||||
const r = Math.max(30, Number(item.r || 50) || 50);
|
||||
// The pole foot is the visual and physical center of the shade.
|
||||
return {
|
||||
centerX: Number(item.x || 0),
|
||||
centerY: Number(item.y || 0) + r * 0.84,
|
||||
radiusX: r * 1.58,
|
||||
radiusY: r * 0.68,
|
||||
};
|
||||
}
|
||||
|
||||
function parasolRange(item = null) {
|
||||
return parasolFootprint(item)?.radiusX || 0;
|
||||
}
|
||||
|
||||
function parasolContains(item = null, x = 0, y = 0) {
|
||||
const footprint = parasolFootprint(item);
|
||||
if (!footprint) return false;
|
||||
const nx = (Number(x || 0) - footprint.centerX) / Math.max(1, footprint.radiusX);
|
||||
const ny = (Number(y || 0) - footprint.centerY) / Math.max(1, footprint.radiusY);
|
||||
return nx * nx + ny * ny <= 1;
|
||||
}
|
||||
|
||||
function parasolAt(worldRef, x, y) {
|
||||
if (!worldRef) return null;
|
||||
const candidates = typeof worldRef.itemsOfType === "function"
|
||||
? worldRef.itemsOfType("rain_shelter")
|
||||
: (worldRef.nearbyItems?.(x, y, 240) || []);
|
||||
let best = null;
|
||||
let bestRatio = Infinity;
|
||||
for (const item of candidates || []) {
|
||||
const footprint = parasolFootprint(item);
|
||||
if (!footprint) continue;
|
||||
const nx = (Number(x || 0) - footprint.centerX) / Math.max(1, footprint.radiusX);
|
||||
const ny = (Number(y || 0) - footprint.centerY) / Math.max(1, footprint.radiusY);
|
||||
const ratio = Math.sqrt(nx * nx + ny * ny);
|
||||
if (ratio <= 1 && ratio < bestRatio) { best = item; bestRatio = ratio; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function enclosedShelterFor(worldRef, target = null) {
|
||||
if (!worldRef || !target?.insideNestBoxId) return null;
|
||||
const holder = worldRef.nestBoxById?.(target.insideNestBoxId)
|
||||
|| (worldRef.items || []).find(item => item && !item.dead && item.id === target.insideNestBoxId)
|
||||
|| null;
|
||||
return holder && (holder.type === "nest_box" || holder.type === "pipe") ? holder : null;
|
||||
}
|
||||
function shelterRange(item = null) { return parasolRange(item); }
|
||||
function shelterAt(worldRef, x, y, target = null) { return parasolAt(worldRef, x, y) || enclosedShelterFor(worldRef, target); }
|
||||
function isSheltered(worldRef, x, y, target = null) { return Boolean(shelterAt(worldRef, x, y, target)); }
|
||||
function blocksWeatherAt(worldRef, x, y, target = null) { return isSheltered(worldRef, x, y, target); }
|
||||
|
||||
function updateBody(dt) {
|
||||
const parent = this.parentToFollow ? this.parentToFollow() : null;
|
||||
if (parent && dist(this, parent) < 96) {
|
||||
|
|
@ -19,18 +73,28 @@
|
|||
this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends.");
|
||||
}
|
||||
let grassComfort = 0;
|
||||
for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
|
||||
for (const it of (this.world.nearbyGrass?.(this.x, this.y, 105) || this.world.nearbyItems(this.x, this.y, 105))) {
|
||||
if (it.type !== "grass" || it.dead) continue;
|
||||
const d = distXY(this.x, this.y, it.x, it.y);
|
||||
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
|
||||
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
|
||||
}
|
||||
let nestComfort = 0;
|
||||
for (const it of this.world.nearbyItems(this.x, this.y, 120)) {
|
||||
for (const it of (this.world.nearbyNonGrassItems?.(this.x, this.y, 120) || this.world.nearbyItems(this.x, this.y, 120))) {
|
||||
if (!it || it.dead || it.type !== "nest_box") continue;
|
||||
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
|
||||
}
|
||||
let rainShelterComfort = 0;
|
||||
const rainShelter = parasolAt(this.world, this.x, this.y);
|
||||
if (rainShelter && (this.world?.weather || "") === "light_rain") {
|
||||
const range = parasolRange(rainShelter);
|
||||
rainShelterComfort = clamp(1 - distXY(this.x, this.y, rainShelter.x, rainShelter.y) / range, 0.25, 1);
|
||||
}
|
||||
if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) });
|
||||
if (rainShelterComfort > 0) {
|
||||
applyNeedRelief(this, { safety: -dt * Math.min(6, rainShelterComfort * 5), health: -dt * Math.min(3, rainShelterComfort * 2), fulfill: -dt * Math.min(2, rainShelterComfort * 1.2) });
|
||||
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.35, rainShelterComfort * 0.08));
|
||||
}
|
||||
if (grassComfort > 0) {
|
||||
applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) });
|
||||
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
|
||||
|
|
@ -45,6 +109,9 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
const parasolSystem = Object.freeze({ parasolFootprint, parasolRange, parasolContains, parasolAt, enclosedShelterFor, shelterRange, shelterAt, isSheltered, blocksWeatherAt });
|
||||
global.TarinaiParasolSystem = parasolSystem;
|
||||
global.TarinaiRainShelterSystem = parasolSystem;
|
||||
global.TarinaiLocalEnvironmentSystem = Object.freeze({
|
||||
updateOne,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"];
|
||||
const TARINAI_NEED_LABELS = {
|
||||
food: "\u6442\u990c",
|
||||
food: "\u98df\u6b32",
|
||||
sleep: "\u7761\u7720",
|
||||
health: "\u5065\u5eb7",
|
||||
safety: "\u5b89\u5168",
|
||||
|
|
@ -49,6 +49,7 @@ const TARINAI_ACTION_LOCK_SECONDS = {
|
|||
seek_comfort_temperature: 5.8,
|
||||
panic_escape: 2.1,
|
||||
hide_at_owned_structure: 6.4,
|
||||
crowd_escape: 2.4,
|
||||
approach_friend: 6.4,
|
||||
approach_parent_or_child: 7.0,
|
||||
approach_mate: 5.6,
|
||||
|
|
@ -77,6 +78,7 @@ const TARINAI_ACTION_PRIORITY = {
|
|||
birth_ritual: 72,
|
||||
approach_mate: 72,
|
||||
approach_parent_or_child: 66,
|
||||
crowd_escape: 6,
|
||||
approach_friend: 66,
|
||||
eat_food: 50,
|
||||
drink_water: 48,
|
||||
|
|
@ -84,6 +86,7 @@ const TARINAI_ACTION_PRIORITY = {
|
|||
build_plushie: 28,
|
||||
use_plushie: 24,
|
||||
return_owned_structure: 22,
|
||||
play_seesaw: 27,
|
||||
play: 20,
|
||||
wander_lightly: 10,
|
||||
};
|
||||
|
|
@ -230,6 +233,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
|
|||
}
|
||||
const panicAction = actions.find(a => a.id === "panic_escape");
|
||||
if (!panicAction) return false;
|
||||
if (breaker && breaker !== tarinai) tarinai.lastPanicThreat = breaker;
|
||||
const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 };
|
||||
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
|
||||
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, panicAction, reasonText)) {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ function updateNeeds(tarinai, world, dt = 0) {
|
|||
}
|
||||
}
|
||||
if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) raw.safety += 34;
|
||||
const socialParts = { bond: 0, family: 0, mate: 0, conflict: 0, fearSocial: 0 };
|
||||
const socialParts = { bond: 0, family: 0, mate: 0, conflict: 0, fearSocial: 0, crowd: 0 };
|
||||
socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16);
|
||||
raw.social = socialParts.bond;
|
||||
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
|
||||
|
|
@ -264,11 +264,12 @@ function actionSelectionBonus(action, need, tarinai, options = {}) {
|
|||
else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.10 + Math.max(0, aggression) * 14;
|
||||
else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.6 + Math.max(0, sociability) * 10;
|
||||
else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 1.15 + Math.max(0, sociability) * 24;
|
||||
else if (action.subNeed === "crowd") bonus += Number(p.crowd || 0) * 0.72;
|
||||
} else if (need === "fulfill") {
|
||||
const p = tarinai?.fulfillReasonParts || {};
|
||||
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
|
||||
else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0);
|
||||
else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2;
|
||||
else if (action.id === "play" || action.id === "play_seesaw" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2;
|
||||
else if (options.includeComfortActions && (action.id === "return_owned_structure" || action.id === "use_plushie")) {
|
||||
bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5;
|
||||
}
|
||||
|
|
@ -302,7 +303,7 @@ function socialFightMateActionWeights(tarinai) {
|
|||
// regression guard baseline: return ({ conflict: 34, mate: 24, family: 12, bond: 14
|
||||
// regression guard baseline: subValue >= threshold && subValue >= socialPull * 0.82
|
||||
function socialSubNeedStartThreshold(subNeed = "") {
|
||||
return ({ conflict: 18, mate: 24, family: 12, bond: 14, fearSocial: 18 })[subNeed] ?? 16;
|
||||
return ({ conflict: 18, mate: 24, family: 12, bond: 14, fearSocial: 18, crowd: 18 })[subNeed] ?? 16;
|
||||
}
|
||||
|
||||
function shouldStartActionBySubNeed(tarinai, action, needs) {
|
||||
|
|
@ -311,11 +312,16 @@ function shouldStartActionBySubNeed(tarinai, action, needs) {
|
|||
const status = tarinai?.temperatureStatus || tarinai?.world?.temperatureStatusFor?.(tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15), tarinai);
|
||||
return Boolean(status && (status.discomfort || 0) >= 2.5);
|
||||
}
|
||||
if (action?.id === "play_seesaw") {
|
||||
return Boolean(globalThis.TarinaiSeesawSystem?.shouldSeek?.(tarinai?.world, tarinai, needs));
|
||||
}
|
||||
if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs);
|
||||
const parts = tarinai?.socialReasonParts || {};
|
||||
const subNeed = action.subNeed;
|
||||
const subValue = Number(parts[subNeed] || 0) || 0;
|
||||
const total = Number(needs?.social || 0) || 0;
|
||||
const crowdValue = Math.max(0, Number(parts.crowd || 0) || 0);
|
||||
const compatibleSocialTotal = subNeed === "crowd" ? crowdValue : Math.max(0, total - crowdValue);
|
||||
const prof = tarinai?.personalityProfile?.() || {};
|
||||
const cur = tarinai?.currentPersonality || {};
|
||||
const modifier = subNeed === "conflict" ? Math.max(0, Number(cur.aggression ?? prof.fight ?? 0) || 0) * 8
|
||||
|
|
@ -324,14 +330,18 @@ function shouldStartActionBySubNeed(tarinai, action, needs) {
|
|||
: 0;
|
||||
const threshold = Math.max(6, socialSubNeedStartThreshold(subNeed) - modifier);
|
||||
const isContinuingSameAction = (getTarinaiBehaviorId(tarinai)) === action.id;
|
||||
if (subNeed === "crowd") {
|
||||
if (subValue >= threshold) return true;
|
||||
return isContinuingSameAction && subValue >= threshold * 0.55;
|
||||
}
|
||||
if (subNeed === "mate" || subNeed === "conflict") {
|
||||
const relationDrive = Math.max(Number(parts.mate || 0) || 0, Number(parts.conflict || 0) || 0, total * 0.36);
|
||||
const relationDrive = Math.max(Number(parts.mate || 0) || 0, Number(parts.conflict || 0) || 0, compatibleSocialTotal * 0.36);
|
||||
const drugBoosted = (tarinai?.loveMochiTimer || 0) > 0.04 || (tarinai?.fightMochiTimer || 0) > 0.04;
|
||||
return total >= needThreshold("social", "start") && (drugBoosted || relationDrive >= Math.min(socialSubNeedStartThreshold("mate"), socialSubNeedStartThreshold("conflict")) * 0.48);
|
||||
return compatibleSocialTotal >= needThreshold("social", "start") && (drugBoosted || relationDrive >= Math.min(socialSubNeedStartThreshold("mate"), socialSubNeedStartThreshold("conflict")) * 0.48);
|
||||
}
|
||||
if (subValue >= threshold) return true;
|
||||
if (isContinuingSameAction && subValue >= threshold * 0.55) return true;
|
||||
return total >= needThreshold("social", "start") && subValue >= threshold * 0.36;
|
||||
return compatibleSocialTotal >= needThreshold("social", "start") && subValue >= threshold * 0.36;
|
||||
}
|
||||
|
||||
function conflictNeedsForChoice(selectedAction, candidates, needs) {
|
||||
|
|
@ -376,6 +386,9 @@ function choosePriorityNeedAction(needs, tarinai, world) {
|
|||
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
|
||||
priority = (status?.harmful || (status?.harmExcess || 0) > 0) ? 92 : 53;
|
||||
}
|
||||
if (action.id === "play_seesaw") {
|
||||
priority = Math.max(priority, Number(globalThis.TarinaiSeesawSystem?.actionPriority?.(world, tarinai)) || 0);
|
||||
}
|
||||
if (action.id === "eat_food") {
|
||||
const hunger = Number(tarinai?.hunger || 0) || 0;
|
||||
if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120);
|
||||
|
|
@ -495,6 +508,7 @@ function continueCurrentBehavior(tarinai, world, needs, dt) {
|
|||
|| (action.id === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic")
|
||||
|| (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04)
|
||||
|| (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04)
|
||||
|| (action.id === "play_seesaw" && Boolean(globalThis.TarinaiSeesawSystem?.validReservation?.(tarinai, world)))
|
||||
|| ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15);
|
||||
const foodSuppressed = need === "food" && !isCurrentBehaviorForced(tarinai) && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15);
|
||||
if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) {
|
||||
|
|
@ -579,11 +593,11 @@ function shouldWakeFromSleep(tarinai, world, needs) {
|
|||
const elapsed = Math.max(0, now - (Number.isFinite(started) ? started : now));
|
||||
const minSleepDuration = Math.max(2, Number(session.minDuration) || 8);
|
||||
|
||||
// 明確な妨害だけは最低睡眠時間より優先して起床させる。
|
||||
// \u660e\u78ba\u306a\u59a8\u5bb3\u3060\u3051\u306f\u6700\u4f4e\u7761\u7720\u6642\u9593\u3088\u308a\u512a\u5148\u3057\u3066\u8d77\u5e8a\u3055\u305b\u308b\u3002
|
||||
if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true;
|
||||
if (findNearbyDanger(world, tarinai, 160)) return true;
|
||||
|
||||
// 空腹・睡眠欲の回復・体力回復などの通常要因では、最低2時間は寝続ける。
|
||||
// \u7a7a\u8179\u30fb\u7761\u7720\u6b32\u306e\u56de\u5fa9\u30fb\u4f53\u529b\u56de\u5fa9\u306a\u3069\u306e\u901a\u5e38\u8981\u56e0\u3067\u306f\u3001\u6700\u4f4e2\u6642\u9593\u306f\u5bdd\u7d9a\u3051\u308b\u3002
|
||||
if (elapsed < minSleepDuration) return false;
|
||||
|
||||
const foodNeed = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
|
||||
|
|
@ -642,6 +656,16 @@ function resolveNeedsCore(dt) {
|
|||
const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds();
|
||||
const needs = updateNeedsCached(this, this.world, dt);
|
||||
synchronizeActionTextFromState(this);
|
||||
if (this.state === "seek_toilet" || this.toiletUrgeTargetId) {
|
||||
this.toiletUrgeTargetId = "";
|
||||
this.toiletUrgeStartedAt = 0;
|
||||
this.toiletUrgeDeadline = 0;
|
||||
if (this.state === "seek_toilet") {
|
||||
this.target = null;
|
||||
this.behaviorLockTimer = 0;
|
||||
this.goIdle?.("\u5f85\u3063\u3066\u3044\u308b");
|
||||
}
|
||||
}
|
||||
if (processForcedBehaviorQueue(this, this.world, needs)) return;
|
||||
if (protectSleepSession(this, this.world, needs)) return;
|
||||
if (continueBuildPlan(this, this.world, dt)) return;
|
||||
|
|
@ -847,6 +871,7 @@ function resolveNeedsCore(dt) {
|
|||
applyZunchiSlavePersonality(this, { birth: !!options.birth });
|
||||
this.visibleSpriteId = this.zunchiSlaveSpriteId();
|
||||
this.spriteLockUntil = 0;
|
||||
this.world?.syncTarinaiCountEntry?.(this);
|
||||
this.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u72b6\u614b");
|
||||
return true;
|
||||
},
|
||||
|
|
@ -1007,6 +1032,8 @@ function resolveNeedsCore(dt) {
|
|||
if (!Number.isFinite(this.world?.w) || !Number.isFinite(this.world?.h)) return false;
|
||||
if ((this.entryTimer || 0) > 0.04) return false;
|
||||
if (!this.isSunbathTime()) return false;
|
||||
const parasolSystem = globalThis.TarinaiParasolSystem || globalThis.TarinaiRainShelterSystem;
|
||||
if (parasolSystem?.blocksWeatherAt?.(this.world, this.x, this.y)) return false;
|
||||
const currentFelt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15);
|
||||
if (Number.isFinite(Number(currentFelt)) && Number(currentFelt) >= 22) return false;
|
||||
if ((this.sunbathCooldown || 0) > 0) return false;
|
||||
|
|
@ -1159,28 +1186,34 @@ function resolveNeedsCore(dt) {
|
|||
return global.TarinaiNeedPlannerSystem.updateOne(this, dt);
|
||||
},
|
||||
|
||||
dropPoopNow(source = "normal") {
|
||||
const threshold = NORMAL_POOP_MEAL_THRESHOLD || 3;
|
||||
if ((Number(this.digest) || 0) + 1e-6 < threshold) return false;
|
||||
this.digest = Math.max(0, (Number(this.digest) || 0) - threshold);
|
||||
this.poopCount = (this.poopCount || 0) + 1;
|
||||
const side = this.facingDir();
|
||||
const backX = this.x - side * this.radius * rand(0.72, 0.96);
|
||||
const backY = this.y + this.radius * rand(0.25, 0.48);
|
||||
this.world.spawnZunchi(backX, backY, this);
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true });
|
||||
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] });
|
||||
return true;
|
||||
},
|
||||
|
||||
makePoop(force = 1) {
|
||||
const add = Math.max(0, Number(force) || 0);
|
||||
if (add <= 0) return false;
|
||||
const threshold = NORMAL_POOP_MEAL_THRESHOLD || 3;
|
||||
this.digest = Math.max(0, Number(this.digest) || 0) + add;
|
||||
if (this.digest + 1e-6 < threshold) return false;
|
||||
this.digest = Math.max(0, this.digest - threshold);
|
||||
if (this.hasLodgedPinEffect?.("blocksZunchi")) {
|
||||
this.digest = Math.max(0, this.digest - threshold);
|
||||
this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1);
|
||||
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
|
||||
if ((this.oshiriByoZunchiStock || 0) >= 6) applyNeedShock(this, { health: 8 });
|
||||
return false;
|
||||
}
|
||||
this.poopCount = (this.poopCount || 0) + 1;
|
||||
const side = this.facingDir();
|
||||
const backX = this.x - side * this.radius * rand(0.72, 0.96);
|
||||
const backY = this.y + this.radius * rand(0.25, 0.48);
|
||||
this.world.spawnZunchi(backX, backY, this);
|
||||
const now = this.world?.time || 0;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true });
|
||||
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] });
|
||||
return true;
|
||||
return this.dropPoopNow?.("normal") || false;
|
||||
},
|
||||
|
||||
interactWithItems(dt) {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@
|
|||
function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) {
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
const list = worldRef?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || worldRef?.items || [];
|
||||
const list = worldRef?.nearbyNonGrassItems?.(tarinai.x, tarinai.y, maxDist) || worldRef?.items || [];
|
||||
for (const item of list) {
|
||||
if (!item || item.dead || item.type !== type) continue;
|
||||
if (!sleepPlaceAvailableFor(worldRef, tarinai, item)) continue;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ function tarinaiSpriteReversesFacing(sprite) {
|
|||
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.02 || (tarinai.pokeFlashTimer || 0) > 0.03) return true;
|
||||
if ((tarinai.focusPulseTimer || 0) > 0.04 || tarinai.state === "cursor_friend" || tarinai.state === "cursor_enemy") return true;
|
||||
const tier = window.TarinaiPerf.renderQualityTier() || "high";
|
||||
if (tier === "low" && worldRef._mechanicalCrowdPressure?.active) return false;
|
||||
if (tier === "low") return false;
|
||||
if (tier === "mid") {
|
||||
const p = worldRef.pointer;
|
||||
|
|
@ -615,11 +616,11 @@ function tarinaiSpriteReversesFacing(sprite) {
|
|||
};
|
||||
if (stressActive) {
|
||||
const pct = clamp((this.stress || 0) / 130, 0, 1);
|
||||
drawMeter(stressY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
|
||||
drawMeter(stressY, pct, "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1));
|
||||
}
|
||||
if (hpActive) {
|
||||
const pct = typeof tarinaiEnergyRatio === "function" ? tarinaiEnergyRatio(this) : clamp(this.energy / Math.max(1, this.maxEnergy || 100), 0, 1);
|
||||
drawMeter(hpY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
|
||||
drawMeter(hpY, pct, "rgba(118,190,80,0.88)", clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue