y
This commit is contained in:
parent
61718e2981
commit
f657b6a4a4
94 changed files with 3970 additions and 1241 deletions
|
|
@ -1,186 +1,268 @@
|
||||||
# 実績システム詳細監査レポート
|
# 実績システム再監査レポート v39.16.78
|
||||||
|
|
||||||
対象: `tarinai - コピー(8).zip`
|
対象: 全72実績、解除イベント、判定順序、進捗リセット、保存・復元、Undo/Redo、サーバー同期
|
||||||
監査対象: 全64実績、解除イベント、進捗リセット、保存・復元、サーバー同期、関連する衝突・死亡処理
|
方針: 前回重点対象だった「自分のずんち」「敵の敵は味方」だけでなく、これまで大きく取り上げていなかった実績を再度コード経路から監査した。明確な不具合は修正し、ゲームデザイン判断が必要な条件は変更していない。
|
||||||
|
|
||||||
## 1. 修正した不具合
|
## 1. 今回新たに確定・修正した不具合
|
||||||
|
|
||||||
### 「あ^~たまらねぇぜ」(`self_zunchi_death`)
|
### 1.1 「ミニマリスト」: プレイヤー設置物情報が保存されなくなる
|
||||||
|
|
||||||
**確定仕様**
|
`ミニマリスト` は「現在存在するプレイヤー設置物が10個以下」を判定するため、各アイテムの `_achievementPlayerPlaced` を必要とする。
|
||||||
|
|
||||||
- おしり鋲を抜いてずんちを排出しただけでは解除しない。
|
旧実装では、この情報を保存する条件が実質的に `無計画都市` の未解除状態へ依存していた。そのため、`無計画都市` を先に解除した後にセーブ・ロードすると、既存アイテムのプレイヤー設置情報が失われ、`ミニマリスト` の物数判定が過少になる可能性があった。
|
||||||
- 排出ずんちが一度本人の当たり判定外まで離れた後、本人へ戻って衝突する必要がある。
|
|
||||||
- 本人に戻って衝突しても、非致死なら解除しない。
|
|
||||||
- 本人への帰還衝突で本人が死亡した場合のみ解除する。
|
|
||||||
- プレイヤーが保持した、または一度停止した排出ずんちは対象外。
|
|
||||||
- バウンス柵などで反射・加速したこと自体は失格条件ではない。
|
|
||||||
|
|
||||||
**今回確認した実原因**
|
**修正:** `無計画都市` と `ミニマリスト` のどちらかが未解除なら、プレイヤー設置情報を保存する。30秒削除用の設置時刻は従来どおり `無計画都市` に必要な場合だけ保存する。
|
||||||
|
|
||||||
おしり鋲から排出されるずんちは本人の中心付近(±8px)に生成されます。一方、旧判定は生成直後から本人との高速衝突を有効としていたため、まだ外へ飛び出す前の重なりで全弾が自己衝突扱いになり得ました。この初動衝突はダメージ、速度の18%化、同一個体への0.55秒ヒットクールダウンを発生させるため、
|
|
||||||
|
|
||||||
`排出 → バウンス柵で反射・加速 → 本人へ帰還して致死衝突`
|
|
||||||
|
|
||||||
という本来の達成経路を阻害していました。
|
|
||||||
|
|
||||||
また、以前確認した別の不具合として、致死衝突時は死亡処理内で生存個体ID `t.id` が解放されるため、ダメージ後に所有者IDを比較すると正しい本人死亡でも一致判定を失う問題がありました。
|
|
||||||
|
|
||||||
**修正**
|
|
||||||
|
|
||||||
- 排出ずんちに `achievementBurstClearedOwner` を追加し、生成時は未成立に設定。
|
|
||||||
- 本人の当たり判定外まで一度離れた時点で帰還判定を有効化。
|
|
||||||
- それ以前の生成直後オーバーラップは、ダメージ・減速・ヒットクールダウンを含めて完全に無視。
|
|
||||||
- 帰還後の致死衝突では、ダメージ適用前に所有者一致を保持して、死亡処理によるID解放後も解除できるよう維持。
|
|
||||||
- バウンス柵による反射・加速では資格を失わない。
|
|
||||||
|
|
||||||
追加回帰テストでは以下を確認済みです。
|
|
||||||
|
|
||||||
1. 生成直後の本人との重なり → ダメージなし、解除なし。
|
|
||||||
2. 本人から一度離れる → 帰還判定が有効化。
|
|
||||||
3. バウンス柵相当の高速帰還で本人へ致死衝突 → 解除する。
|
|
||||||
4. 帰還しても非致死 → 解除しない。
|
|
||||||
5. 他個体への致死衝突 → 解除しない。
|
|
||||||
6. 停止・保持済みの排出ずんち → 解除しない。
|
|
||||||
|
|
||||||
### 「敵の敵は味方」(`enemy_enemy_friend`)
|
|
||||||
|
|
||||||
**原因**
|
|
||||||
|
|
||||||
30体以上の状態で連続数を稼いだあと、個体数が30未満へ落ちても、その間にアリを倒さなければ旧連続数が残っていました。その後30体へ戻すと、過去の連続数から再開できました。
|
|
||||||
|
|
||||||
**修正**
|
|
||||||
|
|
||||||
死亡処理の時点で生存数を再計算し、30体未満になった瞬間に連続数を0へ戻すよう変更しました。フィールド進捗リセット時には、同一Worldオブジェクトに残り得るランタイム状態と危険アイテム戦闘追跡も破棄するようにしました。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 全64実績の実装条件
|
### 1.2 「サウナ水風呂」: 高温開始時刻が更新され続ける
|
||||||
|
|
||||||
### 生態・繁殖系
|
条件は「暑すぎる状態になった個体を15秒以内に寒すぎる状態へ移す」だが、旧実装は高温状態を評価するたびに開始時刻を現在時刻へ更新していた。
|
||||||
|
|
||||||
| # | 実績 | 実装上の解除条件 |
|
このため、長時間高温に置いた後でも、寒冷へ移した直前の評価時刻との差だけで15秒以内と判定され得た。
|
||||||
|---|---|---|
|
|
||||||
| 1 | はじめての繁殖 | 繁殖成功イベントが1回発生。 |
|
|
||||||
| 2 | ずんちどれい | 自然な戦績判定による変身。戦闘5回以上、勝率30%以下などの条件を満たし、戦績経路で奴隷化に成功。単なる生成・手動付与は対象外。 |
|
|
||||||
| 3 | たりない王 | 自然な戦績判定による変身。戦闘6回以上、勝率75%以上などを満たし、戦績経路で王化に成功。 |
|
|
||||||
| 4 | 5世代のずんちどれい | 同一観察世界で、自然発生した奴隷の「世代番号」に5世代連続の並びが成立。直系血統限定ではない。 |
|
|
||||||
| 5 | 3世代のたりない王 | 同一観察世界で、自然発生した王の世代番号に3世代連続の並びが成立。 |
|
|
||||||
| 6 | たりない不在のアリの巣 | 生きたアリの巣が1個以上あり、生存たりないが0体。 |
|
|
||||||
| 7 | ベビーブーム | 同一World内の60ゲーム秒のローリング区間で繁殖30回。内部IDには旧閾値 `50` が残るが、表示と実判定は30で一致。 |
|
|
||||||
| 8 | 偉大なる母 | 累計繁殖成功3333回。内部IDには旧閾値 `1000` が残る。進捗は永続。 |
|
|
||||||
| 9 | 天寿 | 死因が「天寿を全うした」と正規化される死亡。 |
|
|
||||||
| 10 | 25体全員が病気 | 生存25体以上で、全員がずんち病・爆発病・喧嘩病のいずれかを持つ。睡眠病だけの個体は不適格。 |
|
|
||||||
| 11 | 幸せなコロニー | 6日目以降、コロニー気分 `happy` をゲーム内1日分連続維持。 |
|
|
||||||
| 12 | 直接給餌33回 | 現在の観察世界で、成功した直接の食べ物プレゼントを33回。 |
|
|
||||||
| 13 | 25体を5分守る | 生存25体以上を300ゲーム秒維持。天寿以外の死亡でタイマー再開始。天寿でも結果として25体未満になれば条件未達へ戻る。 |
|
|
||||||
| 14 | アリ25匹 | 生きたアリが同時に25匹以上。 |
|
|
||||||
| 15 | 過保護 | 同一個体に直接食料10回以上、かつ直接の薬・治療3回以上。 |
|
|
||||||
| 16 | 自給自足 | 生存20体以上、複製機0個を600ゲーム秒維持。直接給餌するとタイマーをリセット。 |
|
|
||||||
| 17 | 雨宿り | 天候が小雨、生存20体以上、全生存個体が雨宿り判定。 |
|
|
||||||
| 18 | 永遠の歴史 | 10世代以上の誕生、または世界の最大世代が10以上。 |
|
|
||||||
| 19 | 町医者 | 実際にHPを1以上回復した応急手当を累計50回。 |
|
|
||||||
| 20 | 繁殖を求めし者 | ラブ餅影響下の成功繁殖を累計721回。 |
|
|
||||||
|
|
||||||
### 実験・危険行動系
|
**修正:** 快適域外の高温状態へ**入った瞬間**だけ開始時刻を記録し、連続して高温にいる間は更新しない。高温状態を離れてから再び入った場合は新しい試行として時刻を記録する。
|
||||||
|
|
||||||
| # | 実績 | 実装上の解除条件 |
|
|
||||||
|---|---|---|
|
|
||||||
| 21 | あ^~たまらねぇぜ | おしり鋲由来の排出ずんちが一度本人から離れ、停止・保持されないまま本人へ帰還して衝突し、その衝突ダメージで本人が死亡。バウンス柵による反射・加速は有効。今回再修正。 |
|
|
||||||
| 22 | 10秒で50死亡 | 同一World内の10ゲーム秒ローリング区間で死亡50件。 |
|
|
||||||
| 23 | サッカーボール死 | 通常の `ball` が速度285以上で衝突し、その衝突ダメージが致死。風船は対象外。 |
|
|
||||||
| 24 | 戦闘中の危険アイテム連鎖死 | 戦闘ペアの一方が危険カテゴリ由来の新しいダメージで死亡し、その相手も後に危険カテゴリ由来で死亡。現在、同一World内で第1死亡から第2死亡までの明示的な時間制限はない。 |
|
|
||||||
| 25 | ヘコヘコ中に着火 | 繁殖儀式状態の個体へ着火。着火による状態変更前の儀式状態を参照。 |
|
|
||||||
| 26 | 下剤中の餓死 | 下剤効果が有効なフレームで、飢餓・空腹系の死因により死亡。 |
|
|
||||||
| 27 | アリ100匹討伐 | アリ死亡を累計100件。永続進捗。 |
|
|
||||||
| 28 | 絶対零度未満 | 生きたアイテム位置の温度計算値が `-273.15℃` 未満。温度効果は重複加算されるため到達可能。 |
|
|
||||||
| 29 | サウナ→水風呂 | 同一生存個体が快適域外の高温状態に入り、15ゲーム秒以内に快適域外の低温状態へ移行。 |
|
|
||||||
| 30 | 薬品台帳コンプリート | 応急手当、鎮静剤、睡眠薬、下剤、プロテイン、ニテロプ、弾薬、謎の薬、水銀、巨大化薬、小型化薬の11種を各1回使用。鎖と王冠は対象外。 |
|
|
||||||
| 31 | 水銀で天寿 | 水銀寿命モードが有効な個体が天寿で死亡。 |
|
|
||||||
| 32 | 敵の敵は味方 | 生存30体以上の状態で、危険カテゴリのアイテムを直近ダメージ源とするアリ死亡を10連続。危険アイテムでたりないを傷つけると連続数リセット。30体未満になった時点でも今回リセットするよう修正。 |
|
|
||||||
| 33 | 革命 | 王が喧嘩死し、直前0.1ゲーム秒以内の戦闘加害者がずんちどれい。 |
|
|
||||||
| 34 | 火に油 | 喧嘩餅を受けた個体が30ゲーム秒以内に参加する喧嘩が開始。 |
|
|
||||||
| 35 | 王の完全満足 | 生存中の王がプロテイン状態と巨大化薬状態を同時に持つ。 |
|
|
||||||
| 36 | 奴隷の完全満足 | 生存中の奴隷がニテロプ状態と小型化薬状態を同時に持つ。 |
|
|
||||||
| 37 | 混沌を求めし者 | 喧嘩餅効果中の個体を少なくとも一方に含む喧嘩開始を累計666回。 |
|
|
||||||
| 38 | 粘着爆弾15渡し | 同一の生きた粘着爆弾の受け渡し回数が15回。保存対象。 |
|
|
||||||
| 39 | 電線で7体感電 | 同一の通常電線 `wire` が7体の異なる生存たりないを感電させる。絶縁電線は対象外。 |
|
|
||||||
|
|
||||||
### 建築・環境系
|
|
||||||
|
|
||||||
| # | 実績 | 実装上の解除条件 |
|
|
||||||
|---|---|---|
|
|
||||||
| 40 | 100個設置 | プレイヤーによる配置を累計100回。永続進捗。 |
|
|
||||||
| 41 | 機械化産業 | 永続進捗上でロープ・棒・バネを各1回以上配置し、信号駆動先がONになった履歴も成立。現実装は「現在同時に存在」ではなく履歴ANDで、信号源も検知器だけに厳密限定されていない。 |
|
|
||||||
| 42 | 情報化産業 | 電線または絶縁電線が、回路基板Aの出力端子と回路基板Bの入力端子を直接接続し、A出力とB入力がともにON。 |
|
|
||||||
| 43 | ロボット掃除 | ロボット掃除機が有効対象を200回処理。内部IDには旧閾値 `100` が残るが表示・実判定は200。 |
|
|
||||||
| 44 | ミニマリスト | 6日目以降、生存たりない1体以上、気分 `happy`、現在存在するプレイヤー設置物が10個以下。 |
|
|
||||||
| 45 | 無計画都市 | プレイヤー設置物を設置後30ゲーム秒以内に削除する行為を累計30回。 |
|
|
||||||
| 46 | 潔癖ロボット世界 | 生存アイテムがすべてロボット掃除機、生存たりない0、生存アリ0。さらに少なくとも1台が全6対象マスク、高速モード、稼働中。複数台可。 |
|
|
||||||
| 47 | メガロポリス | 生存たりない100体以上、複製機10個以上、草ベッド・巣箱・パイプ合計15個以上。 |
|
|
||||||
| 48 | トイレ5個 | 生きたトイレが5個以上。 |
|
|
||||||
| 49 | 公園の地面変更 | フィールド種別が `park` の状態で、有効な地面変更を1回。 |
|
|
||||||
| 50 | 1秒で地面4変更 | 壁時計の1秒ローリング区間で有効な地面変更4回。 |
|
|
||||||
|
|
||||||
### 操作・プレイ継続系
|
|
||||||
|
|
||||||
| # | 実績 | 実装上の解除条件 |
|
|
||||||
|---|---|---|
|
|
||||||
| 51 | 5分間観察 | 最後の介入操作から300ゲーム秒。配置・削除・直接給餌・つつく・掴む・射撃・リンク等は介入として記録。閲覧だけは介入にならない。 |
|
|
||||||
| 52 | 9スロット保存 | 9個すべてのセーブスロットに保存ハッシュが存在。 |
|
|
||||||
| 53 | 1秒で4空クリック | 空フィールドクリックを壁時計1秒以内に4回。内部IDには旧名称 `pause_spam` が残る。 |
|
|
||||||
| 54 | Undo大量蘇生 | 1回のUndoで死亡個体数が10体以上減る。 |
|
|
||||||
| 55 | 30秒保持 | 同一の生存たりないを30,000ms連続してプレイヤー保持。タブ非表示で保持タイマーをリセット。 |
|
|
||||||
| 56 | Undo 20回 | 成功したUndoを累計20回。永続進捗。 |
|
|
||||||
| 57 | Redo 20回 | 成功したRedoを累計20回。永続進捗。 |
|
|
||||||
| 58 | ぬいぐるみを飛ばす | つつく操作の共通ターゲット処理からぬいぐるみを飛ばす。 |
|
|
||||||
| 59 | スナイパー | 発砲を累計333回。命中不要。 |
|
|
||||||
| 60 | 一桁FPS | 計測FPSが0より大きく10未満。非表示タブ由来の大きなフレーム間隔は計測から除外。 |
|
|
||||||
| 61 | 7日連続起動 | ローカル暦の日付で7日連続してゲームを開く。永続進捗。 |
|
|
||||||
| 62 | 24時間連続プレイ | 実績システム読込時点から壁時計で24時間経過。タブ非表示中も経過時間に含まれる。 |
|
|
||||||
| 63 | 1時間連続プレイ | 同様に壁時計で1時間経過。 |
|
|
||||||
| 64 | 真のたりない観察者 | 自身を除く63実績がすべて解除済み。 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 監査で残した仕様上の注意点
|
### 1.3 「抵抗器じゃない」: 致死感電した7体目がカウントされない
|
||||||
|
|
||||||
以下はコード上の不達成バグではありませんが、表示文から期待される意味と実装の範囲に差があります。条件変更はゲームデザイン判断になるため、今回は勝手に変更していません。
|
旧実装では感電ダメージを与えた後に実績カウントを行っていた。感電が致死になると対象は死亡済みになり、`recordWireShock` の生存チェックで除外されるため、7体目がその感電で死亡すると解除できない場合があった。
|
||||||
|
|
||||||
### 戦闘ペア危険死
|
**修正:** 有効な感電を確認した時点で、ダメージ適用より先に実績カウントを行う。致死・非致死のどちらでも、その感電自体は1体分として扱う。
|
||||||
|
|
||||||
最初の危険死で相手を記録したあと、同一World内では期限がありません。その相手がかなり後になって別の危険アイテムで死亡しても成立し得ます。フィールド進捗リセット時の追跡残留は今回除去しました。
|
|
||||||
|
|
||||||
### 機械化産業
|
|
||||||
|
|
||||||
現在は「ロープ・棒・バネを過去に各1回以上配置した」という永続履歴と、「信号駆動先がONになった履歴」のANDです。現在同じ設備として存在する必要はありません。また信号系は回路基板出力も信号源になり得るため、表示文を「検知器の信号」に厳密限定して読む場合は条件が広めです。
|
|
||||||
|
|
||||||
### 敵の敵は味方の「無傷」
|
|
||||||
|
|
||||||
現在のリセット対象は、危険カテゴリのアイテムによるたりないへのダメージです。通常の非危険ダメージまで含む「一切のダメージなし」ではありません。今回、30体未満への人口低下で連続数が残る明確な不具合のみ修正しました。
|
|
||||||
|
|
||||||
### 互換性のため残っている旧内部ID
|
|
||||||
|
|
||||||
以下は内部ID名と現行閾値が異なりますが、表示文と実際の解除条件は一致しています。ID変更は既存保存データ・サーバー集計との互換性を壊すため変更していません。
|
|
||||||
|
|
||||||
- `birth_50_in_60_seconds` → 現行は30回
|
|
||||||
- `great_mother_1000_births` → 現行は3333回
|
|
||||||
- `robot_cleaner_100` → 現行は200回
|
|
||||||
- `pause_spam_4_in_1_second` → 現行条件は空フィールドクリック
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 検証結果
|
### 1.4 実績リセット後に隠れた途中進捗が残る
|
||||||
|
|
||||||
以下を修正版に対して実行し、すべて成功しました。
|
全実績リセット後も、World・たりない・アイテム本体に直接保持された一部の途中状態が残っていた。そのため、リセット直後に少ない追加操作だけで再解除できる実績があった。
|
||||||
|
|
||||||
- JavaScript全ファイルの `node --check`
|
**修正したリセット対象:**
|
||||||
- Python監査スクリプトのコンパイル確認
|
|
||||||
- `achievement_api.php` のPHP構文確認
|
- 同一個体への直接給餌回数・直接治療回数
|
||||||
- 全64実績の定義・UI・同期監査
|
- サウナ開始時刻・直前高温状態
|
||||||
- プレイスタイル実績の境界値・リセット範囲・永続化監査
|
- たりない保持開始時刻
|
||||||
|
- 喧嘩餅から喧嘩開始までの一時情報
|
||||||
|
- 粘着爆弾の受け渡し回数
|
||||||
|
- `無計画都市` 用の配置時刻
|
||||||
|
- 自然奴隷・自然王の世代進捗
|
||||||
|
|
||||||
|
`ミニマリスト` が現在の配置物を正しく識別するため、既存アイテムの「プレイヤーが配置した物か」という事実自体はリセットで消していない。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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・同期監査
|
||||||
|
- プレイスタイル実績の境界値・リセット範囲・永続化・フック監査
|
||||||
|
- 「あぁ^~ たまらねぇぜ」専用の生成直後除外 / 離脱 / 帰還 / 致死・非致死回帰テスト
|
||||||
|
- サウナ開始時刻、実績リセット、監視介入、ミニマリスト保存情報、致死感電の追加回帰テスト
|
||||||
- 実績イベント統合監査
|
- 実績イベント統合監査
|
||||||
- サーバー状態・旧形式移行・並行同期監査
|
- サーバー状態・旧形式移行・並行同期監査
|
||||||
- 「あ^~たまらねぇぜ」専用回帰テスト6ケース(生成直後除外・離脱後帰還を含む)
|
- 全体回帰監査 `scripts/regression_check.py`
|
||||||
|
- Python監査スクリプトのコンパイル確認
|
||||||
|
- `achievement_api.php` のPHP構文確認
|
||||||
|
|
||||||
最終テスト結果はいずれも `[OK]` です。
|
結果はすべて `[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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
|
||||||
- Version/cache: `app_manifest.json`, `js/version.js`, generated `service-worker.js`, and `index.html` query params share `39.16.75`.
|
- 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.
|
- 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.
|
- 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`.
|
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
|
||||||
|
|
@ -84,7 +84,7 @@ This section records current boundaries only. Version-by-version cleanup history
|
||||||
|
|
||||||
## Current Feature Routing
|
## 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.75`.
|
- 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 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.
|
- 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.
|
- Logic board: `js/circuit_board_system.js`, `js/signal_system.js`, signal-related item definitions/metadata, and the generated circuit editor markup/styles.
|
||||||
|
|
@ -96,7 +96,7 @@ This section records current boundaries only. Version-by-version cleanup history
|
||||||
|
|
||||||
The previous cleanup candidate queue has been applied or adjusted. Current state:
|
The previous cleanup candidate queue has been applied or adjusted. Current state:
|
||||||
|
|
||||||
- Version/docs routing was aligned to `39.16.74`; `js/version.js` build identity now matches the app version, and `README.md` no longer points at an older cache-query script URL.
|
- 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`.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 15-bit text conversion uses arithmetic Unicode range mapping instead of a 32768-character string and reverse Map.
|
||||||
- Binary schema 46 is the only accepted save/spell format; it stores the current achievement mask and sticky-bomb pass count.
|
- 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.75
|
- Version: 39.16.94
|
||||||
- CompressionStream and DecompressionStream are drained concurrently to avoid browser backpressure stalls.
|
- 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.
|
- Load confirmation uses the in-game dialog; fan wind is realtime; zunchi tool preview opacity, carried plushie depth, and blanket grass rules were corrected.
|
||||||
|
|
@ -172,15 +172,91 @@ tarinai_compact_save_stage4
|
||||||
- Save snapshot/schema remains 40/49.
|
- Save snapshot/schema remains 40/49.
|
||||||
|
|
||||||
|
|
||||||
## v39.16.74
|
## v39.16.76
|
||||||
- Removed the circuit-board OFF constant source.
|
- 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.
|
- 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.
|
- Extended the 8-byte achievement mask to use its final bit, supporting all 64 current achievements.
|
||||||
- Save snapshot/schema remains 40/49.
|
- Save snapshot/schema remains 40/49.
|
||||||
|
|
||||||
|
|
||||||
## v39.16.75
|
## v39.16.78
|
||||||
- Fixed 「あぁ^~ たまらねぇぜ」: burst zunchi no longer treat their spawn overlap with the owner as a collision.
|
- 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.
|
- 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.
|
- Preserved the existing nonfatal, stopped, and player-held disqualification behavior.
|
||||||
- Added route-level regression coverage for spawn overlap, owner clearance, accelerated return, fatal/nonfatal outcomes, and fatal live-ID release.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
||||||
"version": "39.16.75",
|
"version": "39.16.98",
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"app": "index.html",
|
"app": "index.html",
|
||||||
"runtime": "js/main.js",
|
"runtime": "js/main.js",
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ Manual save and restore use browser storage. Reset returns to field selection an
|
||||||
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.
|
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.74)
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
@ -86,13 +86,14 @@ Display settings control effects, shadows, drawing detail, and quality choices f
|
||||||
- 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.
|
- 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 雨宿り完了.
|
- 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.
|
- 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.74)
|
### Achievement spell persistence (v39.16.78)
|
||||||
- Exported spells compactly include non-debug achievement unlocks, unlock times, and persistent progress.
|
- 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.
|
- 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.
|
- 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. Only the current schema-49 save/spell format and achievement payload v5 are accepted.
|
- 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.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.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 その他.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
||||||
- 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.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.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.
|
- 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.75`.
|
- 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.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.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.
|
- 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.
|
||||||
|
|
@ -72,7 +72,7 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
|
||||||
- v39.16.39 changes: restored the compact sticky operation palette, added exact-match right-panel search, reorganized colony settings, and refreshed tool icons.
|
- 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.75)
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
@ -108,5 +108,5 @@ Colony birth/death chart lines, reverse rotators, champion-trait inheritance, re
|
||||||
|
|
||||||
- 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.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.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.75: 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.
|
- 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.74: 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,17 @@ const ACHIEVEMENT_IDS = [
|
||||||
'daily_play_7_days',
|
'daily_play_7_days',
|
||||||
'wire_shock_7_tarinai',
|
'wire_shock_7_tarinai',
|
||||||
'information_industry',
|
'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_DIR_NAME = '.achievement_data';
|
||||||
const DATA_FILE_NAME = 'state.json';
|
const DATA_FILE_NAME = 'state.json';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "39.16.75",
|
"version": "39.16.98",
|
||||||
"css": [
|
"css": [
|
||||||
"css/base.css",
|
"css/base.css",
|
||||||
"css/layout.css",
|
"css/layout.css",
|
||||||
|
|
|
||||||
|
|
@ -1209,6 +1209,7 @@
|
||||||
.achievement-toast-copy { min-width: 0; }
|
.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 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-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) {
|
@media (max-width: 980px) {
|
||||||
.achievement-toast {
|
.achievement-toast {
|
||||||
|
|
|
||||||
363
index.html
363
index.html
|
|
@ -4,14 +4,14 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>たりない観察</title>
|
<title>たりない観察</title>
|
||||||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.16.75" />
|
<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.75" />
|
<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.75" />
|
<link rel="shortcut icon" href="favicon.ico?v=39.16.98" />
|
||||||
<link rel="stylesheet" href="css/base.css?v=39.16.75" />
|
<link rel="stylesheet" href="css/base.css?v=39.16.98" />
|
||||||
<link rel="stylesheet" href="css/layout.css?v=39.16.75" />
|
<link rel="stylesheet" href="css/layout.css?v=39.16.98" />
|
||||||
<link rel="stylesheet" href="css/panel.css?v=39.16.75" />
|
<link rel="stylesheet" href="css/panel.css?v=39.16.98" />
|
||||||
<link rel="stylesheet" href="css/components.css?v=39.16.75" />
|
<link rel="stylesheet" href="css/components.css?v=39.16.98" />
|
||||||
<link rel="stylesheet" href="css/mobile.css?v=39.16.75" />
|
<link rel="stylesheet" href="css/mobile.css?v=39.16.98" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||||
<button id="speedBtn" class="btn">速度 x1</button>
|
<button id="speedBtn" class="btn">速度 x1</button>
|
||||||
<button id="saveBtn" class="btn">セーブ</button>
|
<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/64</span></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>
|
<button id="resetBtn" class="btn danger reset-top-btn">リセット</button>
|
||||||
<div id="topMenu" class="top-menu">
|
<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>
|
<button id="topMenuBtn" class="btn top-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="メニュー">☰</button>
|
||||||
|
|
@ -138,11 +138,11 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="colony-setting-section colony-limit-section">
|
<section class="colony-setting-section colony-limit-section">
|
||||||
<div class="colony-setting-section-head"><span>上限設定</span><small>0は上限なし</small></div>
|
<div class="colony-setting-section-head"><span>上限設定</span><small>300の次は∞</small></div>
|
||||||
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
<div class="colony-limit-control" aria-label="コロニー上限設定">
|
||||||
<div class="colony-limit-grid">
|
<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="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="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>
|
<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>
|
||||||
<div id="colonyLimitStatus" class="colony-limit-status">たりないの数 0 / ∞ 物体数 0 / ∞</div>
|
<div id="colonyLimitStatus" class="colony-limit-status">たりないの数 0 / ∞ 物体数 0 / ∞</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
<div class="achievement-dialog-head">
|
<div class="achievement-dialog-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="achievement-dialog-kicker">たりない観察</div>
|
<div class="achievement-dialog-kicker">たりない観察</div>
|
||||||
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 64</span></h2>
|
<h2 id="achievementsDialogTitle">実績 <span id="achievementDialogCount">0 / 75</span></h2>
|
||||||
</div>
|
</div>
|
||||||
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
<button id="achievementsCloseIconBtn" class="achievement-close-icon" type="button" aria-label="実績を閉じる">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -223,6 +223,7 @@
|
||||||
<div class="achievement-toast-copy">
|
<div class="achievement-toast-copy">
|
||||||
<small>実績を解除しました</small>
|
<small>実績を解除しました</small>
|
||||||
<strong id="achievementToastTitle"></strong>
|
<strong id="achievementToastTitle"></strong>
|
||||||
|
<span id="achievementToastCondition" class="achievement-toast-condition"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -292,173 +293,173 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||||
<script src="js/version.js?v=39.16.75" defer></script>
|
<script src="js/version.js?v=39.16.98" defer></script>
|
||||||
<script src="js/event_bus.js?v=39.16.75" defer></script>
|
<script src="js/event_bus.js?v=39.16.98" defer></script>
|
||||||
<script src="js/domain_ids.js?v=39.16.75" defer></script>
|
<script src="js/domain_ids.js?v=39.16.98" defer></script>
|
||||||
<script src="js/registry_base.js?v=39.16.75" defer></script>
|
<script src="js/registry_base.js?v=39.16.98" defer></script>
|
||||||
<script src="js/disease_registry.js?v=39.16.75" defer></script>
|
<script src="js/disease_registry.js?v=39.16.98" defer></script>
|
||||||
<script src="js/sound_pack.js?v=39.16.75" defer></script>
|
<script src="js/sound_pack.js?v=39.16.98" defer></script>
|
||||||
<script src="js/deterministic_helpers.js?v=39.16.75" defer></script>
|
<script src="js/deterministic_helpers.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_tool_metadata.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/item_effect_definitions.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_registry.js?v=39.16.75" defer></script>
|
<script src="js/item_registry.js?v=39.16.98" defer></script>
|
||||||
<script src="js/data.js?v=39.16.75" defer></script>
|
<script src="js/data.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ground_types.js?v=39.16.75" defer></script>
|
<script src="js/ground_types.js?v=39.16.98" defer></script>
|
||||||
<script src="js/input_mode_manager.js?v=39.16.75" defer></script>
|
<script src="js/input_mode_manager.js?v=39.16.98" defer></script>
|
||||||
<script src="js/math.js?v=39.16.75" defer></script>
|
<script src="js/math.js?v=39.16.98" defer></script>
|
||||||
<script src="js/geometry_helpers.js?v=39.16.75" defer></script>
|
<script src="js/geometry_helpers.js?v=39.16.98" defer></script>
|
||||||
<script src="js/collision_footprint_system.js?v=39.16.75" defer></script>
|
<script src="js/collision_footprint_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/physics_helpers.js?v=39.16.75" defer></script>
|
<script src="js/physics_helpers.js?v=39.16.98" defer></script>
|
||||||
<script src="js/placement_preview_system.js?v=39.16.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/mechanical_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/mechanical_shape_bridge.js?v=39.16.75" defer></script>
|
<script src="js/mechanical_shape_bridge.js?v=39.16.98" defer></script>
|
||||||
<script src="js/constraint_system.js?v=39.16.75" defer></script>
|
<script src="js/constraint_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/physics_world_system.js?v=39.16.75" 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.75" 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.75" defer></script>
|
<script src="js/circuit_board_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/signal_system.js?v=39.16.75" defer></script>
|
<script src="js/signal_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/assets.js?v=39.16.75" defer></script>
|
<script src="js/assets.js?v=39.16.98" defer></script>
|
||||||
<script src="js/audio.js?v=39.16.75" defer></script>
|
<script src="js/audio.js?v=39.16.98" defer></script>
|
||||||
<script src="js/perf_profiler.js?v=39.16.75" defer></script>
|
<script src="js/perf_profiler.js?v=39.16.98" defer></script>
|
||||||
<script src="js/display_helpers.js?v=39.16.75" defer></script>
|
<script src="js/display_helpers.js?v=39.16.98" defer></script>
|
||||||
<script src="js/render.js?v=39.16.75" defer></script>
|
<script src="js/render.js?v=39.16.98" defer></script>
|
||||||
<script src="js/sim_core.js?v=39.16.75" defer></script>
|
<script src="js/sim_core.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai_seed_factory.js?v=39.16.75" defer></script>
|
<script src="js/tarinai_seed_factory.js?v=39.16.98" defer></script>
|
||||||
<script src="js/structures.js?v=39.16.75" defer></script>
|
<script src="js/structures.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_type_catalog.js?v=39.16.75" defer></script>
|
<script src="js/item_type_catalog.js?v=39.16.98" defer></script>
|
||||||
<script src="js/items.js?v=39.16.75" defer></script>
|
<script src="js/items.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_type_initializers.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/item_lifecycle_pipeline.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_runtime.js?v=39.16.75" defer></script>
|
<script src="js/item_runtime.js?v=39.16.98" defer></script>
|
||||||
<script src="js/structure_lifecycle.js?v=39.16.75" defer></script>
|
<script src="js/structure_lifecycle.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_render_helpers.js?v=39.16.75" 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.75" 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.75" defer></script>
|
<script src="js/burn_motion_util.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ants.js?v=39.16.75" defer></script>
|
<script src="js/ants.js?v=39.16.98" defer></script>
|
||||||
<script src="js/health.js?v=39.16.75" defer></script>
|
<script src="js/health.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai.js?v=39.16.75" defer></script>
|
<script src="js/tarinai.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai_action_spec.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/tarinai_building_behavior.js?v=39.16.98" defer></script>
|
||||||
<script src="js/seesaw_system.js?v=39.16.75" defer></script>
|
<script src="js/seesaw_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai_action_definitions.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/tarinai_update_pipeline.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai_runtime.js?v=39.16.75" defer></script>
|
<script src="js/tarinai_runtime.js?v=39.16.98" defer></script>
|
||||||
<script src="js/tarinai_render.js?v=39.16.75" defer></script>
|
<script src="js/tarinai_render.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world.js?v=39.16.75" defer></script>
|
<script src="js/world.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_view.js?v=39.16.75" defer></script>
|
<script src="js/world_view.js?v=39.16.98" defer></script>
|
||||||
<script src="js/family_graph.js?v=39.16.75" defer></script>
|
<script src="js/family_graph.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_reset_presets.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/world_combat_effects.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_environment.js?v=39.16.75" defer></script>
|
<script src="js/world_environment.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_temperature_system.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/world_ants_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/weather_system.js?v=39.16.75" defer></script>
|
<script src="js/weather_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/item_update_scheduler.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/simulation_ambient_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/simulation_systems.js?v=39.16.75" defer></script>
|
<script src="js/simulation_systems.js?v=39.16.98" defer></script>
|
||||||
<script src="js/system_order.js?v=39.16.75" defer></script>
|
<script src="js/system_order.js?v=39.16.98" defer></script>
|
||||||
<script src="js/simulation.js?v=39.16.75" defer></script>
|
<script src="js/simulation.js?v=39.16.98" defer></script>
|
||||||
<script src="js/colony_situation_system.js?v=39.16.75" defer></script>
|
<script src="js/colony_situation_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_update.js?v=39.16.75" defer></script>
|
<script src="js/world_update.js?v=39.16.98" defer></script>
|
||||||
<script src="js/world_tool_actions.js?v=39.16.75" 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.75" 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.75" defer></script>
|
<script src="js/world_event_effects.js?v=39.16.98" defer></script>
|
||||||
<script src="js/command_dispatcher.js?v=39.16.75" defer></script>
|
<script src="js/command_dispatcher.js?v=39.16.98" defer></script>
|
||||||
<script src="js/text_catalog.js?v=39.16.75" defer></script>
|
<script src="js/text_catalog.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui.js?v=39.16.75" defer></script>
|
<script src="js/ui.js?v=39.16.98" defer></script>
|
||||||
<script src="js/game_dialogs.js?v=39.16.75" defer></script>
|
<script src="js/game_dialogs.js?v=39.16.98" defer></script>
|
||||||
<script src="js/achievements.js?v=39.16.75" defer></script>
|
<script src="js/achievements.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_helpers.js?v=39.16.75" defer></script>
|
<script src="js/ui_helpers.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_log.js?v=39.16.75" defer></script>
|
<script src="js/ui_log.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_selected.js?v=39.16.75" defer></script>
|
<script src="js/ui_selected.js?v=39.16.98" defer></script>
|
||||||
<script src="js/save_schema.js?v=39.16.75" defer></script>
|
<script src="js/save_schema.js?v=39.16.98" defer></script>
|
||||||
<script src="js/snapshot_system.js?v=39.16.75" defer></script>
|
<script src="js/snapshot_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/restore_coordinator.js?v=39.16.75" defer></script>
|
<script src="js/restore_coordinator.js?v=39.16.98" defer></script>
|
||||||
<script src="js/history_system.js?v=39.16.75" defer></script>
|
<script src="js/history_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/save_codec.js?v=39.16.75" defer></script>
|
<script src="js/save_codec.js?v=39.16.98" defer></script>
|
||||||
<script src="js/save_storage.js?v=39.16.75" defer></script>
|
<script src="js/save_storage.js?v=39.16.98" defer></script>
|
||||||
<script src="js/save_system.js?v=39.16.75" defer></script>
|
<script src="js/save_system.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_tooltips.js?v=39.16.75" defer></script>
|
<script src="js/ui_tooltips.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_layout_dialogs.js?v=39.16.75" defer></script>
|
<script src="js/ui_layout_dialogs.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_ground.js?v=39.16.75" defer></script>
|
<script src="js/ui_ground.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_tools.js?v=39.16.75" defer></script>
|
<script src="js/ui_tools.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_input_shared.js?v=39.16.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/ui_input_mouse.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_bind.js?v=39.16.75" defer></script>
|
<script src="js/ui_bind.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_charts.js?v=39.16.75" defer></script>
|
<script src="js/ui_charts.js?v=39.16.98" defer></script>
|
||||||
<script src="js/ui_family_data.js?v=39.16.75" 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.75" 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.75" 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.75" 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.75" defer></script>
|
<script src="js/ui_family_render.js?v=39.16.98" defer></script>
|
||||||
<script src="js/main.js?v=39.16.75" defer></script>
|
<script src="js/main.js?v=39.16.98" defer></script>
|
||||||
<script src="js/debug_tools.js?v=39.16.75" defer></script>
|
<script src="js/debug_tools.js?v=39.16.98" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
const STORAGE_KEY = "tarinai_achievements_v2";
|
const STORAGE_KEY = "tarinai_achievements_v2";
|
||||||
const PLAYER_KEY = "tarinai_achievement_player_v1";
|
const PLAYER_KEY = "tarinai_achievement_player_v1";
|
||||||
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
|
const API_URL = String(global.TARINAI_ACHIEVEMENT_API || "achievement_api.php");
|
||||||
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.74");
|
const GAME_VERSION = String(global.TARINAI_VERSION || "39.16.77");
|
||||||
const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug"));
|
const DEBUG_MODE = ["1", "true"].includes(new URLSearchParams(global.location?.search || "").get("debug"));
|
||||||
const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const PLAYER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const ABSOLUTE_ZERO_C = -273.15;
|
const ABSOLUTE_ZERO_C = -273.15;
|
||||||
|
|
@ -23,9 +23,17 @@
|
||||||
const ROBOT_ALL_TARGET_MASK = 0x3f;
|
const ROBOT_ALL_TARGET_MASK = 0x3f;
|
||||||
const SYNC_DEBOUNCE_MS = 450;
|
const SYNC_DEBOUNCE_MS = 450;
|
||||||
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
const SPELL_STATE_VERSION = 5;
|
const SPELL_STATE_VERSION = 8;
|
||||||
const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
const MEDICINE_LEDGER_TYPES = Object.freeze(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
||||||
const DIRECT_TREATMENT_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
const DIRECT_TREATMENT_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
|
||||||
|
const OBSERVER_GAME_DAYS = 3;
|
||||||
|
const STRENGTH_IN_NUMBERS_TARGET = 100;
|
||||||
|
const ELITE_FEW_MAX_POPULATION = 25;
|
||||||
|
const ELITE_FEW_TARGET_DAYS = 5;
|
||||||
|
const STATISTICIAN_TARGET = 30;
|
||||||
|
const LIVELY_MAKING_TARGET = 100;
|
||||||
|
const MEMENTO_MORI_TARGET = 10000;
|
||||||
|
const FULL_SEASON_CYCLE_DAYS = 20;
|
||||||
const PRE_UNLOCK_DESCRIPTION_IDS = new Set([
|
const PRE_UNLOCK_DESCRIPTION_IDS = new Set([
|
||||||
"natural_zunchi_slave",
|
"natural_zunchi_slave",
|
||||||
"natural_tarinai_king",
|
"natural_tarinai_king",
|
||||||
|
|
@ -53,20 +61,32 @@
|
||||||
"chaos_seeker_666_fights",
|
"chaos_seeker_666_fights",
|
||||||
"fertility_seeker_721_love_births",
|
"fertility_seeker_721_love_births",
|
||||||
"daily_play_7_days",
|
"daily_play_7_days",
|
||||||
|
"elite_few",
|
||||||
|
"lively_making",
|
||||||
|
"memento_mori",
|
||||||
]);
|
]);
|
||||||
const ACHIEVEMENT_CATEGORIES = Object.freeze([
|
const ACHIEVEMENT_CATEGORIES = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: "ecology",
|
id: "ecology",
|
||||||
title: "\u751f\u614b",
|
title: "\u751f\u614b",
|
||||||
ids: Object.freeze([
|
ids: Object.freeze([
|
||||||
"first_birth", "natural_zunchi_slave", "natural_tarinai_king",
|
"natural_zunchi_slave", "natural_tarinai_king", "lifespan_completed",
|
||||||
"natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
|
|
||||||
"ant_nest_without_tarinai",
|
|
||||||
"birth_50_in_60_seconds", "great_mother_1000_births", "lifespan_completed",
|
|
||||||
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
"all_non_sleep_diseased_25", "colony_happy", "direct_feed_33",
|
||||||
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
|
"safe_colony_25_5_minutes", "ants_alive_25", "overprotective",
|
||||||
"self_sufficient", "rain_shelter_all", "eternal_history_generation_10", "town_doctor_50",
|
"self_sufficient", "rain_shelter_all", "medicine_ledger_all",
|
||||||
"fertility_seeker_721_love_births",
|
"enemy_enemy_friend", "king_full_satisfaction", "slave_zero_satisfaction",
|
||||||
|
"town_doctor_50", "ant_nest_without_tarinai", "zunchi_overflow",
|
||||||
|
"comfortable_beds", "stone_pillow", "favorite_one",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: "population",
|
||||||
|
title: "\u7e41\u6b96\u30fb\u4eba\u53e3",
|
||||||
|
ids: Object.freeze([
|
||||||
|
"first_birth", "natural_zunchi_slave_5_generations", "natural_tarinai_king_3_generations",
|
||||||
|
"birth_50_in_60_seconds", "great_mother_1000_births", "eternal_history_generation_10",
|
||||||
|
"fertility_seeker_721_love_births", "strength_in_numbers", "elite_few",
|
||||||
|
"lively_making", "memento_mori",
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
|
@ -76,8 +96,7 @@
|
||||||
"self_zunchi_death", "death_50_in_10_seconds", "soccer_ball_death",
|
"self_zunchi_death", "death_50_in_10_seconds", "soccer_ball_death",
|
||||||
"fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation",
|
"fight_pair_danger_kill", "ignite_during_birth_ritual", "laxative_starvation",
|
||||||
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
|
"ants_killed_100", "below_absolute_zero_item", "sauna_cold_plunge",
|
||||||
"medicine_ledger_all", "mercury_lifespan", "enemy_enemy_friend",
|
"mercury_lifespan", "revolution", "fuel_to_fire",
|
||||||
"revolution", "fuel_to_fire", "king_full_satisfaction", "slave_zero_satisfaction",
|
|
||||||
"chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai",
|
"chaos_seeker_666_fights", "sticky_bomb_15_passes", "wire_shock_7_tarinai",
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
|
@ -92,12 +111,19 @@
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: "operation",
|
id: "operation",
|
||||||
title: "\u305d\u306e\u4ed6",
|
title: "\u64cd\u4f5c",
|
||||||
ids: Object.freeze([
|
ids: Object.freeze([
|
||||||
"idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second",
|
"idle_observer_5_minutes", "secret_collection_9_slots", "pause_spam_4_in_1_second",
|
||||||
"undo_mass_revival", "held_30_seconds", "undo_20", "redo_20",
|
"undo_mass_revival", "held_30_seconds", "undo_20", "redo_20",
|
||||||
"poke_plushie_fling", "sniper_333_shots", "low_fps_single_digit",
|
"poke_plushie_fling", "sniper_333_shots", "statistician", "well_informed",
|
||||||
"daily_play_7_days", "continuous_play_24_hours", "continuous_play_1_hour", "true_tarinai_observer",
|
]),
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: "other",
|
||||||
|
title: "\u305d\u306e\u4ed6",
|
||||||
|
ids: Object.freeze([
|
||||||
|
"low_fps_single_digit", "daily_play_7_days", "continuous_play_24_hours",
|
||||||
|
"continuous_play_1_hour", "across_seasons", "true_tarinai_observer",
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -119,7 +145,7 @@
|
||||||
Object.freeze({ id: "direct_feed_33", title: "\u990c\u3084\u308a\u3058\u3044\u3055\u3093", description: "\u624b\u304b\u3089\u76f4\u63a533\u56de\u990c\u3092\u4e0e\u3048\u308b\u3002" }),
|
Object.freeze({ id: "direct_feed_33", title: "\u990c\u3084\u308a\u3058\u3044\u3055\u3093", description: "\u624b\u304b\u3089\u76f4\u63a533\u56de\u990c\u3092\u4e0e\u3048\u308b\u3002" }),
|
||||||
Object.freeze({ id: "ignite_during_birth_ritual", title: "\u71c3\u3048\u4e0a\u304c\u308b\u604b", description: "\u305f\u308a\u306a\u3044\u304c\u30d8\u30b3\u30d8\u30b3\u4e2d\u306b\u7740\u706b\u3059\u308b\u3002" }),
|
Object.freeze({ id: "ignite_during_birth_ritual", title: "\u71c3\u3048\u4e0a\u304c\u308b\u604b", description: "\u305f\u308a\u306a\u3044\u304c\u30d8\u30b3\u30d8\u30b3\u4e2d\u306b\u7740\u706b\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "laxative_starvation", title: "\u5185\u81d3\u5168\u90e8\u51fa\u305f", description: "\u4e0b\u5264\u306e\u52b9\u679c\u4e2d\u306b\u9913\u6b7b\u3059\u308b\u3002" }),
|
Object.freeze({ id: "laxative_starvation", title: "\u5185\u81d3\u5168\u90e8\u51fa\u305f", description: "\u4e0b\u5264\u306e\u52b9\u679c\u4e2d\u306b\u9913\u6b7b\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "5\u5206\u9593\u753b\u9762\u3092\u898b\u308b\u3060\u3051" }),
|
Object.freeze({ id: "idle_observer_5_minutes", title: "\u76e3\u8996\u30ab\u30e1\u30e9", description: "3\u30b2\u30fc\u30e0\u65e5\u3001\u64cd\u4f5c\u305b\u305a\u753b\u9762\u3092\u898b\u308b\u3060\u3051" }),
|
||||||
Object.freeze({ id: "placed_objects_100", title: "\u7269\u3067\u3044\u3063\u3071\u3044", description: "\u914d\u7f6e\u7269\u3092\u7d2f\u8a08100\u500b\u7f6e\u304f\u3002" }),
|
Object.freeze({ id: "placed_objects_100", title: "\u7269\u3067\u3044\u3063\u3071\u3044", description: "\u914d\u7f6e\u7269\u3092\u7d2f\u8a08100\u500b\u7f6e\u304f\u3002" }),
|
||||||
Object.freeze({ id: "mechanized_industry", title: "\u6a5f\u68b0\u5316\u7523\u696d", description: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3067\u63a5\u7d9a\u5148\u3092\u4f5c\u52d5\u3055\u305b\u3001\u30ed\u30fc\u30d7\u30fb\u68d2\u30fb\u3070\u306d\u3092\u305d\u308c\u305e\u308c1\u672c\u4ee5\u4e0a\u8a2d\u7f6e\u3059\u308b\u3002" }),
|
Object.freeze({ id: "mechanized_industry", title: "\u6a5f\u68b0\u5316\u7523\u696d", description: "\u691c\u77e5\u5668\u306e\u4fe1\u53f7\u3067\u63a5\u7d9a\u5148\u3092\u4f5c\u52d5\u3055\u305b\u3001\u30ed\u30fc\u30d7\u30fb\u68d2\u30fb\u3070\u306d\u3092\u305d\u308c\u305e\u308c1\u672c\u4ee5\u4e0a\u8a2d\u7f6e\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "safe_colony_25_5_minutes", title: "\u5b89\u5168\u7b2c\u4e00", description: "25\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3092\u30b2\u30fc\u30e0\u51855\u5206\u9593\u7dad\u6301\u3057\u3001\u305d\u306e\u9593\u5bff\u547d\u4ee5\u5916\u3067\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\u3002" }),
|
Object.freeze({ id: "safe_colony_25_5_minutes", title: "\u5b89\u5168\u7b2c\u4e00", description: "25\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3092\u30b2\u30fc\u30e0\u51855\u5206\u9593\u7dad\u6301\u3057\u3001\u305d\u306e\u9593\u5bff\u547d\u4ee5\u5916\u3067\u6b7b\u4ea1\u8005\u3092\u51fa\u3055\u306a\u3044\u3002" }),
|
||||||
|
|
@ -166,6 +192,17 @@
|
||||||
Object.freeze({ id: "daily_play_7_days", title: "\u6bce\u65e5\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "7\u65e5\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }),
|
Object.freeze({ id: "daily_play_7_days", title: "\u6bce\u65e5\u305f\u308a\u306a\u3044\u89b3\u5bdf", description: "7\u65e5\u9023\u7d9a\u3067\u30d7\u30ec\u30a4\u3059\u308b\u3002" }),
|
||||||
Object.freeze({ id: "wire_shock_7_tarinai", title: "\u62b5\u6297\u5668\u3058\u3083\u306a\u3044", description: "\u96fb\u6d41\u304c\u6d41\u308c\u3066\u3044\u308b1\u672c\u306e\u96fb\u7dda\u30677\u4f53\u306e\u305f\u308a\u306a\u3044\u3092\u611f\u96fb\u3055\u305b\u308b\u3002" }),
|
Object.freeze({ id: "wire_shock_7_tarinai", title: "\u62b5\u6297\u5668\u3058\u3083\u306a\u3044", description: "\u96fb\u6d41\u304c\u6d41\u308c\u3066\u3044\u308b1\u672c\u306e\u96fb\u7dda\u30677\u4f53\u306e\u305f\u308a\u306a\u3044\u3092\u611f\u96fb\u3055\u305b\u308b\u3002" }),
|
||||||
Object.freeze({ id: "information_industry", title: "\u60c5\u5831\u5316\u7523\u696d", description: "\u57fa\u76e4\u540c\u58eb\u3092\u96fb\u7dda\u985e\u3067\u3064\u306a\u304e\u3001\u4fe1\u53f7\u3092\u6d41\u3059\u3002" }),
|
Object.freeze({ id: "information_industry", title: "\u60c5\u5831\u5316\u7523\u696d", description: "\u57fa\u76e4\u540c\u58eb\u3092\u96fb\u7dda\u985e\u3067\u3064\u306a\u304e\u3001\u4fe1\u53f7\u3092\u6d41\u3059\u3002" }),
|
||||||
|
Object.freeze({ id: "strength_in_numbers", title: "\u6570\u3053\u305d\u529b", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u305f\u308a\u306a\u3044\u304c100\u4f53\u306b\u5230\u9054\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "elite_few", title: "\u5c11\u6570\u7cbe\u92ed", description: "\u305f\u308a\u306a\u3044\u304c25\u4f53\u4ee5\u4e0b\u306e\u72b6\u614b\u30925\u65e5\u9593\u7dad\u6301\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "zunchi_overflow", title: "\u8e29\u5c4e\u611f", description: "\u305f\u308a\u306a\u3044\u304c20\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3067\u3001\u305a\u3093\u3061\u306e\u6570\u304c\u305f\u308a\u306a\u3044\u306e\u6570\u3092\u4e0a\u56de\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "comfortable_beds", title: "\u5feb\u9069\u306a\u5bdd\u5e8a", description: "\u751f\u5b58\u3057\u3066\u3044\u308b\u5168\u3066\u306e\u305f\u308a\u306a\u3044\u306b\u884c\u304d\u6e21\u308b\u6570\u306e\u5bdd\u5e8a\u3092\u7528\u610f\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "stone_pillow", title: "\u77f3\u306b\u6795\u3059", description: "\u305f\u308a\u306a\u3044\u304c30\u4f53\u4ee5\u4e0a\u3044\u308b\u72b6\u614b\u3067\u3001\u5bdd\u5e8a\u5145\u8db3\u7387\u30920%\u306b\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "across_seasons", title: "\u5b63\u7bc0\u3092\u8d8a\u3048\u3066", description: "\u6625\u590f\u79cb\u51ac\u306e\u56db\u5b63\u3092\u4e00\u5de1\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "favorite_one", title: "\u63a8\u3057", description: "\u3042\u308b\u500b\u4f53\u3092\u304a\u6c17\u306b\u5165\u308a\u306b\u767b\u9332\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "statistician", title: "\u7d71\u8a08\u5b66\u8005", description: "\u7d71\u8a08\u306e\u500b\u4f53\u6570\u30fb\u6b32\u6c42\u30fb\u74b0\u5883\u30fb\u30b0\u30e9\u30d5\u8868\u793a\u5207\u66ff\u30dc\u30bf\u30f3\u3092\u7d2f\u8a0830\u56de\u62bc\u3059\u3002" }),
|
||||||
|
Object.freeze({ id: "well_informed", title: "\u60c5\u5831\u901a", description: "\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "lively_making", title: "\u8cd1\u308f\u3044\u3065\u304f\u308a", description: "\u300c\u305f\u308a\u306a\u3044\u3092\u8ffd\u52a0\u300d\u3067100\u4f53\u8ffd\u52a0\u3059\u308b\u3002" }),
|
||||||
|
Object.freeze({ id: "memento_mori", title: "\u30e1\u30e1\u30f3\u30c8\u30fb\u30e2\u30ea", description: "\u305f\u308a\u306a\u3044\u304c\u5408\u8a0810000\u4f53\u6b7b\u4ea1\u3059\u308b\u3002" }),
|
||||||
]);
|
]);
|
||||||
const definitionById = new Map(DEFINITIONS.map(def => [def.id, def]));
|
const definitionById = new Map(DEFINITIONS.map(def => [def.id, def]));
|
||||||
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
|
const CATEGORY_DEFINITIONS = new Map(ACHIEVEMENT_CATEGORIES.map(category => [
|
||||||
|
|
@ -187,6 +224,7 @@
|
||||||
sharedStatus: document.getElementById("achievementSharedStatus"),
|
sharedStatus: document.getElementById("achievementSharedStatus"),
|
||||||
toast: document.getElementById("achievementToast"),
|
toast: document.getElementById("achievementToast"),
|
||||||
toastTitle: document.getElementById("achievementToastTitle"),
|
toastTitle: document.getElementById("achievementToastTitle"),
|
||||||
|
toastCondition: document.getElementById("achievementToastCondition"),
|
||||||
pauseButton: document.getElementById("pauseBtn"),
|
pauseButton: document.getElementById("pauseBtn"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -205,9 +243,10 @@
|
||||||
let continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
let continuousPlayLastHeartbeatAt = continuousPlayStartedAt;
|
||||||
let lastListSignature = "";
|
let lastListSignature = "";
|
||||||
let scheduledSyncTimer = 0;
|
let scheduledSyncTimer = 0;
|
||||||
|
let scheduledProgressSaveTimer = 0;
|
||||||
|
|
||||||
function blankState() {
|
function blankState() {
|
||||||
return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0 } };
|
return { unlocked: {}, pending: [], resetPending: false, debugUnlocked: [], progress: { placementCount: 0, linkTypes: [], signalActivated: false, antKills: 0, birthCount: 0, quickDeleteCount: 0, undoCount: 0, redoCount: 0, medicineTypes: [], firstAidHeals: 0, fightMochiFightCount: 0, shotCount: 0, loveMochiBirthCount: 0, dailyPlayStreak: 0, dailyPlayLastDay: 0, statsButtonPressCount: 0, totalDeathCount: 0, manualTarinaiAddedCount: 0 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadState() {
|
function loadState() {
|
||||||
|
|
@ -257,6 +296,9 @@
|
||||||
loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 0) || 0)),
|
loveMochiBirthCount: Math.max(0, Math.floor(Number(progressSource.loveMochiBirthCount || 0) || 0)),
|
||||||
dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))),
|
dailyPlayStreak: Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(progressSource.dailyPlayStreak || 0) || 0))),
|
||||||
dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)),
|
dailyPlayLastDay: Math.max(0, Math.floor(Number(progressSource.dailyPlayLastDay || 0) || 0)),
|
||||||
|
statsButtonPressCount: Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(progressSource.statsButtonPressCount || 0) || 0))),
|
||||||
|
totalDeathCount: Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(progressSource.totalDeathCount || 0) || 0))),
|
||||||
|
manualTarinaiAddedCount: Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(progressSource.manualTarinaiAddedCount || 0) || 0))),
|
||||||
};
|
};
|
||||||
const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]);
|
const missingCompletionRequirement = DEFINITIONS.some(definition => definition.id !== COMPLETIONIST_ID && !unlocked[definition.id]);
|
||||||
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
|
if (unlocked[COMPLETIONIST_ID] && missingCompletionRequirement) {
|
||||||
|
|
@ -275,6 +317,14 @@
|
||||||
catch (_) {}
|
catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleProgressSave(delay = 350) {
|
||||||
|
if (scheduledProgressSaveTimer) return;
|
||||||
|
scheduledProgressSaveTimer = global.setTimeout(() => {
|
||||||
|
scheduledProgressSaveTimer = 0;
|
||||||
|
saveState();
|
||||||
|
}, Math.max(0, Number(delay) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
function createPlayerId() {
|
function createPlayerId() {
|
||||||
if (global.crypto?.randomUUID) return global.crypto.randomUUID();
|
if (global.crypto?.randomUUID) return global.crypto.randomUUID();
|
||||||
const hex = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
|
const hex = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
|
||||||
|
|
@ -376,6 +426,29 @@
|
||||||
const count = state.unlocked[id] ? DAILY_PLAY_TARGET : Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress?.dailyPlayStreak || 0) || 0)));
|
const count = state.unlocked[id] ? DAILY_PLAY_TARGET : Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress?.dailyPlayStreak || 0) || 0)));
|
||||||
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / DAILY_PLAY_TARGET * 100).toFixed(1)}%\uff08${count} / ${DAILY_PLAY_TARGET}\u65e5\uff09`;
|
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / DAILY_PLAY_TARGET * 100).toFixed(1)}%\uff08${count} / ${DAILY_PLAY_TARGET}\u65e5\uff09`;
|
||||||
}
|
}
|
||||||
|
if (id === "elite_few") {
|
||||||
|
const world = global.world || null;
|
||||||
|
const dayLength = Math.max(1, Number(world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120);
|
||||||
|
const now = Math.max(0, Number(world?.time || 0) || 0);
|
||||||
|
const startedAt = Number(world?.achievementEliteFewStartAt);
|
||||||
|
const elapsedDays = Number.isFinite(startedAt) && startedAt >= 0 && now >= startedAt
|
||||||
|
? Math.min(ELITE_FEW_TARGET_DAYS, (now - startedAt) / dayLength)
|
||||||
|
: 0;
|
||||||
|
const aliveCount = (world?.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0);
|
||||||
|
return `\u73fe\u5728 ${elapsedDays.toFixed(1)} / ${ELITE_FEW_TARGET_DAYS}\u65e5\uff08\u500b\u4f53\u6570 ${aliveCount} / ${ELITE_FEW_MAX_POPULATION}\u4ee5\u4e0b\uff09`;
|
||||||
|
}
|
||||||
|
if (id === "statistician") {
|
||||||
|
const count = state.unlocked[id] ? STATISTICIAN_TARGET : Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress?.statsButtonPressCount || 0) || 0)));
|
||||||
|
return `\u73fe\u5728 ${count} / ${STATISTICIAN_TARGET}\u56de`;
|
||||||
|
}
|
||||||
|
if (id === "lively_making") {
|
||||||
|
const count = state.unlocked[id] ? LIVELY_MAKING_TARGET : Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress?.manualTarinaiAddedCount || 0) || 0)));
|
||||||
|
return `\u73fe\u5728 ${count} / ${LIVELY_MAKING_TARGET}\u4f53`;
|
||||||
|
}
|
||||||
|
if (id === "memento_mori") {
|
||||||
|
const count = state.unlocked[id] ? MEMENTO_MORI_TARGET : Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress?.totalDeathCount || 0) || 0)));
|
||||||
|
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(count / MEMENTO_MORI_TARGET * 100).toFixed(1)}%\uff08${count} / ${MEMENTO_MORI_TARGET}\u4f53\uff09`;
|
||||||
|
}
|
||||||
if (id === "continuous_play_1_hour") {
|
if (id === "continuous_play_1_hour") {
|
||||||
const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs());
|
const elapsed = Math.min(CONTINUOUS_PLAY_TARGET_MS, continuousPlayElapsedMs());
|
||||||
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`;
|
return `\u73fe\u5728\u306e\u9054\u6210\u7387 ${(elapsed / CONTINUOUS_PLAY_TARGET_MS * 100).toFixed(1)}%\uff08${formatElapsed(elapsed)} / 60:00\uff09`;
|
||||||
|
|
@ -411,6 +484,10 @@
|
||||||
Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Number(state.progress?.loveMochiBirthCount || 0) || 0)),
|
Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Number(state.progress?.loveMochiBirthCount || 0) || 0)),
|
||||||
Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)),
|
Math.min(DAILY_PLAY_TARGET, Math.max(0, Number(state.progress?.dailyPlayStreak || 0) || 0)),
|
||||||
Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0),
|
Math.max(0, Number(state.progress?.dailyPlayLastDay || 0) || 0),
|
||||||
|
Math.min(STATISTICIAN_TARGET, Math.max(0, Number(state.progress?.statsButtonPressCount || 0) || 0)),
|
||||||
|
Math.min(MEMENTO_MORI_TARGET, Math.max(0, Number(state.progress?.totalDeathCount || 0) || 0)),
|
||||||
|
Math.min(LIVELY_MAKING_TARGET, Math.max(0, Number(state.progress?.manualTarinaiAddedCount || 0) || 0)),
|
||||||
|
Math.floor(Math.max(0, (Number(global.world?.time || 0) || 0) - Math.max(0, Number(global.world?.achievementEliteFewStartAt || 0) || 0)) / Math.max(1, Number(global.world?.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120) * 10),
|
||||||
].join(":");
|
].join(":");
|
||||||
return `${unlocks}|${rates}|${local}`;
|
return `${unlocks}|${rates}|${local}`;
|
||||||
}
|
}
|
||||||
|
|
@ -580,7 +657,7 @@
|
||||||
if (toastActive || !toastQueue.length) return;
|
if (toastActive || !toastQueue.length) return;
|
||||||
const definition = toastQueue.shift();
|
const definition = toastQueue.shift();
|
||||||
if (!dom.toast || !dom.toastTitle) {
|
if (!dom.toast || !dom.toastTitle) {
|
||||||
global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title}`);
|
global.showToast?.(`\u5b9f\u7e3e\u89e3\u9664: ${definition.title} \u2014 ${definition.description || ""}`);
|
||||||
global.setTimeout(runNextUnlockToast, 80);
|
global.setTimeout(runNextUnlockToast, 80);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -590,6 +667,7 @@
|
||||||
resetUnlockToastVisual();
|
resetUnlockToastVisual();
|
||||||
dom.button?.classList.remove("achievement-absorb-target");
|
dom.button?.classList.remove("achievement-absorb-target");
|
||||||
dom.toastTitle.textContent = definition.title;
|
dom.toastTitle.textContent = definition.title;
|
||||||
|
if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";
|
||||||
dom.toast.classList.remove("hidden");
|
dom.toast.classList.remove("hidden");
|
||||||
void dom.toast.offsetWidth;
|
void dom.toast.offsetWidth;
|
||||||
showUnlockToast.absorbTimer = global.setTimeout(absorbUnlockToast, 2700);
|
showUnlockToast.absorbTimer = global.setTimeout(absorbUnlockToast, 2700);
|
||||||
|
|
@ -796,10 +874,17 @@
|
||||||
|
|
||||||
function recordNaturalGeneration(world, tarinai, property, required) {
|
function recordNaturalGeneration(world, tarinai, property, required) {
|
||||||
if (!world || !tarinai) return 0;
|
if (!world || !tarinai) return 0;
|
||||||
const generation = Math.max(1, Math.floor(Number(tarinai.generation || 1) || 1));
|
const depthKey = property === "achievementNaturalSlaveGenerations"
|
||||||
const current = Array.isArray(world[property]) ? world[property] : [];
|
? "_achievementNaturalSlaveLineageDepth"
|
||||||
world[property] = [...new Set([...current, generation])].sort((a, b) => a - b);
|
: "_achievementNaturalKingLineageDepth";
|
||||||
return generationRunLength(world[property]) >= required ? generation : 0;
|
const parentIds = Array.isArray(tarinai.parents) ? tarinai.parents.filter(Boolean) : [];
|
||||||
|
let parentDepth = 0;
|
||||||
|
for (const parentId of parentIds) {
|
||||||
|
const parent = (world.tarinai || []).find(candidate => candidate && String(candidate.id || "") === String(parentId));
|
||||||
|
parentDepth = Math.max(parentDepth, Math.max(0, Math.floor(Number(parent?.[depthKey] || 0) || 0)));
|
||||||
|
}
|
||||||
|
tarinai[depthKey] = Math.max(1, parentDepth + 1);
|
||||||
|
return tarinai[depthKey] >= required ? tarinai[depthKey] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailWorld(detail = {}) {
|
function detailWorld(detail = {}) {
|
||||||
|
|
@ -831,6 +916,9 @@
|
||||||
state.progress.loveMochiBirthCount = Math.max(0, Math.floor(Number(state.progress.loveMochiBirthCount || 0) || 0));
|
state.progress.loveMochiBirthCount = Math.max(0, Math.floor(Number(state.progress.loveMochiBirthCount || 0) || 0));
|
||||||
state.progress.dailyPlayStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress.dailyPlayStreak || 0) || 0)));
|
state.progress.dailyPlayStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(state.progress.dailyPlayStreak || 0) || 0)));
|
||||||
state.progress.dailyPlayLastDay = Math.max(0, Math.floor(Number(state.progress.dailyPlayLastDay || 0) || 0));
|
state.progress.dailyPlayLastDay = Math.max(0, Math.floor(Number(state.progress.dailyPlayLastDay || 0) || 0));
|
||||||
|
state.progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(state.progress.statsButtonPressCount || 0) || 0)));
|
||||||
|
state.progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(state.progress.totalDeathCount || 0) || 0)));
|
||||||
|
state.progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(state.progress.manualTarinaiAddedCount || 0) || 0)));
|
||||||
return state.progress;
|
return state.progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -838,14 +926,15 @@
|
||||||
return Math.max(0, Math.min(255, Number(value) || 0)).toString(16).padStart(2, "0");
|
return Math.max(0, Math.min(255, Number(value) || 0)).toString(16).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0) {
|
function unlockedIndexesFromMaskWords(lowWord = 0, highWord = 0, extraWord = 0) {
|
||||||
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(lowWord) || 0)));
|
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(lowWord) || 0)));
|
||||||
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
|
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(highWord) || 0)));
|
||||||
const high = Math.max(0, Math.min(highMax, Math.floor(Number(highWord) || 0)));
|
const extraMax = (2 ** Math.max(0, DEFINITIONS.length - 64)) - 1;
|
||||||
|
const extra = Math.max(0, Math.min(extraMax, Math.floor(Number(extraWord) || 0)));
|
||||||
const indexes = [];
|
const indexes = [];
|
||||||
for (let index = 0; index < DEFINITIONS.length; index += 1) {
|
for (let index = 0; index < DEFINITIONS.length; index += 1) {
|
||||||
const word = index < 32 ? low : high;
|
const word = index < 32 ? low : (index < 64 ? high : extra);
|
||||||
const bit = index < 32 ? index : index - 32;
|
const bit = index < 32 ? index : (index < 64 ? index - 32 : index - 64);
|
||||||
if (Math.floor(word / (2 ** bit)) % 2) indexes.push(index);
|
if (Math.floor(word / (2 ** bit)) % 2) indexes.push(index);
|
||||||
}
|
}
|
||||||
return indexes;
|
return indexes;
|
||||||
|
|
@ -854,13 +943,15 @@
|
||||||
function exportSpellState() {
|
function exportSpellState() {
|
||||||
let lowWord = 0;
|
let lowWord = 0;
|
||||||
let highWord = 0;
|
let highWord = 0;
|
||||||
|
let extraWord = 0;
|
||||||
const timestamps = [];
|
const timestamps = [];
|
||||||
for (let index = 0; index < DEFINITIONS.length; index += 1) {
|
for (let index = 0; index < DEFINITIONS.length; index += 1) {
|
||||||
const id = DEFINITIONS[index].id;
|
const id = DEFINITIONS[index].id;
|
||||||
const timestamp = Number(state.unlocked[id] || 0) || 0;
|
const timestamp = Number(state.unlocked[id] || 0) || 0;
|
||||||
if (timestamp <= 0 || state.debugUnlocked?.includes?.(id)) continue;
|
if (timestamp <= 0 || state.debugUnlocked?.includes?.(id)) continue;
|
||||||
if (index < 32) lowWord += 2 ** index;
|
if (index < 32) lowWord += 2 ** index;
|
||||||
else highWord += 2 ** (index - 32);
|
else if (index < 64) highWord += 2 ** (index - 32);
|
||||||
|
else extraWord += 2 ** (index - 64);
|
||||||
timestamps.push(Math.max(1, Math.floor(timestamp / 60000)));
|
timestamps.push(Math.max(1, Math.floor(timestamp / 60000)));
|
||||||
}
|
}
|
||||||
const baseTimestamp = timestamps.length ? Math.min(...timestamps) : 0;
|
const baseTimestamp = timestamps.length ? Math.min(...timestamps) : 0;
|
||||||
|
|
@ -878,6 +969,7 @@
|
||||||
SPELL_STATE_VERSION,
|
SPELL_STATE_VERSION,
|
||||||
lowWord >>> 0,
|
lowWord >>> 0,
|
||||||
highWord >>> 0,
|
highWord >>> 0,
|
||||||
|
extraWord >>> 0,
|
||||||
baseTimestamp,
|
baseTimestamp,
|
||||||
deltas,
|
deltas,
|
||||||
[
|
[
|
||||||
|
|
@ -895,33 +987,39 @@
|
||||||
state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount),
|
state.unlocked.fertility_seeker_721_love_births ? 0 : Math.min(FERTILITY_BIRTH_TARGET, progress.loveMochiBirthCount),
|
||||||
state.unlocked.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak),
|
state.unlocked.daily_play_7_days ? 0 : Math.min(DAILY_PLAY_TARGET, progress.dailyPlayStreak),
|
||||||
state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay),
|
state.unlocked.daily_play_7_days ? 0 : Math.min(131071, progress.dailyPlayLastDay),
|
||||||
|
state.unlocked.statistician ? 0 : Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount),
|
||||||
|
state.unlocked.memento_mori ? 0 : Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount),
|
||||||
|
state.unlocked.lively_making ? 0 : Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSpellState(payload = null) {
|
function parseSpellState(payload = null) {
|
||||||
if (!Array.isArray(payload) || Number(payload[0]) !== SPELL_STATE_VERSION) return null;
|
if (!Array.isArray(payload)) return null;
|
||||||
|
const version = Number(payload[0]);
|
||||||
|
if (version !== SPELL_STATE_VERSION) return null;
|
||||||
const lowWord = Number(payload[1]);
|
const lowWord = Number(payload[1]);
|
||||||
const highWord = Number(payload[2]);
|
const highWord = Number(payload[2]);
|
||||||
const highMax = (2 ** Math.max(0, DEFINITIONS.length - 32)) - 1;
|
const extraWord = Number(payload[3]);
|
||||||
|
const extraMax = (2 ** Math.max(0, DEFINITIONS.length - 64)) - 1;
|
||||||
if (!Number.isSafeInteger(lowWord) || lowWord < 0 || lowWord > 0xffffffff ||
|
if (!Number.isSafeInteger(lowWord) || lowWord < 0 || lowWord > 0xffffffff ||
|
||||||
!Number.isSafeInteger(highWord) || highWord < 0 || highWord > highMax) {
|
!Number.isSafeInteger(highWord) || highWord < 0 || highWord > 0xffffffff ||
|
||||||
|
!Number.isSafeInteger(extraWord) || extraWord < 0 || extraWord > extraMax) {
|
||||||
throw new Error("invalid achievement spell mask");
|
throw new Error("invalid achievement spell mask");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
unlockedIndexes: unlockedIndexesFromMaskWords(lowWord, highWord),
|
unlockedIndexes: unlockedIndexesFromMaskWords(lowWord, highWord, extraWord),
|
||||||
baseTimestamp: Math.max(0, Math.floor(Number(payload[3] || 0) || 0)),
|
baseTimestamp: Math.max(0, Math.floor(Number(payload[4] || 0) || 0)),
|
||||||
deltas: Array.isArray(payload[4]) ? payload[4] : [],
|
deltas: Array.isArray(payload[5]) ? payload[5] : [],
|
||||||
packed: Array.isArray(payload[5]) ? payload[5] : [],
|
packed: Array.isArray(payload[6]) ? payload[6] : [],
|
||||||
timestampUnitMs: 60000,
|
timestampUnitMs: 60000,
|
||||||
progressVersion: SPELL_STATE_VERSION,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function importSpellState(payload = null) {
|
function importSpellState(payload = null) {
|
||||||
const parsed = parseSpellState(payload);
|
const parsed = parseSpellState(payload);
|
||||||
if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false };
|
if (!parsed) return { included: false, unlockedAdded: 0, progressChanged: false };
|
||||||
const { unlockedIndexes, baseTimestamp, deltas, packed, timestampUnitMs, progressVersion } = parsed;
|
const { unlockedIndexes, baseTimestamp, deltas, packed, timestampUnitMs } = parsed;
|
||||||
if (deltas.length !== unlockedIndexes.length) throw new Error("invalid achievement spell timestamps");
|
if (deltas.length !== unlockedIndexes.length) throw new Error("invalid achievement spell timestamps");
|
||||||
const importedUnlocks = [];
|
const importedUnlocks = [];
|
||||||
let importedCompletionistAt = 0;
|
let importedCompletionistAt = 0;
|
||||||
|
|
@ -971,19 +1069,20 @@
|
||||||
const importedMedicines = MEDICINE_LEDGER_TYPES.filter((_type, index) => medicineMask & (1 << index));
|
const importedMedicines = MEDICINE_LEDGER_TYPES.filter((_type, index) => medicineMask & (1 << index));
|
||||||
progress.medicineTypes = [...new Set([...(progress.medicineTypes || []), ...importedMedicines])];
|
progress.medicineTypes = [...new Set([...(progress.medicineTypes || []), ...importedMedicines])];
|
||||||
progress.firstAidHeals = Math.max(progress.firstAidHeals, Math.min(50, Math.max(0, Math.floor(Number(packed[8] || 0) || 0))));
|
progress.firstAidHeals = Math.max(progress.firstAidHeals, Math.min(50, Math.max(0, Math.floor(Number(packed[8] || 0) || 0))));
|
||||||
if (progressVersion === SPELL_STATE_VERSION) {
|
progress.fightMochiFightCount = Math.max(progress.fightMochiFightCount, Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Math.floor(Number(packed[9] || 0) || 0))));
|
||||||
progress.fightMochiFightCount = Math.max(progress.fightMochiFightCount, Math.min(CHAOS_FIGHT_TARGET, Math.max(0, Math.floor(Number(packed[9] || 0) || 0))));
|
progress.shotCount = Math.max(progress.shotCount, Math.min(SNIPER_SHOT_TARGET, Math.max(0, Math.floor(Number(packed[10] || 0) || 0))));
|
||||||
progress.shotCount = Math.max(progress.shotCount, Math.min(SNIPER_SHOT_TARGET, Math.max(0, Math.floor(Number(packed[10] || 0) || 0))));
|
progress.loveMochiBirthCount = Math.max(progress.loveMochiBirthCount, Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Math.floor(Number(packed[11] || 0) || 0))));
|
||||||
progress.loveMochiBirthCount = Math.max(progress.loveMochiBirthCount, Math.min(FERTILITY_BIRTH_TARGET, Math.max(0, Math.floor(Number(packed[11] || 0) || 0))));
|
const importedDailyStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(packed[12] || 0) || 0)));
|
||||||
const importedDailyStreak = Math.min(DAILY_PLAY_TARGET, Math.max(0, Math.floor(Number(packed[12] || 0) || 0)));
|
const importedDailyLastDay = Math.min(131071, Math.max(0, Math.floor(Number(packed[13] || 0) || 0)));
|
||||||
const importedDailyLastDay = Math.min(131071, Math.max(0, Math.floor(Number(packed[13] || 0) || 0)));
|
if (importedDailyLastDay > progress.dailyPlayLastDay) {
|
||||||
if (importedDailyLastDay > progress.dailyPlayLastDay) {
|
progress.dailyPlayLastDay = importedDailyLastDay;
|
||||||
progress.dailyPlayLastDay = importedDailyLastDay;
|
progress.dailyPlayStreak = importedDailyStreak;
|
||||||
progress.dailyPlayStreak = importedDailyStreak;
|
} else if (importedDailyLastDay === progress.dailyPlayLastDay) {
|
||||||
} else if (importedDailyLastDay === progress.dailyPlayLastDay) {
|
progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak);
|
||||||
progress.dailyPlayStreak = Math.max(progress.dailyPlayStreak, importedDailyStreak);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
progress.statsButtonPressCount = Math.max(progress.statsButtonPressCount, Math.min(STATISTICIAN_TARGET, Math.max(0, Math.floor(Number(packed[14] || 0) || 0))));
|
||||||
|
progress.totalDeathCount = Math.max(progress.totalDeathCount, Math.min(MEMENTO_MORI_TARGET, Math.max(0, Math.floor(Number(packed[15] || 0) || 0))));
|
||||||
|
progress.manualTarinaiAddedCount = Math.max(progress.manualTarinaiAddedCount, Math.min(LIVELY_MAKING_TARGET, Math.max(0, Math.floor(Number(packed[16] || 0) || 0))));
|
||||||
const progressChanged = previousProgress !== JSON.stringify(progress);
|
const progressChanged = previousProgress !== JSON.stringify(progress);
|
||||||
changed = changed || progressChanged;
|
changed = changed || progressChanged;
|
||||||
|
|
||||||
|
|
@ -1006,6 +1105,7 @@
|
||||||
|
|
||||||
function recordPlayerPlacement(detail = {}) {
|
function recordPlayerPlacement(detail = {}) {
|
||||||
const world = detailWorld(detail);
|
const world = detailWorld(detail);
|
||||||
|
if (world) recordIntervention({ ...detail, world, tool: detail.tool || "placement" });
|
||||||
if (!world) return false;
|
if (!world) return false;
|
||||||
recordIntervention({ ...detail, world });
|
recordIntervention({ ...detail, world });
|
||||||
const item = detail.item || null;
|
const item = detail.item || null;
|
||||||
|
|
@ -1039,6 +1139,9 @@
|
||||||
if (!Number.isFinite(placedAt) || now < placedAt || now - placedAt > 30.0001) return changed;
|
if (!Number.isFinite(placedAt) || now < placedAt || now - placedAt > 30.0001) return changed;
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
if (!state.unlocked.unplanned_city_30) {
|
if (!state.unlocked.unplanned_city_30) {
|
||||||
|
const operationKey = detail.operationId == null ? "" : String(detail.operationId);
|
||||||
|
if (operationKey && world._achievementLastQuickDeleteOperation === operationKey) return changed;
|
||||||
|
if (operationKey) world._achievementLastQuickDeleteOperation = operationKey;
|
||||||
progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1);
|
progress.quickDeleteCount = Math.min(30, progress.quickDeleteCount + 1);
|
||||||
saveState();
|
saveState();
|
||||||
renderIfDialogOpen();
|
renderIfDialogOpen();
|
||||||
|
|
@ -1050,6 +1153,8 @@
|
||||||
function recordHistoryAction(kind, detail = {}) {
|
function recordHistoryAction(kind, detail = {}) {
|
||||||
const action = String(kind || detail.kind || "");
|
const action = String(kind || detail.kind || "");
|
||||||
if (action !== "undo" && action !== "redo") return false;
|
if (action !== "undo" && action !== "redo") return false;
|
||||||
|
const world = detailWorld(detail);
|
||||||
|
if (world) recordIntervention({ ...detail, world, tool: action });
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
const key = action === "undo" ? "undoCount" : "redoCount";
|
const key = action === "undo" ? "undoCount" : "redoCount";
|
||||||
const achievementId = action === "undo" ? "undo_20" : "redo_20";
|
const achievementId = action === "undo" ? "undo_20" : "redo_20";
|
||||||
|
|
@ -1149,7 +1254,8 @@
|
||||||
function recordAntKilled(detail = {}) {
|
function recordAntKilled(detail = {}) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
if (!state.unlocked.ants_killed_100) {
|
const playerCaused = Boolean(detail.playerCaused || detail.ant?._achievementLastDamageSource?.danger);
|
||||||
|
if (!state.unlocked.ants_killed_100 && playerCaused) {
|
||||||
progress.antKills = Math.min(100, progress.antKills + 1);
|
progress.antKills = Math.min(100, progress.antKills + 1);
|
||||||
saveState();
|
saveState();
|
||||||
renderIfDialogOpen();
|
renderIfDialogOpen();
|
||||||
|
|
@ -1277,6 +1383,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordEmptyClick(detail = {}) {
|
function recordEmptyClick(detail = {}) {
|
||||||
|
const world = detailWorld(detail);
|
||||||
|
if (world) recordIntervention({ ...detail, world, tool: "empty_click" });
|
||||||
if (state.unlocked.pause_spam_4_in_1_second) return false;
|
if (state.unlocked.pause_spam_4_in_1_second) return false;
|
||||||
const now = Number(detail.now);
|
const now = Number(detail.now);
|
||||||
const timestamp = Number.isFinite(now) ? now : Date.now();
|
const timestamp = Number.isFinite(now) ? now : Date.now();
|
||||||
|
|
@ -1294,6 +1402,7 @@
|
||||||
const previous = String(detail.previous || "");
|
const previous = String(detail.previous || "");
|
||||||
const next = String(detail.next || world?.groundType || "");
|
const next = String(detail.next || world?.groundType || "");
|
||||||
if (!world || detail.silent || !previous || !next || previous === next) return false;
|
if (!world || detail.silent || !previous || !next || previous === next) return false;
|
||||||
|
recordIntervention({ ...detail, world, tool: "ground_change" });
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (!state.unlocked.park_ground_changed && String(world.fieldType || "") === "park") {
|
if (!state.unlocked.park_ground_changed && String(world.fieldType || "") === "park") {
|
||||||
changed = unlock("park_ground_changed", { ...detail, world, previous, next }) || changed;
|
changed = unlock("park_ground_changed", { ...detail, world, previous, next }) || changed;
|
||||||
|
|
@ -1478,11 +1587,12 @@
|
||||||
|
|
||||||
function recordDirectCare(detail = {}) {
|
function recordDirectCare(detail = {}) {
|
||||||
const world = detailWorld(detail);
|
const world = detailWorld(detail);
|
||||||
|
if (world) recordIntervention({ ...detail, world, tool: "direct_care" });
|
||||||
const tarinai = detail.tarinai || null;
|
const tarinai = detail.tarinai || null;
|
||||||
const type = String(detail.type || detail.item?.type || "");
|
const type = String(detail.type || detail.item?.type || "");
|
||||||
if (!world || !tarinai) return false;
|
if (!world || !tarinai) return false;
|
||||||
const foodGift = Boolean(detail.foodGift);
|
const foodGift = Boolean(detail.foodGift);
|
||||||
const treatment = Boolean(detail.treatment || DIRECT_TREATMENT_TYPES.has(type));
|
const treatment = Boolean(detail.beneficialRecovery);
|
||||||
if (foodGift) {
|
if (foodGift) {
|
||||||
tarinai._achievementDirectFeedCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectFeedCount || 0) || 0)) + 1;
|
tarinai._achievementDirectFeedCount = Math.max(0, Math.floor(Number(tarinai._achievementDirectFeedCount || 0) || 0)) + 1;
|
||||||
world.achievementSelfSufficientStartAt = -1;
|
world.achievementSelfSufficientStartAt = -1;
|
||||||
|
|
@ -1552,15 +1662,12 @@
|
||||||
|
|
||||||
function evaluateMegalopolis(world = global.world, detail = {}) {
|
function evaluateMegalopolis(world = global.world, detail = {}) {
|
||||||
if (!world || state.unlocked.megalopolis) return false;
|
if (!world || state.unlocked.megalopolis) return false;
|
||||||
const aliveCount = (world.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0);
|
const aliveCount = playstylePopulationCount(world);
|
||||||
if (aliveCount < 100) return false;
|
if (aliveCount < 100) return false;
|
||||||
let duplicatorCount = 0;
|
const duplicatorCount = playstyleItemCount(world, "duplicator");
|
||||||
let shelterCount = 0;
|
const shelterCount = playstyleItemCount(world, "grass_bed")
|
||||||
for (const item of world.items || []) {
|
+ playstyleItemCount(world, "nest_box")
|
||||||
if (!item || item.dead) continue;
|
+ playstyleItemCount(world, "pipe");
|
||||||
if (item.type === "duplicator") duplicatorCount += 1;
|
|
||||||
else if (item.type === "grass_bed" || item.type === "nest_box" || item.type === "pipe") shelterCount += 1;
|
|
||||||
}
|
|
||||||
if (duplicatorCount < 10 || shelterCount < 15) return false;
|
if (duplicatorCount < 10 || shelterCount < 15) return false;
|
||||||
return unlock("megalopolis", { ...detail, world, aliveCount, duplicatorCount, shelterCount });
|
return unlock("megalopolis", { ...detail, world, aliveCount, duplicatorCount, shelterCount });
|
||||||
}
|
}
|
||||||
|
|
@ -1578,11 +1685,22 @@
|
||||||
world.achievementNoDeathStartAt = -1;
|
world.achievementNoDeathStartAt = -1;
|
||||||
world.achievementHappyStartAt = -1;
|
world.achievementHappyStartAt = -1;
|
||||||
world.achievementFightMochiSerial = 0;
|
world.achievementFightMochiSerial = 0;
|
||||||
|
world.achievementEliteFewStartAt = -1;
|
||||||
for (const tarinai of world.tarinai || []) {
|
for (const tarinai of world.tarinai || []) {
|
||||||
if (!tarinai) continue;
|
if (!tarinai) continue;
|
||||||
tarinai._achievementFightMochiAt = null;
|
tarinai._achievementFightMochiAt = null;
|
||||||
tarinai._achievementFightMochiSerial = 0;
|
tarinai._achievementFightMochiSerial = 0;
|
||||||
tarinai._achievementFightMochiWorld = null;
|
tarinai._achievementFightMochiWorld = null;
|
||||||
|
tarinai._achievementDirectFeedCount = 0;
|
||||||
|
tarinai._achievementDirectTreatmentCount = 0;
|
||||||
|
tarinai._achievementSaunaHotAt = null;
|
||||||
|
tarinai._achievementSaunaWasHot = false;
|
||||||
|
tarinai._achievementHeldStartedAt = null;
|
||||||
|
}
|
||||||
|
for (const item of world.items || []) {
|
||||||
|
if (!item) continue;
|
||||||
|
if (item.type === "sticky_bomb") item.stickyBombPassCount = 0;
|
||||||
|
item._achievementPlacedAt = null;
|
||||||
}
|
}
|
||||||
world.achievementNaturalSlaveGenerations = [];
|
world.achievementNaturalSlaveGenerations = [];
|
||||||
world.achievementNaturalKingGenerations = [];
|
world.achievementNaturalKingGenerations = [];
|
||||||
|
|
@ -1619,6 +1737,8 @@
|
||||||
const world = detail.world || child?.world || null;
|
const world = detail.world || child?.world || null;
|
||||||
const generation = Math.max(1, Math.floor(Number(child?.generation || world?.maxGeneration || 1) || 1));
|
const generation = Math.max(1, Math.floor(Number(child?.generation || world?.maxGeneration || 1) || 1));
|
||||||
if (generation >= GENERATION_TARGET) unlock("eternal_history_generation_10", { ...detail, world, child, generation });
|
if (generation >= GENERATION_TARGET) unlock("eternal_history_generation_10", { ...detail, world, child, generation });
|
||||||
|
if (child?.isZunchiSlave) recordNaturalStatus("slave", { ...detail, world, tarinai: child, bornWithStatus: true });
|
||||||
|
if (child?.isTarinaiChampion) recordNaturalStatus("king", { ...detail, world, tarinai: child, bornWithStatus: true });
|
||||||
evaluateMegalopolis(world, detail);
|
evaluateMegalopolis(world, detail);
|
||||||
const progress = ensureProgress();
|
const progress = ensureProgress();
|
||||||
if (!state.unlocked.great_mother_1000_births) {
|
if (!state.unlocked.great_mother_1000_births) {
|
||||||
|
|
@ -1638,12 +1758,18 @@
|
||||||
|
|
||||||
function recordDeath(detail = {}) {
|
function recordDeath(detail = {}) {
|
||||||
recordFightPairDangerDeath(detail);
|
recordFightPairDangerDeath(detail);
|
||||||
|
if (!state.unlocked.memento_mori) {
|
||||||
|
const progress = ensureProgress();
|
||||||
|
progress.totalDeathCount = Math.min(MEMENTO_MORI_TARGET, progress.totalDeathCount + 1);
|
||||||
|
if (progress.totalDeathCount >= MEMENTO_MORI_TARGET) unlock("memento_mori", { ...detail, count: progress.totalDeathCount });
|
||||||
|
else { scheduleProgressSave(); renderIfDialogOpen(); }
|
||||||
|
}
|
||||||
const reason = String(detail.reason || detail.tarinai?.deathReason || "");
|
const reason = String(detail.reason || detail.tarinai?.deathReason || "");
|
||||||
const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason);
|
const lifespanDeath = /\u5bff\u547d|\u5929\u5bff/.test(reason);
|
||||||
const world = detail.world || detail.tarinai?.world || null;
|
const world = detail.world || detail.tarinai?.world || null;
|
||||||
if (world) {
|
if (world) {
|
||||||
evaluateMegalopolis(world, detail);
|
evaluateMegalopolis(world, detail);
|
||||||
const aliveAfterDeath = (world.tarinai || []).reduce((count, candidate) => count + (candidate && !candidate.dead ? 1 : 0), 0);
|
const aliveAfterDeath = playstylePopulationCount(world);
|
||||||
if (!state.unlocked.enemy_enemy_friend && aliveAfterDeath < 30) world.achievementEnemyAntKills = 0;
|
if (!state.unlocked.enemy_enemy_friend && aliveAfterDeath < 30) world.achievementEnemyAntKills = 0;
|
||||||
if (!lifespanDeath) {
|
if (!lifespanDeath) {
|
||||||
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
|
world.achievementNoDeathStartAt = aliveAfterDeath >= 25 ? Math.max(0, Number(world.time || 0) || 0) : -1;
|
||||||
|
|
@ -1706,12 +1832,126 @@
|
||||||
return unlock("ignite_during_birth_ritual", detail);
|
return unlock("ignite_during_birth_ritual", detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordFavoriteTarinai(detail = {}) {
|
||||||
|
if (state.unlocked.favorite_one) return false;
|
||||||
|
const tarinai = detail.tarinai || detail.target || null;
|
||||||
|
if (!tarinai || tarinai.dead || !tarinai.favorite) return false;
|
||||||
|
return unlock("favorite_one", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordStatsButtonPress(detail = {}) {
|
||||||
|
if (state.unlocked.statistician) return false;
|
||||||
|
const progress = ensureProgress();
|
||||||
|
progress.statsButtonPressCount = Math.min(STATISTICIAN_TARGET, progress.statsButtonPressCount + 1);
|
||||||
|
if (progress.statsButtonPressCount >= STATISTICIAN_TARGET) {
|
||||||
|
return unlock("statistician", { ...detail, count: progress.statsButtonPressCount });
|
||||||
|
}
|
||||||
|
saveState();
|
||||||
|
renderIfDialogOpen();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPushNotificationsEnabled(detail = {}) {
|
||||||
|
if (state.unlocked.well_informed) return false;
|
||||||
|
return unlock("well_informed", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordManualTarinaiAdded(detail = {}) {
|
||||||
|
if (state.unlocked.lively_making) return false;
|
||||||
|
const progress = ensureProgress();
|
||||||
|
progress.manualTarinaiAddedCount = Math.min(LIVELY_MAKING_TARGET, progress.manualTarinaiAddedCount + 1);
|
||||||
|
if (progress.manualTarinaiAddedCount >= LIVELY_MAKING_TARGET) {
|
||||||
|
return unlock("lively_making", { ...detail, count: progress.manualTarinaiAddedCount });
|
||||||
|
}
|
||||||
|
saveState();
|
||||||
|
renderIfDialogOpen();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playstylePopulationCount(worldRef) {
|
||||||
|
const cached = worldRef?.tarinaiCounts?.();
|
||||||
|
if (cached && Number.isFinite(Number(cached.alive))) return Math.max(0, Number(cached.alive) || 0);
|
||||||
|
return (worldRef?.tarinai || []).reduce((count, tarinai) => count + (tarinai && !tarinai.dead ? 1 : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playstyleItemCount(worldRef, type) {
|
||||||
|
const counts = worldRef?.itemCounts;
|
||||||
|
if (counts && typeof counts === "object") return Math.max(0, Number(counts[type] || 0) || 0);
|
||||||
|
let count = 0;
|
||||||
|
for (const item of worldRef?.items || []) if (item && !item.dead && item.type === type) count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playstyleBedCapacity(worldRef) {
|
||||||
|
return playstyleItemCount(worldRef, "bed")
|
||||||
|
+ playstyleItemCount(worldRef, "grass_bed")
|
||||||
|
+ playstyleItemCount(worldRef, "nest_box") * 5
|
||||||
|
+ playstyleItemCount(worldRef, "pipe") * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateEvent(worldRef, trigger = "timer", detail = {}) {
|
||||||
|
if (!worldRef) return false;
|
||||||
|
const kind = String(trigger || "timer");
|
||||||
|
const populationEvent = kind === "population" || kind === "timer" || kind === "season" || kind === "restore";
|
||||||
|
const itemEvent = kind === "items" || populationEvent;
|
||||||
|
const now = Math.max(0, Number(worldRef.time || 0) || 0);
|
||||||
|
const aliveCount = populationEvent || itemEvent ? playstylePopulationCount(worldRef) : 0;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (populationEvent) {
|
||||||
|
if (!state.unlocked.strength_in_numbers && aliveCount >= STRENGTH_IN_NUMBERS_TARGET) {
|
||||||
|
changed = unlock("strength_in_numbers", { ...detail, world: worldRef, count: aliveCount }) || changed;
|
||||||
|
}
|
||||||
|
if (!state.unlocked.elite_few) {
|
||||||
|
if (aliveCount > 0 && aliveCount <= ELITE_FEW_MAX_POPULATION) {
|
||||||
|
let startedAt = Number(worldRef.achievementEliteFewStartAt);
|
||||||
|
if (!Number.isFinite(startedAt) || startedAt < 0 || startedAt > now) {
|
||||||
|
startedAt = now;
|
||||||
|
worldRef.achievementEliteFewStartAt = now;
|
||||||
|
}
|
||||||
|
const dayLength = Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120);
|
||||||
|
const targetDuration = dayLength * ELITE_FEW_TARGET_DAYS;
|
||||||
|
if (now - startedAt >= targetDuration) {
|
||||||
|
changed = unlock("elite_few", { ...detail, world: worldRef, count: aliveCount, elapsed: now - startedAt, days: ELITE_FEW_TARGET_DAYS }) || changed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
worldRef.achievementEliteFewStartAt = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = evaluateMegalopolis(worldRef, detail) || changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemEvent) {
|
||||||
|
const zunchiCount = playstyleItemCount(worldRef, "zunchi");
|
||||||
|
const bedCapacity = playstyleBedCapacity(worldRef);
|
||||||
|
if (!state.unlocked.zunchi_overflow && aliveCount >= 20 && zunchiCount > aliveCount) {
|
||||||
|
changed = unlock("zunchi_overflow", { ...detail, world: worldRef, aliveCount, zunchiCount }) || changed;
|
||||||
|
}
|
||||||
|
if (!state.unlocked.comfortable_beds && aliveCount > 0 && bedCapacity >= aliveCount) {
|
||||||
|
changed = unlock("comfortable_beds", { ...detail, world: worldRef, aliveCount, bedCapacity }) || changed;
|
||||||
|
}
|
||||||
|
if (!state.unlocked.stone_pillow && aliveCount >= 30 && bedCapacity === 0) {
|
||||||
|
changed = unlock("stone_pillow", { ...detail, world: worldRef, aliveCount, bedCapacity }) || changed;
|
||||||
|
}
|
||||||
|
changed = evaluateMegalopolis(worldRef, detail) || changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((kind === "season" || kind === "timer" || kind === "restore") && !state.unlocked.across_seasons) {
|
||||||
|
const elapsedDays = Number.isFinite(Number(worldRef.elapsedDays))
|
||||||
|
? Math.max(0, Number(worldRef.elapsedDays) || 0)
|
||||||
|
: Math.max(0, Math.floor(now / Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120)));
|
||||||
|
if (elapsedDays >= FULL_SEASON_CYCLE_DAYS) changed = unlock("across_seasons", { ...detail, world: worldRef, elapsedDays }) || changed;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function evaluateWorld(worldRef, mood = null) {
|
function evaluateWorld(worldRef, mood = null) {
|
||||||
if (!worldRef) return false;
|
if (!worldRef) return false;
|
||||||
const maxGeneration = Math.max(1, Math.floor(Number(worldRef.maxGeneration || 1) || 1));
|
const maxGeneration = Math.max(1, Math.floor(Number(worldRef.maxGeneration || 1) || 1));
|
||||||
if (!state.unlocked.eternal_history_generation_10 && maxGeneration >= GENERATION_TARGET) {
|
if (!state.unlocked.eternal_history_generation_10 && maxGeneration >= GENERATION_TARGET) {
|
||||||
unlock("eternal_history_generation_10", { world: worldRef, generation: maxGeneration });
|
unlock("eternal_history_generation_10", { world: worldRef, generation: maxGeneration });
|
||||||
}
|
}
|
||||||
|
evaluateEvent(worldRef, "timer", { mood });
|
||||||
const needsAliveScan = !state.unlocked.all_non_sleep_diseased_25
|
const needsAliveScan = !state.unlocked.all_non_sleep_diseased_25
|
||||||
|| !state.unlocked.colony_happy
|
|| !state.unlocked.colony_happy
|
||||||
|| !state.unlocked.minimalist_happy
|
|| !state.unlocked.minimalist_happy
|
||||||
|
|
@ -1727,6 +1967,7 @@
|
||||||
if (!needsAliveScan && !needsAntScan && !needsTemperatureItemScan && !needsCleanFreakScan) return true;
|
if (!needsAliveScan && !needsAntScan && !needsTemperatureItemScan && !needsCleanFreakScan) return true;
|
||||||
const now = Math.max(0, Number(worldRef.time || 0) || 0);
|
const now = Math.max(0, Number(worldRef.time || 0) || 0);
|
||||||
const alive = needsAliveScan ? (worldRef.tarinai || []).filter(t => t && !t.dead) : [];
|
const alive = needsAliveScan ? (worldRef.tarinai || []).filter(t => t && !t.dead) : [];
|
||||||
|
const aliveCount = alive.length;
|
||||||
if (!state.unlocked.all_non_sleep_diseased_25
|
if (!state.unlocked.all_non_sleep_diseased_25
|
||||||
&& alive.length >= 25
|
&& alive.length >= 25
|
||||||
&& alive.every(t => Boolean(t.zunchiDisease || t.explosionDisease || t.fightDisease))) {
|
&& alive.every(t => Boolean(t.zunchiDisease || t.explosionDisease || t.fightDisease))) {
|
||||||
|
|
@ -1778,12 +2019,24 @@
|
||||||
const temperature = Number(worldRef.feltTemperatureFor?.(tarinai) ?? worldRef.temperatureAt?.(tarinai.x, tarinai.y, tarinai));
|
const temperature = Number(worldRef.feltTemperatureFor?.(tarinai) ?? worldRef.temperatureAt?.(tarinai.x, tarinai.y, tarinai));
|
||||||
const status = Number.isFinite(temperature) ? worldRef.temperatureStatusFor?.(temperature, tarinai) : null;
|
const status = Number.isFinite(temperature) ? worldRef.temperatureStatusFor?.(temperature, tarinai) : null;
|
||||||
const direction = String(status?.direction || "");
|
const direction = String(status?.direction || "");
|
||||||
if (direction === "hot" && status?.comfortable !== true) tarinai._achievementSaunaHotAt = now;
|
const isHot = direction === "hot" && status?.comfortable !== true;
|
||||||
const hotAt = tarinai._achievementSaunaHotAt == null ? NaN : Number(tarinai._achievementSaunaHotAt);
|
const isCold = direction === "cold" && status?.comfortable !== true;
|
||||||
if (direction === "cold" && status?.comfortable !== true && Number.isFinite(hotAt) && now >= hotAt && now - hotAt <= 15.0001) {
|
let hotAt = tarinai._achievementSaunaHotAt == null ? NaN : Number(tarinai._achievementSaunaHotAt);
|
||||||
unlock("sauna_cold_plunge", { world: worldRef, tarinai, elapsed: now - hotAt, temperature });
|
let wasHot = tarinai._achievementSaunaWasHot;
|
||||||
|
if (wasHot == null) wasHot = Boolean(isHot && Number.isFinite(hotAt));
|
||||||
|
if (isHot) {
|
||||||
|
if (!wasHot || !Number.isFinite(hotAt) || hotAt > now) {
|
||||||
|
hotAt = now;
|
||||||
|
tarinai._achievementSaunaHotAt = now;
|
||||||
|
}
|
||||||
|
tarinai._achievementSaunaWasHot = true;
|
||||||
|
} else {
|
||||||
|
tarinai._achievementSaunaWasHot = false;
|
||||||
|
if (isCold && Number.isFinite(hotAt) && now >= hotAt && now - hotAt <= 15.0001) {
|
||||||
|
unlock("sauna_cold_plunge", { world: worldRef, tarinai, elapsed: now - hotAt, temperature });
|
||||||
|
}
|
||||||
|
if (Number.isFinite(hotAt) && now - hotAt > 15.0001) tarinai._achievementSaunaHotAt = null;
|
||||||
}
|
}
|
||||||
if (Number.isFinite(hotAt) && now - hotAt > 15.0001) tarinai._achievementSaunaHotAt = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1793,7 +2046,9 @@
|
||||||
lastInterventionAt = now;
|
lastInterventionAt = now;
|
||||||
worldRef.achievementLastInterventionAt = now;
|
worldRef.achievementLastInterventionAt = now;
|
||||||
}
|
}
|
||||||
if (now - lastInterventionAt >= 300) unlock("idle_observer_5_minutes", { world: worldRef, elapsed: now - lastInterventionAt });
|
const dayLength = Math.max(1, Number(worldRef.config?.dayLength ?? global.CONFIG?.dayLength ?? 120) || 120);
|
||||||
|
const observerTarget = dayLength * OBSERVER_GAME_DAYS;
|
||||||
|
if (now - lastInterventionAt >= observerTarget) unlock("idle_observer_5_minutes", { world: worldRef, elapsed: now - lastInterventionAt, gameDays: OBSERVER_GAME_DAYS });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.unlocked.safe_colony_25_5_minutes) {
|
if (!state.unlocked.safe_colony_25_5_minutes) {
|
||||||
|
|
@ -1913,6 +2168,7 @@
|
||||||
document.addEventListener("keydown", event => {
|
document.addEventListener("keydown", event => {
|
||||||
if (event.key === "Escape" && !dom.dialog?.classList.contains("hidden")) closeDialog();
|
if (event.key === "Escape" && !dom.dialog?.classList.contains("hidden")) closeDialog();
|
||||||
});
|
});
|
||||||
|
global.addEventListener?.("pagehide", saveState);
|
||||||
|
|
||||||
const api = Object.freeze({
|
const api = Object.freeze({
|
||||||
definitions: DEFINITIONS,
|
definitions: DEFINITIONS,
|
||||||
|
|
@ -1945,6 +2201,10 @@
|
||||||
recordLoveMochiBirth,
|
recordLoveMochiBirth,
|
||||||
evaluateMegalopolis,
|
evaluateMegalopolis,
|
||||||
recordHeldTarinai,
|
recordHeldTarinai,
|
||||||
|
recordFavoriteTarinai,
|
||||||
|
recordStatsButtonPress,
|
||||||
|
recordPushNotificationsEnabled,
|
||||||
|
recordManualTarinaiAdded,
|
||||||
resetWorldProgress,
|
resetWorldProgress,
|
||||||
recordLinkPlaced,
|
recordLinkPlaced,
|
||||||
recordSignalActivation,
|
recordSignalActivation,
|
||||||
|
|
@ -1969,6 +2229,7 @@
|
||||||
exportSpellState,
|
exportSpellState,
|
||||||
importSpellState,
|
importSpellState,
|
||||||
evaluateWorld,
|
evaluateWorld,
|
||||||
|
evaluateEvent,
|
||||||
recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); },
|
recordSelfZunchiDeath(detail = {}) { return unlock("self_zunchi_death", detail); },
|
||||||
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
|
recordSoccerBallDeath(detail = {}) { return unlock("soccer_ball_death", detail); },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,8 @@ class AntActor {
|
||||||
if (!t || t.dead) continue;
|
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);
|
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) || [])]) {
|
for (const it of [...(this.world?.nearbyGrass?.(this.x, this.y, contactRadius + 28, true) || [])]) {
|
||||||
if (!it || it.dead || it.type !== "grass") continue;
|
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 (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)) {
|
if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
const avy = a.vy || 0;
|
const avy = a.vy || 0;
|
||||||
const aSpeed = Math.hypot(avx, avy);
|
const aSpeed = Math.hypot(avx, avy);
|
||||||
const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52;
|
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;
|
if (!b || b === a || b.dead || !isBallLikeType(b.type)) continue;
|
||||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||||
if (now - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue;
|
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));
|
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);
|
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);
|
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;
|
return solved;
|
||||||
|
|
@ -166,7 +166,7 @@
|
||||||
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
|
||||||
ball.lastKickedAt = now;
|
ball.lastKickedAt = now;
|
||||||
ball.lastKickerId = t.id;
|
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] });
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +222,7 @@
|
||||||
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
|
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);
|
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
|
||||||
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
|
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 });
|
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.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
|
||||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
||||||
|
|
@ -232,7 +232,7 @@
|
||||||
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
|
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
|
||||||
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
|
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.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] });
|
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.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.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
ball.lastKickerId = t.id;
|
ball.lastKickerId = t.id;
|
||||||
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(t, frameDt, "ball-contact");
|
global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(t, frameDt, "ball-contact");
|
||||||
worldRef.markSpatialDirty?.("sleeping-ball-contact");
|
worldRef.markSpatialDirty?.("sleeping-ball-contact");
|
||||||
worldRef.effects.push(new Effect("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)" }));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +304,7 @@
|
||||||
worldRef.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
|
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}\u306f${objectLabel}\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
|
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.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)" }));
|
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)" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,14 +450,14 @@
|
||||||
for (const t of [target, impactor]) {
|
for (const t of [target, impactor]) {
|
||||||
if (!t || t.dead) continue;
|
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" });
|
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.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
|
||||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
||||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||||
t.fallDir = nx >= 0 ? 1 : -1;
|
t.fallDir = nx >= 0 ? 1 : -1;
|
||||||
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
|
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 (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");
|
if (solved && worldRef.spatialDirty) worldRef.rebuildSpatial?.(true, "post-tarinai-highspeed-collision");
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,6 @@
|
||||||
(function (global) {
|
(function (global) {
|
||||||
function avgOf(list, fn) { return list.length ? list.reduce((sum, t) => sum + (Number(fn(t)) || 0), 0) / list.length : 0; }
|
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 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) {
|
function collect(worldRef) {
|
||||||
const now = worldRef?.time || 0;
|
const now = worldRef?.time || 0;
|
||||||
const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead);
|
const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead);
|
||||||
|
|
@ -29,12 +13,10 @@
|
||||||
const panicCount = countOf(alive, t => t.state === "panic" || (t.fearTimer || 0) > 0.2 || (t.panicTargetUntil || 0) > now);
|
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 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 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 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 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 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" ? 28 : (fieldId === "park" ? 140 : 75);
|
|
||||||
const previousPopulation = Number(worldRef?.lastColonyMoodPopulation);
|
const previousPopulation = Number(worldRef?.lastColonyMoodPopulation);
|
||||||
const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation;
|
const populationIncreasing = Number.isFinite(previousPopulation) && n > previousPopulation;
|
||||||
return {
|
return {
|
||||||
|
|
@ -50,35 +32,36 @@
|
||||||
panicCount,
|
panicCount,
|
||||||
safetyHighCount,
|
safetyHighCount,
|
||||||
weakCount,
|
weakCount,
|
||||||
|
criticalCount,
|
||||||
diseasedCount,
|
diseasedCount,
|
||||||
breedingCount,
|
breedingCount,
|
||||||
recentDeaths,
|
recentDeaths,
|
||||||
nearestAvg,
|
|
||||||
recentShock: (now - (worldRef?.lastShootAt || -999) <= 8) || (now - (worldRef?.lastGenkotsuAt || -999) <= 8),
|
recentShock: (now - (worldRef?.lastShootAt || -999) <= 8) || (now - (worldRef?.lastGenkotsuAt || -999) <= 8),
|
||||||
panicRatio: ratio(panicCount),
|
panicRatio: ratio(panicCount),
|
||||||
safetyRatio: ratio(safetyHighCount),
|
safetyRatio: ratio(safetyHighCount),
|
||||||
weakRatio: ratio(weakCount),
|
weakRatio: ratio(weakCount),
|
||||||
|
criticalRatio: ratio(criticalCount),
|
||||||
diseaseRatio: ratio(diseasedCount),
|
diseaseRatio: ratio(diseasedCount),
|
||||||
breedingRatio: ratio(breedingCount),
|
breedingRatio: ratio(breedingCount),
|
||||||
overcrowdLimit,
|
|
||||||
denseCrowd: Number.isFinite(nearestAvg) && nearestAvg < 58 && n >= Math.max(12, Math.floor(overcrowdLimit * 0.75)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function choose(worldRef, m) {
|
function choose(worldRef, m) {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
const add = (id, priority, ok) => { if (ok) candidates.push({ id, priority }); };
|
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 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("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("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("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("disease_spread", 82, m.diseaseRatio >= 0.25 || (m.diseasedCount >= 3 && m.diseaseRatio >= 0.16));
|
||||||
add("weakened", 74, !m.populationIncreasing && ((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("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("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);
|
||||||
add("isolated", 28, m.n <= 1 || (m.n <= 3 && m.nearestAvg > Math.max(180, Math.min(worldRef?.w || 900, worldRef?.h || 600) * 0.32)));
|
|
||||||
if (!candidates.length) return "relaxed";
|
if (!candidates.length) return "relaxed";
|
||||||
candidates.sort((a, b) => b.priority - a.priority);
|
candidates.sort((a, b) => b.priority - a.priority);
|
||||||
return candidates[0].id;
|
return candidates[0].id;
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
if (!worldRef) return fail("missing_world");
|
if (!worldRef) return fail("missing_world");
|
||||||
if (!target || target.dead) return fail("missing_target");
|
if (!target || target.dead) return fail("missing_target");
|
||||||
target.favorite = !target.favorite;
|
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) });
|
return ok({ selected: target, favorite: Boolean(target.favorite) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
if (found && (found.dead || (Number.isFinite(Number(found.amount)) && Number(found.amount) <= 0))) found = null;
|
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 && 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)) {
|
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));
|
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) {
|
if (found) {
|
||||||
endpoint.id = found.id;
|
endpoint.id = found.id;
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ const CONFIG = {
|
||||||
reproductionCooldown: 18,
|
reproductionCooldown: 18,
|
||||||
dayLength: 120,
|
dayLength: 120,
|
||||||
standardTemperature: 15,
|
standardTemperature: 15,
|
||||||
summerTemperatureBoost: 5,
|
summerTemperatureBoost: 0,
|
||||||
climateTemperatureMin: -20,
|
climateTemperatureMin: -20,
|
||||||
climateTemperatureMax: 50,
|
climateTemperatureMax: 50,
|
||||||
temperatureMin: -Infinity,
|
temperatureMin: -Infinity,
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,13 @@ function updateFirecracker(item, dt, worldRef) {
|
||||||
if ((worldRef.effects.length || 0) >= effectCap) return;
|
if ((worldRef.effects.length || 0) >= effectCap) return;
|
||||||
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
|
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);
|
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,
|
vx: Math.cos(a) * speed * 0.35,
|
||||||
vy: -Math.max(4, speed * 0.65),
|
vy: -Math.max(4, speed * 0.65),
|
||||||
size: Math.max(4, size * 0.58),
|
size: Math.max(4, size * 0.58),
|
||||||
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
|
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
|
||||||
color: "rgba(236,46,24,0.78)",
|
color: "rgba(236,46,24,0.78)",
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fireInteractionBudget(worldRef) {
|
function fireInteractionBudget(worldRef) {
|
||||||
|
|
@ -99,6 +99,7 @@ function updateFirecracker(item, dt, worldRef) {
|
||||||
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
||||||
t.thought = FIRE_BURNING_TEXT;
|
t.thought = FIRE_BURNING_TEXT;
|
||||||
t.lastPanicCause = "burning";
|
t.lastPanicCause = "burning";
|
||||||
|
if (source && source !== t) t.lastPanicThreat = source;
|
||||||
t.lastPanicDetail = FIRE_BURNING_TEXT;
|
t.lastPanicDetail = FIRE_BURNING_TEXT;
|
||||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
||||||
t.sleeping = false;
|
t.sleeping = false;
|
||||||
|
|
@ -114,6 +115,7 @@ function updateFirecracker(item, dt, worldRef) {
|
||||||
t.firePanicCooldownUntil = now + 0.85;
|
t.firePanicCooldownUntil = now + 0.85;
|
||||||
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
|
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
|
||||||
t.lastPanicCause = "fire_seen";
|
t.lastPanicCause = "fire_seen";
|
||||||
|
if (source && source !== t) t.lastPanicThreat = source;
|
||||||
t.lastPanicDetail = FIRE_SEEN_TEXT;
|
t.lastPanicDetail = FIRE_SEEN_TEXT;
|
||||||
t.thought = 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 });
|
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 });
|
||||||
|
|
@ -350,14 +352,11 @@ function updateFirecracker(item, dt, worldRef) {
|
||||||
const d = distXY(item.x, item.y, ant.x, ant.y);
|
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;
|
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) {
|
for (const it of nearby) {
|
||||||
if (!it || it.dead || it === item) continue;
|
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 === "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 === "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)) {
|
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
|
||||||
|
|
@ -366,6 +365,12 @@ function updateFirecracker(item, dt, worldRef) {
|
||||||
changed = true;
|
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;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
// User-facing undo/redo for direct editing actions.
|
// User-facing undo/redo for direct editing actions.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const MAX_HISTORY = 48;
|
const MAX_HISTORY = 48;
|
||||||
|
const MAX_HISTORY_BYTES = 16 * 1024 * 1024;
|
||||||
function cloneSnapshot(snapshot) {
|
|
||||||
if (!snapshot) return null;
|
|
||||||
if (typeof structuredClone === "function") return structuredClone(snapshot);
|
|
||||||
return JSON.parse(JSON.stringify(snapshot));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensure(worldRef) {
|
function ensure(worldRef) {
|
||||||
if (!worldRef) return null;
|
if (!worldRef) return null;
|
||||||
|
|
@ -17,147 +12,123 @@
|
||||||
return worldRef;
|
return worldRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotKey(snapshot) {
|
function codec() {
|
||||||
try {
|
const value = global.TarinaiSaveCodec;
|
||||||
// Snapshot payloads are produced as plain serializable data; stringify them
|
if (!value || typeof value.encodeBinarySnapshot !== "function" || typeof value.decodeBinarySnapshot !== "function") {
|
||||||
// directly for duplicate suppression instead of deep-cloning once just to
|
throw new Error("TarinaiSaveCodec binary history API is unavailable");
|
||||||
// compute the history key. The stored history entry is still cloned below.
|
|
||||||
return JSON.stringify(snapshot);
|
|
||||||
} catch (_) {
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two independent 32-bit accumulators make accidental duplicate suppression
|
||||||
const DELETE_MARK = Object.freeze({ __delete: true });
|
// extremely unlikely without converting the complete snapshot to JSON text.
|
||||||
const HISTORY_FULL_INTERVAL = 6;
|
function fingerprintBytes(bytes) {
|
||||||
const HISTORY_DELTA_MIN_SAVING = 0.72;
|
let h1 = 0x811c9dc5;
|
||||||
|
let h2 = 0x9e3779b9;
|
||||||
function isPlainObject(value) {
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
if (!value || typeof value !== "object") return false;
|
const value = bytes[i];
|
||||||
const proto = Object.getPrototypeOf(value);
|
h1 ^= value;
|
||||||
return proto === Object.prototype || proto === null;
|
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) {
|
function makeHistoryEntry(world, snapshot, label) {
|
||||||
if (Object.is(prev, next)) return undefined;
|
if (!snapshot) return null;
|
||||||
if (Array.isArray(prev) || Array.isArray(next)) {
|
const bytes = codec().encodeBinarySnapshot(snapshot);
|
||||||
const a = JSON.stringify(prev);
|
return {
|
||||||
const b = JSON.stringify(next);
|
bytes,
|
||||||
return a === b ? undefined : cloneSnapshot(next);
|
key: fingerprintBytes(bytes),
|
||||||
}
|
label: String(label || "action"),
|
||||||
if (!isPlainObject(prev) || !isPlainObject(next)) return cloneSnapshot(next);
|
time: Number(world?.time) || 0,
|
||||||
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 applyPatch(base, patch) {
|
function captureEntry(world, label) {
|
||||||
if (patch === undefined) return cloneSnapshot(base);
|
const snapshot = global.TarinaiSnapshot.createSnapshot(world);
|
||||||
if (!isPlainObject(patch) || Array.isArray(patch)) return cloneSnapshot(patch);
|
return makeHistoryEntry(world, snapshot, label);
|
||||||
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 materializeHistoryEntry(stack, entry) {
|
function stackBytes(stack) {
|
||||||
if (!entry) return null;
|
let total = 0;
|
||||||
if (entry.snapshot) return cloneSnapshot(entry.snapshot);
|
for (const entry of stack || []) total += Number(entry?.bytes?.byteLength || entry?.bytes?.length || 0) || 0;
|
||||||
const sequence = [...(stack || []), entry];
|
return total;
|
||||||
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 makeHistoryEntry(world, snap, key, label, stack) {
|
function trim(stack, maxBytes = MAX_HISTORY_BYTES, keepRecent = 1) {
|
||||||
const fullEntry = { snapshot: cloneSnapshot(snap), key, label: String(label || "action"), time: world.time || 0 };
|
if (stack.length > MAX_HISTORY) stack.splice(0, stack.length - MAX_HISTORY);
|
||||||
const stackLen = Array.isArray(stack) ? stack.length : 0;
|
let total = stackBytes(stack);
|
||||||
if (stackLen <= 0 || stackLen % HISTORY_FULL_INTERVAL === 0) return fullEntry;
|
const minKeep = Math.max(0, Math.min(stack.length, Math.floor(Number(keepRecent) || 0)));
|
||||||
const prev = materializeHistoryEntry(stack.slice(0, -1), stack[stack.length - 1]);
|
while (stack.length > minKeep && total > maxBytes) {
|
||||||
if (!prev) return fullEntry;
|
const removed = stack.shift();
|
||||||
const patch = diffValue(prev, snap);
|
total -= Number(removed?.bytes?.byteLength || removed?.bytes?.length || 0) || 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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") {
|
function capture(worldRef = global.world, label = "action") {
|
||||||
const world = ensure(worldRef);
|
const world = ensure(worldRef);
|
||||||
if (!world || world._historyRestoring) return false;
|
if (!world || world._historyRestoring) return false;
|
||||||
const snap = global.TarinaiSnapshot.createSnapshot(world);
|
const entry = captureEntry(world, label);
|
||||||
if (!snap) return false;
|
if (!entry) return false;
|
||||||
const key = snapshotKey(snap);
|
if (entry.key && entry.key === world._lastUndoSnapshotKey) return false;
|
||||||
if (key && key === world._lastUndoSnapshotKey) return false;
|
world._undoStack.push(entry);
|
||||||
world._undoStack.push(makeHistoryEntry(world, snap, key, label, world._undoStack));
|
trim(world._undoStack);
|
||||||
if (world._undoStack.length > MAX_HISTORY) world._undoStack.splice(0, world._undoStack.length - MAX_HISTORY);
|
|
||||||
world._redoStack.length = 0;
|
world._redoStack.length = 0;
|
||||||
world._lastUndoSnapshotKey = key;
|
world._lastUndoSnapshotKey = entry.key;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore(worldRef, entry, stack = []) {
|
function restore(worldRef, entry) {
|
||||||
const snapshot = materializeHistoryEntry(stack, entry);
|
if (!worldRef || !entry?.bytes) return false;
|
||||||
if (!worldRef || !snapshot) return false;
|
const snapshot = codec().decodeBinarySnapshot(entry.bytes);
|
||||||
|
if (!snapshot) return false;
|
||||||
worldRef._historyRestoring = true;
|
worldRef._historyRestoring = true;
|
||||||
try {
|
try {
|
||||||
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
|
global.TarinaiRestoreCoordinator.restoreSnapshot(snapshot, worldRef, { syncUi: true });
|
||||||
worldRef._lastUndoSnapshotKey = snapshotKey(global.TarinaiSnapshot.createSnapshot(worldRef));
|
worldRef._lastUndoSnapshotKey = entry.key || fingerprintBytes(entry.bytes);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
worldRef._historyRestoring = false;
|
worldRef._historyRestoring = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo(worldRef = global.world) {
|
function moveHistory(worldRef, fromKey, toKey, fallbackLabel) {
|
||||||
const world = ensure(worldRef);
|
const world = ensure(worldRef);
|
||||||
if (!world || !world._undoStack.length) return { ok: false, reason: "empty" };
|
const from = world?.[fromKey];
|
||||||
const current = global.TarinaiSnapshot.createSnapshot(world);
|
const to = world?.[toKey];
|
||||||
const prev = world._undoStack.pop();
|
if (!world || !from?.length || !Array.isArray(to)) return { ok: false, reason: "empty" };
|
||||||
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 current = captureEntry(world, fallbackLabel);
|
||||||
const ok = restore(world, prev, world._undoStack);
|
const target = from.pop();
|
||||||
return { ok, label: prev.label || "" };
|
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) {
|
function redo(worldRef = global.world) {
|
||||||
const world = ensure(worldRef);
|
return moveHistory(worldRef, "_redoStack", "_undoStack", "undo");
|
||||||
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 || "" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
global.TarinaiHistory = Object.freeze({ capture, undo, redo, trimMemory });
|
||||||
global.TarinaiHistory = Object.freeze({ capture, undo, redo });
|
|
||||||
})(typeof window !== "undefined" ? window : globalThis);
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
let ballHit = 0;
|
let ballHit = 0;
|
||||||
if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) {
|
if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) {
|
||||||
const ballRange = radius + Math.max(72, (it.r || 88) * 0.78);
|
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) {
|
for (const ball of balls) {
|
||||||
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
|
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));
|
const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y));
|
||||||
|
|
@ -92,10 +92,10 @@
|
||||||
const existingEffects = (worldRef.effects || []).length;
|
const existingEffects = (worldRef.effects || []).length;
|
||||||
const ringCount = existingEffects > 180 ? 2 : 3;
|
const ringCount = existingEffects > 180 ? 2 : 3;
|
||||||
const burstCount = existingEffects > 180 ? 4 : 7;
|
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 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.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 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 {
|
} 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 (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?.();
|
if (isGenkotsu) audio.genkotsuImpact?.();
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
if (!worldRef?.nearbyItems) return;
|
if (!worldRef?.nearbyItems) return;
|
||||||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
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 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);
|
const seen = new Set(items);
|
||||||
// \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
|
// \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
|
// \u5168item\u8d70\u67fb\u3067\u306f\u306a\u304f\u3001\u6a5f\u69cbtype bucket\u3060\u3051\u3092\u8ffd\u52a0\u78ba\u8a8d\u3059\u308b\u3002
|
||||||
|
|
@ -276,7 +276,7 @@
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
return worldRef.nearbyItems?.(cx, cy, radius) || worldRef.items || [];
|
return worldRef.nearbyNonGrassItems?.(cx, cy, radius) || worldRef.items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fanWindAt(fan, x, y, opts = {}) {
|
function fanWindAt(fan, x, y, opts = {}) {
|
||||||
|
|
@ -438,7 +438,7 @@
|
||||||
const now = worldRef.time || 0;
|
const now = worldRef.time || 0;
|
||||||
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
|
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
|
||||||
const radius = Math.max(26, (balloon.r || 20) + 22);
|
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 (!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") {
|
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);
|
const extra = it.type === "fire" ? 16 : (it.type === "firecracker" || it.type === "flame_firecracker" ? 8 : 4);
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
if (!ball || ball.dead || ball.type !== "ball" || !worldRef?.nearbyItems) return 0;
|
if (!ball || ball.dead || ball.type !== "ball" || !worldRef?.nearbyItems) return 0;
|
||||||
const speed = Math.hypot(ball.vx || 0, ball.vy || 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 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;
|
let picked = 0;
|
||||||
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
||||||
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
||||||
|
|
@ -218,6 +218,7 @@
|
||||||
if (t.enterPanic) {
|
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" });
|
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 {
|
} else {
|
||||||
|
t.lastPanicThreat = item;
|
||||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
|
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
|
||||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
|
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 });
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
if (reason === "transfer") global.TarinaiAchievements?.recordStickyBombPass?.(item, { world: worldRef, from: old, to: carrier });
|
if (reason === "transfer") global.TarinaiAchievements?.recordStickyBombPass?.(item, { world: worldRef, from: old, to: carrier });
|
||||||
}
|
}
|
||||||
if (worldRef?.effects && typeof Effect !== "undefined") {
|
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?.markItemBucketsDirty?.("sticky-bomb-attach");
|
||||||
worldRef?.markSpatialDirty?.("sticky-bomb-attach");
|
worldRef?.markSpatialDirty?.("sticky-bomb-attach");
|
||||||
|
|
@ -158,7 +158,7 @@
|
||||||
if (best) attachStickyBomb(item, best, worldRef, "attach");
|
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))) {
|
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);
|
worldRef && (worldRef.drawListDirty = true);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -127,13 +127,13 @@
|
||||||
if (item.stage === "fresh" && item.freshness < 0.45) { item.stage = "dry"; item.stageTimer = 0; }
|
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 (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)) {
|
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),
|
vx: deterministicRange(worldRef, "zunchi-fire-vx", -3, 3, item),
|
||||||
vy: deterministicRange(worldRef, "zunchi-fire-vy", -10, -4, item),
|
vy: deterministicRange(worldRef, "zunchi-fire-vy", -10, -4, item),
|
||||||
size: deterministicRange(worldRef, "zunchi-fire-size", 6, 12, item),
|
size: deterministicRange(worldRef, "zunchi-fire-size", 6, 12, item),
|
||||||
life: deterministicRange(worldRef, "zunchi-fire-life", 0.18, 0.34, item),
|
life: deterministicRange(worldRef, "zunchi-fire-life", 0.18, 0.34, item),
|
||||||
color: "rgba(236,46,24,0.72)",
|
color: "rgba(236,46,24,0.72)",
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
if (item.burnTimer <= 0.01 || (item.amount || 0) <= 0.5) {
|
if (item.burnTimer <= 0.01 || (item.amount || 0) <= 0.5) {
|
||||||
item.burning = false;
|
item.burning = false;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
worldRef?.markSpatialDirty?.("food-passive-resize");
|
worldRef?.markSpatialDirty?.("food-passive-resize");
|
||||||
if (worldRef) worldRef.drawListDirty = true;
|
if (worldRef) worldRef.drawListDirty = true;
|
||||||
}
|
}
|
||||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
|
||||||
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
|
if (item.foodServingsRemaining <= 0.015) item.amount = 0;
|
||||||
}
|
}
|
||||||
} else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) {
|
} else if (MEDICINE_LIKE_FOOD_TYPES.includes(item.type)) {
|
||||||
|
|
@ -41,7 +40,6 @@
|
||||||
item.amount -= lost;
|
item.amount -= lost;
|
||||||
if (lost > 0) {
|
if (lost > 0) {
|
||||||
changed = true;
|
changed = true;
|
||||||
worldRef?.markTerrainDirty?.("food-passive-decay");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +51,13 @@
|
||||||
if (item.type === "water") item.amount -= dt * 0.9;
|
if (item.type === "water") item.amount -= dt * 0.9;
|
||||||
decayServingFood(item, dt, worldRef);
|
decayServingFood(item, dt, worldRef);
|
||||||
if (item.type === "trace") item.amount -= dt * 1.35;
|
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;
|
if (item.type === "ant_corpse") item.amount -= dt * 0.018;
|
||||||
return { done: false };
|
return { done: false };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
js/main.js
15
js/main.js
|
|
@ -16,8 +16,21 @@ function ensureWorldBootstrap() {
|
||||||
let world = ensureWorldBootstrap();
|
let world = ensureWorldBootstrap();
|
||||||
let last = nowSec();
|
let last = nowSec();
|
||||||
let statsTimer = 0;
|
let statsTimer = 0;
|
||||||
|
let statsRefreshPending = false;
|
||||||
window.__tarinaiFps = 0;
|
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
|
const LOADING_SPRITES = SPRITES
|
||||||
.filter(asset => asset && !asset.actionOnly)
|
.filter(asset => asset && !asset.actionOnly)
|
||||||
.slice(0, 11)
|
.slice(0, 11)
|
||||||
|
|
@ -96,7 +109,7 @@ function loop() {
|
||||||
statsTimer += dt;
|
statsTimer += dt;
|
||||||
const statsInterval = 2.5;
|
const statsInterval = 2.5;
|
||||||
if (statsTimer > statsInterval) {
|
if (statsTimer > statsInterval) {
|
||||||
renderStats();
|
scheduleStatsRefresh();
|
||||||
statsTimer = 0;
|
statsTimer = 0;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
|
|
|
||||||
|
|
@ -912,7 +912,7 @@
|
||||||
const rects = obstacleRects(item);
|
const rects = obstacleRects(item);
|
||||||
if (!rects.length) return false;
|
if (!rects.length) return false;
|
||||||
const queryRadius = radius || reach(item) + 96;
|
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 changed = false;
|
||||||
let contacts = 0;
|
let contacts = 0;
|
||||||
for (const other of source) {
|
for (const other of source) {
|
||||||
|
|
@ -1032,7 +1032,7 @@
|
||||||
function fenceRectsNear(item, worldRef, radius) {
|
function fenceRectsNear(item, worldRef, radius) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (!item || !worldRef?.items) return 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) {
|
for (const other of source) {
|
||||||
if (!other || other === item || other.dead || isMechanicalType(other)) continue;
|
if (!other || other === item || other.dead || isMechanicalType(other)) continue;
|
||||||
if (other.type === "gate_fence" && other.gateOpen) continue;
|
if (other.type === "gate_fence" && other.gateOpen) continue;
|
||||||
|
|
@ -1043,7 +1043,7 @@
|
||||||
|
|
||||||
function hasFenceNearby(item, worldRef, radius) {
|
function hasFenceNearby(item, worldRef, radius) {
|
||||||
if (!item || !worldRef?.items) return false;
|
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) {
|
for (const other of source) {
|
||||||
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
|
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
|
||||||
if (other.type === "gate_fence" && other.gateOpen) continue;
|
if (other.type === "gate_fence" && other.gateOpen) continue;
|
||||||
|
|
@ -1088,7 +1088,7 @@
|
||||||
function hasInteractionCandidates(item, worldRef, radius = null) {
|
function hasInteractionCandidates(item, worldRef, radius = null) {
|
||||||
if (!item || !worldRef) return false;
|
if (!item || !worldRef) return false;
|
||||||
const r = radius || reach(item) + 80;
|
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) {
|
for (const other of source) {
|
||||||
if (!other || other === item || other.dead) continue;
|
if (!other || other === item || other.dead) continue;
|
||||||
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type) || isPhysicalCircleContactObject(other)) return true;
|
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type) || isPhysicalCircleContactObject(other)) return true;
|
||||||
|
|
@ -1101,7 +1101,7 @@
|
||||||
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
|
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const radius = reach(item) + 80;
|
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) {
|
for (const other of source) {
|
||||||
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
|
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
|
||||||
changed = resolvePair(item, other, dt, worldRef) || changed;
|
changed = resolvePair(item, other, dt, worldRef) || changed;
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
let fpsAccumFrames = 0;
|
let fpsAccumFrames = 0;
|
||||||
let smoothedFps = 0;
|
let smoothedFps = 0;
|
||||||
let pendingProfileId = "";
|
let pendingProfileId = "";
|
||||||
let pendingProfileSamples = 0;
|
let pendingProfileSeconds = 0;
|
||||||
|
|
||||||
function bucketFor(label) {
|
function bucketFor(label) {
|
||||||
let b = buckets.get(label);
|
let b = buckets.get(label);
|
||||||
|
|
@ -240,13 +240,19 @@
|
||||||
|
|
||||||
function chooseAutoProfile(fps) {
|
function chooseAutoProfile(fps) {
|
||||||
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
|
if (!Number.isFinite(fps) || fps <= 0) return autoProfileId;
|
||||||
if (autoProfileId === "full") return fps < 51 ? "balanced" : "full";
|
// Wide enter/exit bands prevent profile flapping around a single FPS line.
|
||||||
if (autoProfileId === "aggressive") return fps >= 45 ? "balanced" : "aggressive";
|
if (autoProfileId === "full") return fps < 48 ? "balanced" : "full";
|
||||||
if (fps >= 57) return "full";
|
if (autoProfileId === "aggressive") return fps > 45 ? "balanced" : "aggressive";
|
||||||
if (fps < 38) return "aggressive";
|
if (fps < 30) return "aggressive";
|
||||||
|
if (fps > 56) return "full";
|
||||||
return "balanced";
|
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) {
|
function applyAutoProfile(nextId) {
|
||||||
if (!profiles[nextId] || nextId === autoProfileId) return false;
|
if (!profiles[nextId] || nextId === autoProfileId) return false;
|
||||||
const before = activeProfile();
|
const before = activeProfile();
|
||||||
|
|
@ -266,28 +272,29 @@
|
||||||
fpsAccumSeconds += dt;
|
fpsAccumSeconds += dt;
|
||||||
fpsAccumFrames += 1;
|
fpsAccumFrames += 1;
|
||||||
if (fpsAccumSeconds < 0.75) return;
|
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;
|
smoothedFps = smoothedFps > 0 ? smoothedFps * 0.65 + sampledFps * 0.35 : sampledFps;
|
||||||
global.__tarinaiFps = Math.round(smoothedFps);
|
global.__tarinaiFps = Math.round(smoothedFps);
|
||||||
fpsAccumSeconds = 0;
|
fpsAccumSeconds = 0;
|
||||||
fpsAccumFrames = 0;
|
fpsAccumFrames = 0;
|
||||||
if (visualSettings.details !== "auto") return;
|
if (visualSettings.details !== "auto") return;
|
||||||
const desired = chooseAutoProfile(sampledFps);
|
const desired = chooseAutoProfile(smoothedFps);
|
||||||
if (desired === autoProfileId) {
|
if (desired === autoProfileId) {
|
||||||
pendingProfileId = "";
|
pendingProfileId = "";
|
||||||
pendingProfileSamples = 0;
|
pendingProfileSeconds = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingProfileId !== desired) {
|
if (pendingProfileId !== desired) {
|
||||||
pendingProfileId = desired;
|
pendingProfileId = desired;
|
||||||
pendingProfileSamples = 1;
|
pendingProfileSeconds = sampleSeconds;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingProfileSamples += 1;
|
pendingProfileSeconds += sampleSeconds;
|
||||||
if (pendingProfileSamples >= 2) {
|
if (pendingProfileSeconds >= transitionHoldSeconds(autoProfileId, desired)) {
|
||||||
applyAutoProfile(desired);
|
applyAutoProfile(desired);
|
||||||
pendingProfileId = "";
|
pendingProfileId = "";
|
||||||
pendingProfileSamples = 0;
|
pendingProfileSeconds = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +354,7 @@
|
||||||
}
|
}
|
||||||
function dprScaleValue() { return activeProfile().dprScale; }
|
function dprScaleValue() { return activeProfile().dprScale; }
|
||||||
function performanceProfile() { return activeProfile(); }
|
function performanceProfile() { return activeProfile(); }
|
||||||
|
function diagnosticsEnabled() { return debugEnabled; }
|
||||||
function simulationOptimizationTier() { return activeProfile().id; }
|
function simulationOptimizationTier() { return activeProfile().id; }
|
||||||
function consumeResizeRequest() {
|
function consumeResizeRequest() {
|
||||||
const v = resizeRequested;
|
const v = resizeRequested;
|
||||||
|
|
@ -404,6 +412,7 @@
|
||||||
setVisualSetting,
|
setVisualSetting,
|
||||||
dprScale: dprScaleValue,
|
dprScale: dprScaleValue,
|
||||||
performanceProfile,
|
performanceProfile,
|
||||||
|
diagnosticsEnabled,
|
||||||
simulationOptimizationTier,
|
simulationOptimizationTier,
|
||||||
consumeResizeRequest,
|
consumeResizeRequest,
|
||||||
setMetric,
|
setMetric,
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,13 @@
|
||||||
function rectOutside(worldRef, rect, itemType = "") {
|
function rectOutside(worldRef, rect, itemType = "") {
|
||||||
if (!worldRef || !rect) return true;
|
if (!worldRef || !rect) return true;
|
||||||
const pad = global.CONFIG?.worldPadding || 30;
|
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) < 0 || num(rect.bottom) > (worldRef.h || 0);
|
||||||
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < 0 || num(rect.bottom) > (worldRef.h || 0) - (fence ? 0 : pad);
|
|
||||||
}
|
}
|
||||||
function circleOutside(worldRef, x, y, radius) {
|
function circleOutside(worldRef, x, y, radius) {
|
||||||
if (!worldRef) return true;
|
if (!worldRef) return true;
|
||||||
const pad = global.CONFIG?.worldPadding || 30;
|
const pad = global.CONFIG?.worldPadding || 30;
|
||||||
const r = Math.max(0, num(radius));
|
const r = Math.max(0, num(radius));
|
||||||
return num(x) - r < pad || num(y) - r < 0 || 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) {
|
function orientedAabb(rect) {
|
||||||
if (!rect) return null;
|
if (!rect) return null;
|
||||||
|
|
@ -166,12 +165,10 @@
|
||||||
top = Math.max(top, num(item.y) - num(aabb.top));
|
top = Math.max(top, num(item.y) - num(aabb.top));
|
||||||
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
|
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
|
||||||
}
|
}
|
||||||
const fence = global.isFenceItemType?.(item.type);
|
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 bottomPad = 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, top, (worldRef.h || 0) - bottomPad - bottom) : Math.max(top, Math.min((worldRef.h || 0) - bottomPad - 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));
|
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, radius, (worldRef.h || 0) - pad - radius) : Math.max(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) {
|
function overlayForItem(item) {
|
||||||
if (!item || item.dead) return null;
|
if (!item || item.dead) return null;
|
||||||
|
|
|
||||||
351
js/render.js
351
js/render.js
|
|
@ -1077,37 +1077,52 @@ function visibleWorldRect(worldRef, margin = 160) {
|
||||||
|
|
||||||
function renderRadiusForEntity(entity) {
|
function renderRadiusForEntity(entity) {
|
||||||
if (!entity) return 40;
|
if (!entity) return 40;
|
||||||
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
|
const type = String(entity.type || "");
|
||||||
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);
|
const dynamicRadius = type === "genkotsu" || type === "reciprocator" || type === "rotator" || type === "poison_block";
|
||||||
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 (!dynamicRadius && Number.isFinite(entity._renderCullRadius)) return entity._renderCullRadius;
|
||||||
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
|
let value;
|
||||||
if (entity.type === "pipe") return Math.max(78, (entity.r || 36) * 2.8);
|
if (type === "genkotsu") value = Math.max(420, (entity.r || 88) * 6.2);
|
||||||
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
|
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);
|
||||||
if (entity.kind === "queen") return 42;
|
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)));
|
||||||
if (entity.kind === "worker") return 26;
|
else if (type === "nest_box") value = Math.max(86, (entity.r || 34) * 3.2);
|
||||||
if (entity.radius) return Math.max(56, entity.radius * 2.8);
|
else if (type === "pipe") value = Math.max(78, (entity.r || 36) * 2.8);
|
||||||
if (entity.size) return Math.max(34, entity.size * 2.4);
|
else if (type === "ant_nest") value = Math.max(72, (entity.r || 28) * 3.0);
|
||||||
return Math.max(42, (entity.r || 18) * 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) {
|
function isEntityVisibleInRect(entity, rect, extra = 0) {
|
||||||
if (!entity || entity.dead) return false;
|
if (!entity || entity.dead) return false;
|
||||||
const r = renderRadiusForEntity(entity) + extra;
|
return isPointVisibleInRect(entity.x, entity.y, renderRadiusForEntity(entity) + extra, rect);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCarriedPlushieToOwner(it, worldRef) {
|
function carriedPlushieRenderPose(it, worldRef) {
|
||||||
if (!it || !worldRef || !it.isStructure || it.type !== "plushie" || !it.carriedById) return false;
|
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);
|
const owner = worldRef.liveTarinaiById?.(it.carriedById) || (worldRef.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById);
|
||||||
if (!owner) return false;
|
if (!owner) return null;
|
||||||
const ox = it.x, oy = it.y;
|
return { owner, x: owner.x, y: owner.y - Math.max(22, (owner.radius || 24) * 1.02) };
|
||||||
it.ownerId = it.ownerId || owner.id;
|
}
|
||||||
it.onHead = true;
|
|
||||||
it.x = owner.x;
|
function drawCarriedPlushieAtOwner(it, worldRef, ctx, time, lighting) {
|
||||||
it.y = owner.y - Math.max(22, (owner.radius || 24) * 1.02);
|
const pose = carriedPlushieRenderPose(it, worldRef);
|
||||||
return Math.hypot((it.x || 0) - (ox || 0), (it.y || 0) - (oy || 0)) > 0.25;
|
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) {
|
function resetScratchSet(set) {
|
||||||
|
|
@ -1137,8 +1152,34 @@ function pushRenderLayerEntry(stack, entity) {
|
||||||
return 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 collectVisibleRenderStack(worldRef, visibleRect) {
|
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, creatureSortRect = visibleRect) {
|
||||||
const end = window.TarinaiPerf.begin("render.stackBuild");
|
const end = window.TarinaiPerf.begin("render.stackBuild");
|
||||||
try {
|
try {
|
||||||
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [], champions: [] };
|
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [], ballLodgedPins: [], champions: [] };
|
||||||
|
|
@ -1150,57 +1191,90 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
||||||
if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0;
|
if (stack.ballLodgedPins) stack.ballLodgedPins.length = 0;
|
||||||
if (stack.champions) stack.champions.length = 0;
|
if (stack.champions) stack.champions.length = 0;
|
||||||
const seen = worldRef._renderVisibleSeen = resetScratchSet(worldRef._renderVisibleSeen);
|
const seen = worldRef._renderVisibleSeen = resetScratchSet(worldRef._renderVisibleSeen);
|
||||||
const addItem = (it) => {
|
const staticCache = worldRef._renderStaticVisibleCache || (worldRef._renderStaticVisibleCache = { key: "", backItems: [], layered: [] });
|
||||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) syncCarriedPlushieToOwner(it, worldRef);
|
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;
|
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;
|
if (!visible) return;
|
||||||
seen.add(it);
|
seen.add(it);
|
||||||
if (isPinType(it.type) && it.pinState === "lodged") {
|
if (isPinType(it.type) && it.pinState === "lodged") { stack.lodgedPins.push(it); return; }
|
||||||
stack.lodgedPins.push(it);
|
if (isPinType(it.type) && it.pinState === "ball_lodged") { (stack.ballLodgedPins || (stack.ballLodgedPins = [])).push(it); return; }
|
||||||
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 pushRenderLayerEntry(stack, it);
|
|
||||||
};
|
};
|
||||||
const addLayered = (entity) => {
|
const addDynamicEntity = (entity) => {
|
||||||
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
|
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
|
||||||
seen.add(entity);
|
seen.add(entity);
|
||||||
pushRenderLayerEntry(stack, 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);
|
if (entity.isTarinaiChampion && typeof entity.drawChampionCrown === "function") (stack.champions || (stack.champions = [])).push(entity);
|
||||||
};
|
};
|
||||||
|
|
||||||
worldRef.ensureSpatial?.("render-visible");
|
worldRef.ensureSpatial?.("render-visible");
|
||||||
if (worldRef.spatial?.nearbyRectInto) {
|
if (worldRef.spatial?.nearbyRectInto) {
|
||||||
// Pull only visible spatial buckets, then do a render-radius check for
|
if (staticCache.key !== staticKey) {
|
||||||
// oversized sprites/effects that extend outside their origin cell.
|
staticCache.key = staticKey;
|
||||||
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
|
staticCache.backItems.length = 0;
|
||||||
itemScratch.length = 0;
|
staticCache.layered.length = 0;
|
||||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
|
const staticScratch = worldRef._renderVisibleStaticScratch || (worldRef._renderVisibleStaticScratch = []);
|
||||||
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
|
const cacheRect = { left: visibleRect.left - 52, top: visibleRect.top - 52, right: visibleRect.right + 52, bottom: visibleRect.bottom + 52 };
|
||||||
for (const it of itemScratch) addItem(it);
|
staticScratch.length = 0;
|
||||||
// Link-like items can cross the viewport while their midpoint lives in an
|
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, cacheRect, staticScratch);
|
||||||
// off-screen spatial cell. Pull them from their type buckets as a small,
|
const oldVisibleRect = visibleRect;
|
||||||
// deterministic fallback so a newly connected or very long link never
|
visibleRect = cacheRect;
|
||||||
// disappears merely because its cached origin is outside the query rect.
|
for (const it of staticScratch) addItemTo(it, staticCache.backItems, staticCache.layered);
|
||||||
for (const type of LINK_RENDER_TYPES) {
|
visibleRect = oldVisibleRect;
|
||||||
for (const it of worldRef.itemsOfType?.(type) || []) addItem(it);
|
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 = []);
|
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
|
||||||
tarinaiScratch.length = 0;
|
tarinaiScratch.length = 0;
|
||||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
|
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect, tarinaiScratch);
|
||||||
for (const t of tarinaiScratch) addLayered(t);
|
for (const t of tarinaiScratch) {
|
||||||
|
if (!t || t.dead || seen.has(t) || !isEntityVisibleInRect(t, creatureSortRect)) continue;
|
||||||
|
addDynamicEntity(t);
|
||||||
|
}
|
||||||
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
|
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
|
||||||
antScratch.length = 0;
|
antScratch.length = 0;
|
||||||
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
|
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, creatureSortRect, antScratch);
|
||||||
for (const a of antScratch) addLayered(a);
|
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)) {
|
if (!Array.isArray(worldRef.carriedPlushies) || (worldRef.time || 0) >= (worldRef._nextCarriedPlushieRefreshAt || 0)) {
|
||||||
const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []);
|
const plushies = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("plushie") : (worldRef.items || []);
|
||||||
worldRef.carriedPlushies = worldRef.carriedPlushies || [];
|
worldRef.carriedPlushies = worldRef.carriedPlushies || [];
|
||||||
|
|
@ -1208,17 +1282,20 @@ function collectVisibleRenderStack(worldRef, visibleRect) {
|
||||||
for (const it of plushies || []) if (it && !it.dead && it.isStructure && it.type === "plushie" && it.carriedById) worldRef.carriedPlushies.push(it);
|
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;
|
worldRef._nextCarriedPlushieRefreshAt = (worldRef.time || 0) + 0.45;
|
||||||
}
|
}
|
||||||
const plushieCandidates = worldRef.carriedPlushies;
|
for (const it of worldRef.carriedPlushies || []) addItemTo(it, dynamicBack, dynamicLayered);
|
||||||
for (const it of plushieCandidates || []) {
|
|
||||||
if (it && it.isStructure && it.type === "plushie" && it.carriedById) addItem(it);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for (const it of worldRef.items || []) addItem(it);
|
for (const it of worldRef.items || []) addItemTo(it, dynamicBack, dynamicLayered);
|
||||||
for (const t of worldRef.tarinai || []) addLayered(t);
|
for (const t of worldRef.tarinai || []) if (isEntityVisibleInRect(t, creatureSortRect)) addDynamicEntity(t);
|
||||||
for (const a of worldRef.ants || []) addLayered(a);
|
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;
|
return stack;
|
||||||
} finally {
|
} finally {
|
||||||
if (end) end();
|
if (end) end();
|
||||||
|
|
@ -1560,35 +1637,60 @@ function drawLightRays(ctx, w, h, lighting) {
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAtmosphericLighting(ctx, w, h, lighting) {
|
function redrawAtmosphericOverlay(w, h, lighting, tick) {
|
||||||
ctx.save();
|
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) {
|
if (lighting.nightStrength > 0) {
|
||||||
ctx.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
|
c.globalAlpha = lighting.nightStrength * LIGHTING_TUNING.nightOverlayAlpha;
|
||||||
ctx.fillStyle = "#223683";
|
c.fillStyle = "#223683";
|
||||||
ctx.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, cw, ch);
|
||||||
}
|
}
|
||||||
if (lighting.goldenStrength > 0) {
|
if (lighting.goldenStrength > 0) {
|
||||||
ctx.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
|
c.globalAlpha = lighting.goldenStrength * LIGHTING_TUNING.goldenOverlayAlpha;
|
||||||
ctx.fillStyle = "#ffad68";
|
c.fillStyle = "#ffad68";
|
||||||
ctx.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, cw, ch);
|
||||||
}
|
}
|
||||||
if (lighting.noonStrength > 0) {
|
if (lighting.noonStrength > 0) {
|
||||||
ctx.globalAlpha = lighting.noonStrength * 0.035;
|
c.globalAlpha = lighting.noonStrength * 0.035;
|
||||||
ctx.fillStyle = "#fff8cf";
|
c.fillStyle = "#fff8cf";
|
||||||
ctx.fillRect(0, 0, w, h);
|
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();
|
c.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);
|
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, "rgba(0,0,0,0)");
|
||||||
vignette.addColorStop(0.72, "rgba(0,0,0,0)");
|
vignette.addColorStop(0.72, "rgba(0,0,0,0)");
|
||||||
vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
|
vignette.addColorStop(1, `rgba(13, 17, 34, ${lighting.vignetteAlpha})`);
|
||||||
ctx.fillStyle = vignette;
|
c.fillStyle = vignette;
|
||||||
ctx.fillRect(0, 0, w, h);
|
c.fillRect(0, 0, cw, ch);
|
||||||
ctx.restore();
|
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 = {}) {
|
function drawRain(ctx, w, h, t, opts = {}) {
|
||||||
|
|
@ -1650,20 +1752,37 @@ function visualEffectLimit(level) {
|
||||||
return Math.max(0, Number(TARINAI_RENDER_GLOBAL.TarinaiPerf?.performanceProfile?.().effectDrawBudget || 96));
|
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) {
|
function fillScreenFallback(ctx, w, h, lighting) {
|
||||||
const light = lighting?.light ?? 0.7;
|
const light = lighting?.light ?? 0.7;
|
||||||
ctx.save();
|
const key = `${Math.round(w)}x${Math.round(h)}:${light > 0.42 ? "day" : "night"}`;
|
||||||
const bg = ctx.createLinearGradient(0, 0, 0, Math.max(1, h));
|
if (!screenFallbackCache.canvas) {
|
||||||
if (light > 0.42) {
|
screenFallbackCache.canvas = document.createElement("canvas");
|
||||||
bg.addColorStop(0, "#cfe3d3");
|
screenFallbackCache.ctx = screenFallbackCache.canvas.getContext("2d");
|
||||||
bg.addColorStop(1, "#d9d0ad");
|
|
||||||
} else {
|
|
||||||
bg.addColorStop(0, "#24355f");
|
|
||||||
bg.addColorStop(1, "#4f5d71");
|
|
||||||
}
|
}
|
||||||
ctx.fillStyle = bg;
|
if (screenFallbackCache.key !== key) {
|
||||||
ctx.fillRect(0, 0, w, h);
|
const cw = Math.max(1, Math.round(w));
|
||||||
ctx.restore();
|
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() {
|
function render() {
|
||||||
|
|
@ -1680,7 +1799,12 @@ function render() {
|
||||||
|
|
||||||
const endSetup = renderPerfBegin("render.setup");
|
const endSetup = renderPerfBegin("render.setup");
|
||||||
const lighting = getLightingState(world);
|
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 light = lighting.light;
|
||||||
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
|
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
|
||||||
renderPerfEnd(endSetup);
|
renderPerfEnd(endSetup);
|
||||||
|
|
@ -1707,6 +1831,10 @@ function render() {
|
||||||
renderPerfEnd(endBackground);
|
renderPerfEnd(endBackground);
|
||||||
|
|
||||||
const visibleRect = visibleWorldRect(world, 180);
|
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();
|
beginFieldTransform();
|
||||||
const endPreviews = renderPerfBegin("render.previews");
|
const endPreviews = renderPerfBegin("render.previews");
|
||||||
|
|
@ -1716,7 +1844,7 @@ function render() {
|
||||||
drawTerrainLayer(ctx, world, lighting, visibleRect);
|
drawTerrainLayer(ctx, world, lighting, visibleRect);
|
||||||
|
|
||||||
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
|
// 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.visibleBackItems", renderStack.backItems.length);
|
||||||
window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length);
|
window.TarinaiPerf.setMetric("render.visibleLayered", renderStack.layered.length);
|
||||||
const endBackItems = renderPerfBegin("render.draw.backItems");
|
const endBackItems = renderPerfBegin("render.draw.backItems");
|
||||||
|
|
@ -1774,8 +1902,7 @@ function render() {
|
||||||
// A carried plushie shares its owner's depth position instead of being
|
// A carried plushie shares its owner's depth position instead of being
|
||||||
// rendered above every entity as a global attachment layer.
|
// rendered above every entity as a global attachment layer.
|
||||||
for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) {
|
for (const it of carriedPlushiesByOwner.get(entry.entity.id) || []) {
|
||||||
syncCarriedPlushieToOwner(it, world);
|
drawCarriedPlushieAtOwner(it, world, ctx, world.time, lighting);
|
||||||
it.draw(ctx, world.time, lighting);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderPerfEnd(endLayered);
|
renderPerfEnd(endLayered);
|
||||||
|
|
@ -1820,17 +1947,15 @@ function render() {
|
||||||
}
|
}
|
||||||
if (totalEffects > 180) world._renderEffectCursor = cursor;
|
if (totalEffects > 180) world._renderEffectCursor = cursor;
|
||||||
}
|
}
|
||||||
world._effectRenderStats = {
|
if (window.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
total: totalEffects,
|
world._effectRenderStats = {
|
||||||
drawn: drawnEffects,
|
total: totalEffects, drawn: drawnEffects, skipped: Math.max(0, totalEffects - drawnEffects),
|
||||||
skipped: Math.max(0, totalEffects - drawnEffects),
|
budget: maxEffects, scanCap: effectScanCap, level: effectLevel,
|
||||||
budget: maxEffects,
|
};
|
||||||
scanCap: effectScanCap,
|
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
|
||||||
level: effectLevel,
|
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
|
||||||
};
|
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
|
||||||
window.TarinaiPerf.setMetric("render.visibleEffects", drawnEffects);
|
} else world._effectRenderStats = null;
|
||||||
window.TarinaiPerf.setMetric("render.effectDrawBudget", maxEffects);
|
|
||||||
window.TarinaiPerf.setMetric("render.effectDrawSkipped", Math.max(0, totalEffects - drawnEffects));
|
|
||||||
renderPerfEnd(endEffects);
|
renderPerfEnd(endEffects);
|
||||||
drawPressureSwitchRangeOverlay(ctx, world);
|
drawPressureSwitchRangeOverlay(ctx, world);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
|
||||||
|
|
@ -602,7 +602,8 @@
|
||||||
|
|
||||||
function writeWorld(writer, w = []) {
|
function writeWorld(writer, w = []) {
|
||||||
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]);
|
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;
|
let mask = 0;
|
||||||
if (valuesDiffer(w[5], -9990)) mask |= 1;
|
if (valuesDiffer(w[5], -9990)) mask |= 1;
|
||||||
if (valuesDiffer(w[6], 1)) mask |= 2;
|
if (valuesDiffer(w[6], 1)) mask |= 2;
|
||||||
|
|
@ -610,7 +611,8 @@
|
||||||
if (valuesDiffer(w[9], 0)) mask |= 8;
|
if (valuesDiffer(w[9], 0)) mask |= 8;
|
||||||
if (valuesDiffer(w[10], 0)) mask |= 16;
|
if (valuesDiffer(w[10], 0)) mask |= 16;
|
||||||
if (valuesDiffer(w[11], 0)) mask |= 32;
|
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 & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative
|
||||||
if (mask & 2) writer.u(w[6] || 1); // generation
|
if (mask & 2) writer.u(w[6] || 1); // generation
|
||||||
if (mask & 4) writer.u(w[7] || 0); // dead count
|
if (mask & 4) writer.u(w[7] || 0); // dead count
|
||||||
|
|
@ -620,11 +622,13 @@
|
||||||
if (mask & 8) writer.u(w[9] || 0); // birthSerial
|
if (mask & 8) writer.u(w[9] || 0); // birthSerial
|
||||||
if (mask & 16) writer.u(w[10] || 0); // tarinai population limit
|
if (mask & 16) writer.u(w[10] || 0); // tarinai population limit
|
||||||
if (mask & 32) writer.u(w[11] || 0); // object 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) {
|
function readWorld(reader) {
|
||||||
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
|
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
|
||||||
const tick = reader.u();
|
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 lastBirthAt = (mask & 1) ? reader.s() : -9990;
|
||||||
const generation = (mask & 2) ? reader.u() : 1;
|
const generation = (mask & 2) ? reader.u() : 1;
|
||||||
const deadCount = (mask & 4) ? reader.u() : 0;
|
const deadCount = (mask & 4) ? reader.u() : 0;
|
||||||
|
|
@ -632,7 +636,12 @@
|
||||||
const birthSerial = (mask & 8) ? reader.u() : 0;
|
const birthSerial = (mask & 8) ? reader.u() : 0;
|
||||||
const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0;
|
const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0;
|
||||||
const objectLimit = (mask & 32) ? 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
|
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
|
||||||
|
|
@ -1465,10 +1474,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
|
const ACHIEVEMENT_METADATA_BINARY_VERSION = 1;
|
||||||
const ACHIEVEMENT_SPELL_BINARY_VERSION = 5;
|
const ACHIEVEMENT_SPELL_BINARY_VERSION = 8;
|
||||||
const ACHIEVEMENT_MASK_BYTES = 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]);
|
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]);
|
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) {
|
function finiteNumberOrNull(value) {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
|
|
@ -1493,14 +1502,18 @@
|
||||||
function achievementMaskBytesFromSpell(spell = null) {
|
function achievementMaskBytesFromSpell(spell = null) {
|
||||||
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
|
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
|
||||||
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
|
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [] };
|
||||||
if (Number(spell[0]) !== ACHIEVEMENT_SPELL_BINARY_VERSION) throw new Error("unsupported achievement spell version");
|
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 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 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] = Math.floor(low / (2 ** (i * 8))) & 255;
|
||||||
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
|
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
|
||||||
const baseMinute = toSafeUInt(spell[3]);
|
bytes[8] = extra & 255;
|
||||||
const deltas = Array.isArray(spell[4]) ? spell[4].map(toSafeUInt) : [];
|
bytes[9] = (extra >>> 8) & 255;
|
||||||
const progress = Array.isArray(spell[5]) ? spell[5] : [];
|
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;
|
let unlockedCount = 0;
|
||||||
for (const byte of bytes) {
|
for (const byte of bytes) {
|
||||||
let value = byte;
|
let value = byte;
|
||||||
|
|
@ -1515,7 +1528,7 @@
|
||||||
let high = 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++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
|
||||||
for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 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, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
|
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) {
|
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]))));
|
const normalized = maxima.map((max, index) => Math.max(0, Math.min(max, toSafeUInt(values?.[index]))));
|
||||||
|
|
@ -1559,7 +1572,7 @@
|
||||||
const baseMinute = reader.u();
|
const baseMinute = reader.u();
|
||||||
const deltas = [];
|
const deltas = [];
|
||||||
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
|
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
|
||||||
const progress = readAchievementProgress(reader);
|
const progress = readAchievementProgress(reader, ACHIEVEMENT_PROGRESS_BITS, ACHIEVEMENT_PROGRESS_MAX);
|
||||||
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
|
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress);
|
||||||
}
|
}
|
||||||
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
|
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
|
||||||
|
|
@ -1630,7 +1643,7 @@
|
||||||
|
|
||||||
if (Array.isArray(value.s)) writeAchievementSpell(writer, value.s);
|
if (Array.isArray(value.s)) writeAchievementSpell(writer, value.s);
|
||||||
}
|
}
|
||||||
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0, schema = BINARY_SCHEMA_VERSION) {
|
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0) {
|
||||||
const version = reader.b();
|
const version = reader.b();
|
||||||
if (version !== ACHIEVEMENT_METADATA_BINARY_VERSION) throw new Error("unsupported achievement metadata schema");
|
if (version !== ACHIEVEMENT_METADATA_BINARY_VERSION) throw new Error("unsupported achievement metadata schema");
|
||||||
const [flags] = readBitFields(reader, [1]);
|
const [flags] = readBitFields(reader, [1]);
|
||||||
|
|
@ -1678,7 +1691,7 @@
|
||||||
i[index] = [placed, placedAt];
|
i[index] = [placed, placedAt];
|
||||||
});
|
});
|
||||||
const metadata = { w, t, i };
|
const metadata = { w, t, i };
|
||||||
if (flags & 1) metadata.s = readAchievementSpell(reader, schema);
|
if (flags & 1) metadata.s = readAchievementSpell(reader);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1686,9 +1699,12 @@
|
||||||
function summaryFromPayload(w = [], tarinaiCount = 0) {
|
function summaryFromPayload(w = [], tarinaiCount = 0) {
|
||||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
||||||
const totalTime = (Number(w[3]) || 0) / 10;
|
const totalTime = Math.max(0, (Number(w[3]) || 0) / 10);
|
||||||
const day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
const explicitDays = Number(w[18]);
|
||||||
return [day, tarinaiCount, fieldValue(w[0])];
|
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) {
|
function encodeBinarySnapshot(snapshot, stats = null) {
|
||||||
|
|
@ -1738,7 +1754,7 @@
|
||||||
const items = readItemBlocks(reader);
|
const items = readItemBlocks(reader);
|
||||||
let g = null;
|
let g = null;
|
||||||
try {
|
try {
|
||||||
g = readAchievementMetadata(reader, tCount, items.length, schema);
|
g = readAchievementMetadata(reader, tCount, items.length);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error("invalid achievement metadata in binary save");
|
throw new Error("invalid achievement metadata in binary save");
|
||||||
}
|
}
|
||||||
|
|
@ -1826,6 +1842,8 @@
|
||||||
global.TarinaiSaveCodec = {
|
global.TarinaiSaveCodec = {
|
||||||
encodeSnapshot,
|
encodeSnapshot,
|
||||||
decodeSnapshot,
|
decodeSnapshot,
|
||||||
|
encodeBinarySnapshot,
|
||||||
|
decodeBinarySnapshot,
|
||||||
getLastEncodeStats,
|
getLastEncodeStats,
|
||||||
getLastDecodeStats,
|
getLastDecodeStats,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
|
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
|
||||||
|
|
||||||
const SNAPSHOT_VERSION = 40;
|
const SNAPSHOT_VERSION = 40;
|
||||||
const BINARY_SCHEMA_VERSION = 49;
|
const BINARY_SCHEMA_VERSION = 53;
|
||||||
|
|
||||||
function moodValue(index, fallback = "relaxed") {
|
function moodValue(index, fallback = "relaxed") {
|
||||||
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
}
|
}
|
||||||
function detectorItems(item, worldRef, width, height, type = "") {
|
function detectorItems(item, worldRef, width, height, type = "") {
|
||||||
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
|
||||||
return (worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [])
|
return (worldRef.nearbyNonGrassItems?.(item.x, item.y, radius, true) || worldRef.items || [])
|
||||||
.filter(it => it && !it.dead && it !== item && !LINK_TYPES.has(it.type)
|
.filter(it => it && !it.dead && it !== item && !LINK_TYPES.has(it.type)
|
||||||
&& (!type || it.type === type) && insideRect(item, it, width, height));
|
&& (!type || it.type === type) && insideRect(item, it, width, height));
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +345,7 @@
|
||||||
if (now < num(wire._shockCooldowns.get(target), -Infinity)) return false;
|
if (now < num(wire._shockCooldowns.get(target), -Infinity)) return false;
|
||||||
wire._shockCooldowns.set(target, now + 0.38);
|
wire._shockCooldowns.set(target, now + 0.38);
|
||||||
const isAnt = target.kind === "worker" || target.kind === "queen" || (target.r && !target.radius && Number.isFinite(Number(target.hp)));
|
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) {
|
if (isAnt) {
|
||||||
globalThis.TarinaiAchievements?.recordAntDamageSource?.(target, wire, { world: worldRef, reason: "electric_wire" });
|
globalThis.TarinaiAchievements?.recordAntDamageSource?.(target, wire, { world: worldRef, reason: "electric_wire" });
|
||||||
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
|
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
|
||||||
|
|
@ -366,7 +367,6 @@
|
||||||
life: 0.28,
|
life: 0.28,
|
||||||
color: "rgba(118,224,255,0.96)",
|
color: "rgba(118,224,255,0.96)",
|
||||||
});
|
});
|
||||||
global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
110
js/sim_core.js
110
js/sim_core.js
|
|
@ -404,6 +404,10 @@ function relationDisplayName(worldRef, id) {
|
||||||
|
|
||||||
class Effect {
|
class Effect {
|
||||||
constructor(type, x, y, options = {}) {
|
constructor(type, x, y, options = {}) {
|
||||||
|
this.reset(type, x, y, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(type, x, y, options = {}) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.x = x;
|
this.x = x;
|
||||||
this.y = y;
|
this.y = y;
|
||||||
|
|
@ -415,6 +419,9 @@ class Effect {
|
||||||
this.color = options.color || "#fff4a8";
|
this.color = options.color || "#fff4a8";
|
||||||
this.seed = Math.random() * 1000;
|
this.seed = Math.random() * 1000;
|
||||||
this.text = options.text || "";
|
this.text = options.text || "";
|
||||||
|
this._effectPooled = false;
|
||||||
|
this._effectUpdatedFrame = 0;
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(dt) {
|
update(dt) {
|
||||||
|
|
@ -667,7 +674,18 @@ class SpatialGrid {
|
||||||
this.itemCells = new Map();
|
this.itemCells = new Map();
|
||||||
this.staticItemCells = new Map();
|
this.staticItemCells = new Map();
|
||||||
this.dynamicItemCells = 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.tarinaiCells = new Map();
|
||||||
|
this._indexedTarinai = new Set();
|
||||||
|
this._tarinaiSyncStamp = 0;
|
||||||
this.antCells = new Map();
|
this.antCells = new Map();
|
||||||
this.obstacleCells = new Map();
|
this.obstacleCells = new Map();
|
||||||
this.foodCells = new Map();
|
this.foodCells = new Map();
|
||||||
|
|
@ -690,6 +708,10 @@ class SpatialGrid {
|
||||||
this.itemCells.clear();
|
this.itemCells.clear();
|
||||||
this.staticItemCells.clear();
|
this.staticItemCells.clear();
|
||||||
this.dynamicItemCells.clear();
|
this.dynamicItemCells.clear();
|
||||||
|
this.staticNonGrassItemCells.clear();
|
||||||
|
this.dynamicNonGrassItemCells.clear();
|
||||||
|
this.grassCells.clear();
|
||||||
|
this.linkRenderCells.clear();
|
||||||
this.obstacleCells.clear();
|
this.obstacleCells.clear();
|
||||||
this.foodCells.clear();
|
this.foodCells.clear();
|
||||||
this.hazardCells.clear();
|
this.hazardCells.clear();
|
||||||
|
|
@ -703,9 +725,64 @@ class SpatialGrid {
|
||||||
|
|
||||||
|
|
||||||
clearTarinai() {
|
clearTarinai() {
|
||||||
|
for (const t of this._indexedTarinai || []) {
|
||||||
|
if (t) { delete t._spatialTarinaiCellKey; delete t._spatialTarinaiSyncStamp; }
|
||||||
|
}
|
||||||
|
this._indexedTarinai?.clear?.();
|
||||||
this.tarinaiCells.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() {
|
clearAnts() {
|
||||||
this.antCells.clear();
|
this.antCells.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -825,12 +902,22 @@ class SpatialGrid {
|
||||||
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
|
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) {
|
classifyItem(it) {
|
||||||
if (!it || it.dead) return;
|
if (!it || it.dead) return;
|
||||||
const dynamic = this.isDynamicItem(it);
|
const dynamic = this.isDynamicItem(it);
|
||||||
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
|
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
|
||||||
this.add(target, it);
|
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.add(this.itemCells, it);
|
||||||
|
this.addLinkRenderItem(it);
|
||||||
this.addItemToTraitCells(it, dynamic);
|
this.addItemToTraitCells(it, dynamic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -842,25 +929,36 @@ class SpatialGrid {
|
||||||
|
|
||||||
rebuildStaticItems(items, opts = {}) {
|
rebuildStaticItems(items, opts = {}) {
|
||||||
this.staticItemCells.clear();
|
this.staticItemCells.clear();
|
||||||
|
this.staticNonGrassItemCells.clear();
|
||||||
|
this.grassCells.clear();
|
||||||
this.staticObstacleCells.clear();
|
this.staticObstacleCells.clear();
|
||||||
this.staticFoodCells.clear();
|
this.staticFoodCells.clear();
|
||||||
this.staticHazardCells.clear();
|
this.staticHazardCells.clear();
|
||||||
for (const it of items || []) {
|
for (const it of items || []) {
|
||||||
if (!it || it.dead || this.isDynamicItem(it)) continue;
|
if (!it || it.dead || this.isDynamicItem(it)) continue;
|
||||||
this.add(this.staticItemCells, it);
|
this.add(this.staticItemCells, it);
|
||||||
|
if (it.type === "grass") this.add(this.grassCells, it);
|
||||||
|
else this.add(this.staticNonGrassItemCells, it);
|
||||||
this.addItemToTraitCells(it, false);
|
this.addItemToTraitCells(it, false);
|
||||||
}
|
}
|
||||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rebuildLinkRenderItems(items) {
|
||||||
|
this.linkRenderCells.clear();
|
||||||
|
for (const it of items || []) this.addLinkRenderItem(it);
|
||||||
|
}
|
||||||
|
|
||||||
rebuildDynamicItems(items, opts = {}) {
|
rebuildDynamicItems(items, opts = {}) {
|
||||||
this.dynamicItemCells.clear();
|
this.dynamicItemCells.clear();
|
||||||
|
this.dynamicNonGrassItemCells.clear();
|
||||||
this.dynamicObstacleCells.clear();
|
this.dynamicObstacleCells.clear();
|
||||||
this.dynamicFoodCells.clear();
|
this.dynamicFoodCells.clear();
|
||||||
this.dynamicHazardCells.clear();
|
this.dynamicHazardCells.clear();
|
||||||
for (const it of items || []) {
|
for (const it of items || []) {
|
||||||
if (!it || it.dead || !this.isDynamicItem(it)) continue;
|
if (!it || it.dead || !this.isDynamicItem(it)) continue;
|
||||||
this.add(this.dynamicItemCells, it);
|
this.add(this.dynamicItemCells, it);
|
||||||
|
if (it.type !== "grass") this.add(this.dynamicNonGrassItemCells, it);
|
||||||
this.addItemToTraitCells(it, true);
|
this.addItemToTraitCells(it, true);
|
||||||
}
|
}
|
||||||
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
|
||||||
|
|
@ -893,8 +991,16 @@ class SpatialGrid {
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildTarinai(tarinai) {
|
rebuildTarinai(tarinai) {
|
||||||
this.tarinaiCells.clear();
|
this.clearTarinai();
|
||||||
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
|
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 = []) {
|
rebuildAnts(ants = []) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
const updatePolicy = global.TarinaiCreatureUpdatePolicy;
|
const updatePolicy = global.TarinaiCreatureUpdatePolicy;
|
||||||
|
|
||||||
function inc(map, key, n = 1) {
|
function inc(map, key, n = 1) {
|
||||||
|
if (!map) return;
|
||||||
const k = String(key || "none");
|
const k = String(key || "none");
|
||||||
map[k] = (map[k] || 0) + n;
|
map[k] = (map[k] || 0) + n;
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +106,7 @@
|
||||||
const pressureActive = Boolean(pressure?.active);
|
const pressureActive = Boolean(pressure?.active);
|
||||||
const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000;
|
const visibleFullBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureFullBudget", 24)) : 1000000000;
|
||||||
const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000;
|
const visibleMotionBudget = pressureActive ? Math.max(1, profileNumber("pressureCreatureMotionBudget", 36)) : 1000000000;
|
||||||
|
const collectDiagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true;
|
||||||
const stats = {
|
const stats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
full: 0,
|
full: 0,
|
||||||
|
|
@ -116,10 +118,10 @@
|
||||||
visible: 0,
|
visible: 0,
|
||||||
near: 0,
|
near: 0,
|
||||||
far: 0,
|
far: 0,
|
||||||
lanes: {},
|
lanes: collectDiagnostics ? {} : null,
|
||||||
states: {},
|
states: collectDiagnostics ? {} : null,
|
||||||
smoothStates: {},
|
smoothStates: collectDiagnostics ? {} : null,
|
||||||
skippedStates: {},
|
skippedStates: collectDiagnostics ? {} : null,
|
||||||
sleepPhysical: 0,
|
sleepPhysical: 0,
|
||||||
passivePhysical: 0,
|
passivePhysical: 0,
|
||||||
fullBudget: backgroundFullBudgetFor(worldRef),
|
fullBudget: backgroundFullBudgetFor(worldRef),
|
||||||
|
|
@ -147,7 +149,25 @@
|
||||||
// after all visible bodies have been advanced.
|
// after all visible bodies have been advanced.
|
||||||
worldRef.deferSpatialRebuildMode = "mobile";
|
worldRef.deferSpatialRebuildMode = "mobile";
|
||||||
try {
|
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;
|
if (!t || t.dead) continue;
|
||||||
stats.total += 1;
|
stats.total += 1;
|
||||||
const behaviorId = updatePolicy.behaviorId(t);
|
const behaviorId = updatePolicy.behaviorId(t);
|
||||||
|
|
@ -229,7 +249,7 @@
|
||||||
stats.mechanicalDetailedContacts = pressure?.detailedContacts || 0;
|
stats.mechanicalDetailedContacts = pressure?.detailedContacts || 0;
|
||||||
stats.mechanicalDegradedContacts = pressure?.degradedContacts || 0;
|
stats.mechanicalDegradedContacts = pressure?.degradedContacts || 0;
|
||||||
stats.mechanicalSkippedContacts = pressure?.skippedContacts || 0;
|
stats.mechanicalSkippedContacts = pressure?.skippedContacts || 0;
|
||||||
worldRef._creatureUpdateStats = stats;
|
worldRef._creatureUpdateStats = collectDiagnostics ? stats : null;
|
||||||
if (end) end();
|
if (end) end();
|
||||||
}
|
}
|
||||||
const bedConflictInterval = 3.2;
|
const bedConflictInterval = 3.2;
|
||||||
|
|
@ -241,12 +261,19 @@
|
||||||
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
|
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
|
||||||
}
|
}
|
||||||
const endSpatial = global.TarinaiPerf.begin("update.tarinai.spatial") || null;
|
const endSpatial = global.TarinaiPerf.begin("update.tarinai.spatial") || null;
|
||||||
const tarinaiMoved = h.markSpatialDirtyIfMoved(worldRef, worldRef.tarinai, "tarinai-moved", 0.25);
|
// Maintain the Tarinai grid incrementally. The same O(N) pass that used to
|
||||||
if (tarinaiMoved || worldRef.spatialDirty) {
|
// 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");
|
if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-tarinai-update");
|
||||||
else worldRef.ensureSpatial?.("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();
|
if (endSpatial) endSpatial();
|
||||||
const endBallInteractions = global.TarinaiPerf.begin("update.ballInteractions") || null;
|
const endBallInteractions = global.TarinaiPerf.begin("update.ballInteractions") || null;
|
||||||
worldRef.limitBallChasers();
|
worldRef.limitBallChasers();
|
||||||
|
|
|
||||||
|
|
@ -17,28 +17,34 @@
|
||||||
for (let i = 0; i < effects.length; i += 1) {
|
for (let i = 0; i < effects.length; i += 1) {
|
||||||
const effect = effects[i];
|
const effect = effects[i];
|
||||||
if (effect && !effect.dead) effects[write++] = effect;
|
if (effect && !effect.dead) effects[write++] = effect;
|
||||||
|
else if (effect) worldRef.releaseEffect?.(effect);
|
||||||
}
|
}
|
||||||
effects.length = write;
|
effects.length = write;
|
||||||
const compacted = before - write;
|
const compacted = before - write;
|
||||||
const cap = Math.max(1, Number(global.TarinaiPerf?.performanceProfile?.().effectCap || 220));
|
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
|
const dropMask = new Uint8Array(effects.length);
|
||||||
.map((effect, index) => ({
|
for (let priority = 1; priority <= 3 && needDrop > 0; priority += 1) {
|
||||||
effect,
|
for (let i = 0; i < effects.length && needDrop > 0; i += 1) {
|
||||||
index,
|
if (dropMask[i] || effectPriority(effects[i]) !== priority) continue;
|
||||||
score: effectPriority(effect) * 10 + Math.max(0, Math.min(1, Number(effect.life || 0) / Math.max(0.001, Number(effect.maxLife || effect.life || 1)))),
|
dropMask[i] = 1;
|
||||||
}))
|
needDrop -= 1;
|
||||||
.sort((a, b) => a.score - b.score)
|
}
|
||||||
.slice(0, effects.length - cap);
|
}
|
||||||
const dropSet = new Set(drop.map(entry => entry.index));
|
|
||||||
write = 0;
|
write = 0;
|
||||||
|
let dropped = 0;
|
||||||
for (let i = 0; i < effects.length; i += 1) {
|
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;
|
effects.length = write;
|
||||||
worldRef._effectPressureDroppedTotal = (worldRef._effectPressureDroppedTotal || 0) + dropSet.size;
|
worldRef._effectPressureDroppedTotal = (worldRef._effectPressureDroppedTotal || 0) + dropped;
|
||||||
return { compacted, dropped: dropSet.size };
|
return { compacted, dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectUpdateBudgetFor(total) {
|
function effectUpdateBudgetFor(total) {
|
||||||
|
|
@ -61,37 +67,54 @@
|
||||||
const total = effects.length;
|
const total = effects.length;
|
||||||
const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
|
const frameId = (worldRef._effectUpdateFrameId || 0) + 1;
|
||||||
worldRef._effectUpdateFrameId = frameId;
|
worldRef._effectUpdateFrameId = frameId;
|
||||||
const budget = effectUpdateBudgetFor(total);
|
|
||||||
if (budget >= total) {
|
let lightweight = 0;
|
||||||
for (const ef of effects) updateEffect(ef, dt, frameId, 1);
|
const budgeted = [];
|
||||||
worldRef._effectUpdateStats = { total, beforePressure, updated: total, skipped: 0, budget: total, compacted: pressure.compacted, dropped: pressure.dropped, batched: false };
|
for (const effect of effects) {
|
||||||
return;
|
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 updated = 0;
|
||||||
let scanned = 0;
|
let runScale = 1;
|
||||||
while (updated < budget && scanned < total) {
|
if (budget >= active) {
|
||||||
const ef = effects[cursor];
|
for (const ef of budgeted) {
|
||||||
cursor = (cursor + 1) % total;
|
if (updateEffect(ef, dt, frameId, 1)) updated += 1;
|
||||||
scanned += 1;
|
}
|
||||||
if (!ef || ef.dead) continue;
|
} else if (active > 0) {
|
||||||
updateEffect(ef, dt, frameId, runScale);
|
runScale = Math.min(3.0, Math.max(1, active / Math.max(1, budget)));
|
||||||
updated += 1;
|
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 = {
|
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
total,
|
const spawn = worldRef._effectSpawnStats || {};
|
||||||
beforePressure,
|
worldRef._effectUpdateStats = {
|
||||||
updated,
|
total, beforePressure, updated: updated + lightweight, lightweight,
|
||||||
skipped: Math.max(0, total - updated),
|
skipped: Math.max(0, active - updated), budget,
|
||||||
budget,
|
runScale: Number(runScale.toFixed(2)), compacted: pressure.compacted, dropped: pressure.dropped,
|
||||||
runScale: Number(runScale.toFixed(2)),
|
droppedTotal: worldRef._effectPressureDroppedTotal || 0, batched: budget < active,
|
||||||
compacted: pressure.compacted,
|
spawnRequested: spawn.requested || 0, offscreenCulled: spawn.offscreenCulled || 0,
|
||||||
dropped: pressure.dropped,
|
poolEligible: spawn.poolEligible || 0, pooled: spawn.pooled || 0,
|
||||||
droppedTotal: worldRef._effectPressureDroppedTotal || 0,
|
};
|
||||||
batched: true,
|
} else worldRef._effectUpdateStats = null;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
global.TarinaiEffectsSimulationSystem = Object.freeze({
|
global.TarinaiEffectsSimulationSystem = Object.freeze({
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@
|
||||||
function updateClock(worldRef, dt) {
|
function updateClock(worldRef, dt) {
|
||||||
const previousDay = worldRef.day || 1;
|
const previousDay = worldRef.day || 1;
|
||||||
worldRef.time += dt;
|
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) {
|
if (worldRef.day !== previousDay) {
|
||||||
for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} };
|
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) {
|
function prepareSpatialFrame(worldRef) {
|
||||||
worldRef.spatialRebuildsThisFrame = 0;
|
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
worldRef.spatialDirtyMarksThisFrame = 0;
|
worldRef.spatialRebuildsThisFrame = 0;
|
||||||
|
worldRef.spatialDirtyMarksThisFrame = 0;
|
||||||
|
}
|
||||||
worldRef.ensureSpatial?.("update-start");
|
worldRef.ensureSpatial?.("update-start");
|
||||||
worldRef.beginFramePerformanceBudgets?.();
|
worldRef.beginFramePerformanceBudgets?.();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,75 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Layer: simulation/system-maintenance
|
// Layer: simulation/system-maintenance
|
||||||
// Owns periodic compaction, counts refresh, grass limit enforcement,
|
// Owns low-frequency cleanup and cache maintenance. Heavy sweeps are deliberately
|
||||||
// out-of-bounds cleanup, and draw-list invalidation.
|
// staggered so they do not all land on the same animation frame.
|
||||||
(function (global) {
|
(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) {
|
function runMaintenance(worldRef, dt, itemCountBefore) {
|
||||||
global.TarinaiStructureLifecycle?.maintainDependencies?.(worldRef);
|
initializeSchedule(worldRef);
|
||||||
worldRef.compactTimer += dt;
|
const now = Number(worldRef.time || 0) || 0;
|
||||||
const compactInterval = 2.5;
|
|
||||||
let itemsCompacted = false;
|
let itemsCompacted = false;
|
||||||
if (worldRef.compactTimer >= compactInterval) {
|
|
||||||
itemsCompacted = worldRef.compactItems();
|
// Link/plushie reconciliation is a safety net. Deletions already reconcile
|
||||||
worldRef.compactTarinai();
|
// dependencies immediately, so a periodic pass is sufficient during normal play.
|
||||||
worldRef.compactAnts?.();
|
if (now >= (worldRef._nextStructureDependencyCheckAt || 0)) {
|
||||||
worldRef.compactTimer = 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 itemCountChanged = worldRef.items.length !== itemCountBefore || worldRef.items.length !== (worldRef._lastMaintenanceItemCount ?? worldRef.items.length);
|
||||||
const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length);
|
const effectCountChanged = worldRef.effects.length !== (worldRef._lastMaintenanceEffectCount ?? worldRef.effects.length);
|
||||||
const countsDue = worldRef.countsTimer >= countsInterval;
|
if (itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) {
|
||||||
if (countsDue || itemsCompacted || itemCountChanged || effectCountChanged || worldRef.itemBucketsDirty) {
|
worldRef.updateItemCounts();
|
||||||
if (countsDue || itemsCompacted || itemCountChanged || worldRef.itemBucketsDirty) worldRef.updateItemCounts();
|
|
||||||
if (countsDue || itemsCompacted || effectCountChanged) worldRef.updateEffectCounts();
|
|
||||||
worldRef._lastMaintenanceItemCount = worldRef.items.length;
|
worldRef._lastMaintenanceItemCount = worldRef.items.length;
|
||||||
worldRef._lastMaintenanceEffectCount = worldRef.effects.length;
|
|
||||||
worldRef.countsTimer = 0;
|
|
||||||
}
|
}
|
||||||
if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) {
|
if (effectCountChanged || now >= (worldRef._nextEffectCountRefreshAt || 0)) {
|
||||||
worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.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;
|
const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0;
|
||||||
if (removedGrass > 0) {
|
if (removedGrass > 0) {
|
||||||
worldRef.compactItems();
|
worldRef.compactItems();
|
||||||
|
|
@ -37,27 +77,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((worldRef.time || 0) >= (worldRef.nextFireCompactAt || 0)) {
|
if (now >= (worldRef.nextFireCompactAt || 0)) {
|
||||||
worldRef.nextFireCompactAt = (worldRef.time || 0) + 0.8;
|
worldRef.nextFireCompactAt = now + 0.8;
|
||||||
const compactedFires = global.TarinaiItemDynamicToolSystem?.compactWorldFires?.(worldRef) || 0;
|
const compactedFires = global.TarinaiItemDynamicToolSystem?.compactWorldFires?.(worldRef) || 0;
|
||||||
if (compactedFires > 0) {
|
if (compactedFires > 0) worldRef.updateItemCounts();
|
||||||
itemsCompacted = true;
|
|
||||||
worldRef.updateItemCounts();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((worldRef.time || 0) >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
|
if (now >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
|
||||||
worldRef.nextGrassBedExpiryCheckAt = (worldRef.time || 0) + 5.0;
|
worldRef.nextGrassBedExpiryCheckAt = now + 5.0;
|
||||||
const lifespan = Math.max(1, CONFIG.dayLength || 120); // \u30B2\u30FC\u30E0\u518524\u6642\u9593
|
const lifespan = Math.max(1, CONFIG.dayLength || 120);
|
||||||
let removedBeds = 0;
|
let removedBeds = 0;
|
||||||
const grassBeds = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grass_bed") : (worldRef.items || []);
|
const grassBeds = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grass_bed") : (worldRef.items || []);
|
||||||
for (const it of grassBeds || []) {
|
for (const it of grassBeds || []) {
|
||||||
if (!it || it.dead || it.type !== "grass_bed") continue;
|
if (!it || it.dead || it.type !== "grass_bed") continue;
|
||||||
if (!Number.isFinite(Number(it.createdAt))) it.createdAt = worldRef.time || 0;
|
if (!Number.isFinite(Number(it.createdAt))) it.createdAt = now;
|
||||||
if ((worldRef.time || 0) - Number(it.createdAt || 0) < lifespan) continue;
|
if (now - Number(it.createdAt || 0) < lifespan) continue;
|
||||||
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, it, {
|
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, it, {
|
||||||
reason: "grass-bed-expired",
|
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,
|
wake: false,
|
||||||
panicOwner: false,
|
panicOwner: false,
|
||||||
});
|
});
|
||||||
|
|
@ -67,17 +104,14 @@
|
||||||
worldRef.compactItems();
|
worldRef.compactItems();
|
||||||
worldRef.updateItemCounts();
|
worldRef.updateItemCounts();
|
||||||
worldRef.markSpatialDirty?.("grass-bed-expired");
|
worldRef.markSpatialDirty?.("grass-bed-expired");
|
||||||
worldRef.markTerrainDirty?.("grass-bed-expired");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) {
|
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= now) {
|
||||||
worldRef.outOfBoundsCheckAt = worldRef.time;
|
worldRef.outOfBoundsCheckAt = now;
|
||||||
worldRef.sanitizeOutOfBounds();
|
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;
|
worldRef.drawSortTimer = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,12 @@
|
||||||
function compactWorld(worldRef) {
|
function compactWorld(worldRef) {
|
||||||
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
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 [
|
return [
|
||||||
enumIndex(FIELD_IDS, worldRef?.fieldType || "garden"),
|
enumIndex(FIELD_IDS, worldRef?.fieldType || "garden"),
|
||||||
enumIndex(GROUND_IDS, worldRef?.groundType || "soil"),
|
enumIndex(GROUND_IDS, worldRef?.groundType || "soil"),
|
||||||
|
|
@ -187,26 +192,42 @@
|
||||||
q(worldRef?.achievementLastInterventionAt, 10, 0),
|
q(worldRef?.achievementLastInterventionAt, 10, 0),
|
||||||
q(worldRef?.achievementNoDeathStartAt, 10, -10),
|
q(worldRef?.achievementNoDeathStartAt, 10, -10),
|
||||||
q(worldRef?.achievementDirectFeedCount, 1, 0),
|
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 config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
||||||
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
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.fieldType = enumValue(FIELD_IDS, arr[0], "garden");
|
||||||
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
|
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
|
||||||
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
|
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
|
||||||
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
|
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
|
||||||
worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0));
|
worldRef.tarinaiPopulationLimit = worldRef.normalizeColonyLimit?.(u(arr[10], 1, 0)) ?? Math.min(300, Math.max(0, u(arr[10], 1, 0)));
|
||||||
worldRef.objectLimit = Math.max(0, u(arr[11], 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.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.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.achievementPlayerPlacementCount = Math.max(0, u(arr[14], 1, 0));
|
||||||
worldRef.achievementLastInterventionAt = Math.max(0, u(arr[15], 10, totalTime));
|
worldRef.achievementLastInterventionAt = Math.max(0, u(arr[15], 10, totalTime));
|
||||||
worldRef.achievementNoDeathStartAt = u(arr[16], 10, -1);
|
worldRef.achievementNoDeathStartAt = u(arr[16], 10, -1);
|
||||||
worldRef.achievementDirectFeedCount = Math.max(0, u(arr[17], 1, 0));
|
worldRef.achievementDirectFeedCount = Math.max(0, u(arr[17], 1, 0));
|
||||||
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
worldRef.achievementManualTarinaiAddedCount = Math.min(100, Math.max(0, u(arr[19], 1, 0)));
|
||||||
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
|
worldRef.time = Math.max(0, totalTime);
|
||||||
|
worldRef.elapsedDays = encodedElapsedDays;
|
||||||
|
worldRef.day = worldRef.elapsedDays + 1;
|
||||||
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||||
worldRef.deadCount = u(arr[7], 1, 0);
|
worldRef.deadCount = u(arr[7], 1, 0);
|
||||||
worldRef.liveIdNext = 1;
|
worldRef.liveIdNext = 1;
|
||||||
|
|
@ -235,6 +256,8 @@
|
||||||
const needOverprotective = !isUnlocked("overprotective");
|
const needOverprotective = !isUnlocked("overprotective");
|
||||||
const needSauna = !isUnlocked("sauna_cold_plunge");
|
const needSauna = !isUnlocked("sauna_cold_plunge");
|
||||||
const needQuickDelete = !isUnlocked("unplanned_city_30");
|
const needQuickDelete = !isUnlocked("unplanned_city_30");
|
||||||
|
const needMinimalist = !isUnlocked("minimalist_happy");
|
||||||
|
const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;
|
||||||
const metadata = {
|
const metadata = {
|
||||||
w: [
|
w: [
|
||||||
needSlaveGenerations ? [...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
|
needSlaveGenerations ? [...new Set((worldRef?.achievementNaturalSlaveGenerations || []).map(value => Math.max(1, Math.floor(Number(value) || 1))))].sort((a, b) => a - b) : [],
|
||||||
|
|
@ -256,7 +279,7 @@
|
||||||
needSauna ? finiteOrNull(t?._achievementSaunaHotAt) : null,
|
needSauna ? finiteOrNull(t?._achievementSaunaHotAt) : null,
|
||||||
]),
|
]),
|
||||||
i: itemRecords.map(rec => [
|
i: itemRecords.map(rec => [
|
||||||
needQuickDelete && rec?.item?._achievementPlayerPlaced ? 1 : 0,
|
needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0,
|
||||||
needQuickDelete ? finiteOrNull(rec?.item?._achievementPlacedAt) : null,
|
needQuickDelete ? finiteOrNull(rec?.item?._achievementPlacedAt) : null,
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
@ -587,7 +610,7 @@
|
||||||
return {
|
return {
|
||||||
v: SNAPSHOT_VERSION,
|
v: SNAPSHOT_VERSION,
|
||||||
a: "tj1",
|
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),
|
w: compactWorld(worldRef),
|
||||||
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
|
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
|
||||||
i: itemRows,
|
i: itemRows,
|
||||||
|
|
@ -608,7 +631,7 @@
|
||||||
worldRef.selected = null;
|
worldRef.selected = null;
|
||||||
worldRef.events = global.TarinaiEvents || null;
|
worldRef.events = global.TarinaiEvents || null;
|
||||||
worldRef.liveTarinai = new Map();
|
worldRef.liveTarinai = new Map();
|
||||||
applyCompactWorld(worldRef, snapshot.w || []);
|
applyCompactWorld(worldRef, snapshot.w || [], snapshot.m || []);
|
||||||
|
|
||||||
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
||||||
for (let idx = 0; idx < tarRows.length; idx++) {
|
for (let idx = 0; idx < tarRows.length; idx++) {
|
||||||
|
|
@ -681,6 +704,23 @@
|
||||||
if (typeof updateNeedsRuntime === "function") {
|
if (typeof updateNeedsRuntime === "function") {
|
||||||
for (const t of worldRef.tarinai) updateNeedsRuntime(t, worldRef, 0);
|
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.liveIdNext = Math.max(1, worldRef.tarinai.length + 1);
|
||||||
worldRef.liveIdSerial = 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));
|
worldRef.birthSerial = Math.max(Number(worldRef.birthSerial) || 0, ...worldRef.tarinai.map(t => Number(t.birthSerial) || 0));
|
||||||
|
|
@ -694,11 +734,13 @@
|
||||||
worldRef.relationNotices = {};
|
worldRef.relationNotices = {};
|
||||||
worldRef.resolvedFightIds = {};
|
worldRef.resolvedFightIds = {};
|
||||||
worldRef.eventCounters = {};
|
worldRef.eventCounters = {};
|
||||||
|
worldRef.rebuildTarinaiCountCache?.("restore");
|
||||||
worldRef.updateItemCounts?.();
|
worldRef.updateItemCounts?.();
|
||||||
worldRef.enforceGrassLimit?.("load-grass-limit");
|
worldRef.enforceGrassLimit?.("load-grass-limit");
|
||||||
worldRef.compactItems?.();
|
worldRef.compactItems?.();
|
||||||
worldRef.updateItemCounts?.();
|
worldRef.updateItemCounts?.();
|
||||||
worldRef.updateEffectCounts?.();
|
worldRef.updateEffectCounts?.();
|
||||||
|
global.TarinaiAchievements?.evaluateEvent?.(worldRef, "restore", { source: "snapshot" });
|
||||||
worldRef.rebuildSpatial?.(true);
|
worldRef.rebuildSpatial?.(true);
|
||||||
worldRef.markTerrainDirty?.("load");
|
worldRef.markTerrainDirty?.("load");
|
||||||
worldRef.clampCamera?.();
|
worldRef.clampCamera?.();
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@
|
||||||
const ownerPanic = shouldPanicOwnerOnStructureGone(structure, reason);
|
const ownerPanic = shouldPanicOwnerOnStructureGone(structure, reason);
|
||||||
const changed = markStructureGone(structure, reason);
|
const changed = markStructureGone(structure, reason);
|
||||||
if (!changed) return false;
|
if (!changed) return false;
|
||||||
|
worldRef.noteItemInactive?.(structure, reason);
|
||||||
removeDependentLinks(worldRef, structure, reason);
|
removeDependentLinks(worldRef, structure, reason);
|
||||||
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
|
if (ownerPanic && opts.panicOwner !== false) triggerOwnedStructureDestroyedPanic(worldRef, structure, opts.breaker || null, reason);
|
||||||
markWorldAfterStructureChange(worldRef, reason);
|
markWorldAfterStructureChange(worldRef, reason);
|
||||||
|
|
@ -295,6 +296,7 @@
|
||||||
if (user?.target === bed) user.target = null;
|
if (user?.target === bed) user.target = null;
|
||||||
const changed = markStructureGone(bed, "grass-bed-wake-used");
|
const changed = markStructureGone(bed, "grass-bed-wake-used");
|
||||||
if (!changed) return false;
|
if (!changed) return false;
|
||||||
|
worldRef.noteItemInactive?.(bed, "grass-bed-wake-used");
|
||||||
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
|
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +307,7 @@
|
||||||
if (item.isStructure) return deleteStructure(worldRef, item, { reason, userReason: opts.userReason, wake: opts.wake, breaker: opts.breaker || null, panicOwner: opts.panicOwner });
|
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);
|
const changed = markLooseItemGone(item, reason);
|
||||||
if (!changed) return false;
|
if (!changed) return false;
|
||||||
|
worldRef.noteItemInactive?.(item, reason);
|
||||||
removeDependentLinks(worldRef, item, reason);
|
removeDependentLinks(worldRef, item, reason);
|
||||||
markWorldAfterStructureChange(worldRef, reason, item, Math.max(36, Number(item.r || item.radius || 24) + 18));
|
markWorldAfterStructureChange(worldRef, reason, item, Math.max(36, Number(item.r || item.radius || 24) + 18));
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
this.plushieTrailTimer = Math.max(0, (this.plushieTrailTimer || 0) - dt);
|
this.plushieTrailTimer = Math.max(0, (this.plushieTrailTimer || 0) - dt);
|
||||||
if (worldRef?.effects && typeof Effect !== "undefined" && this.plushieTrailTimer <= 0) {
|
if (worldRef?.effects && typeof Effect !== "undefined" && this.plushieTrailTimer <= 0) {
|
||||||
this.plushieTrailTimer = 0.045;
|
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;
|
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) {
|
if (this.plushieFlingTimer <= 0 || this.x < -margin || this.x > (worldRef?.w || 0) + margin || this.y < -margin || this.y > (worldRef?.h || 0) + margin) {
|
||||||
|
|
@ -208,8 +208,8 @@
|
||||||
if (typeof applyNeedShock === "function") applyNeedShock(owner, { fulfill: 12, safety: 4 }, this);
|
if (typeof applyNeedShock === "function") applyNeedShock(owner, { fulfill: 12, safety: 4 }, this);
|
||||||
}
|
}
|
||||||
if (world?.effects && typeof Effect !== "undefined") {
|
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.spawnEffect("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("fall", this.x, this.y, { size: 18, life: 0.28, color: "rgba(190,142,92,0.28)" });
|
||||||
}
|
}
|
||||||
world?.markItemBucketsDirty?.("plushie-fling");
|
world?.markItemBucketsDirty?.("plushie-fling");
|
||||||
world?.markSpatialDirty?.("plushie-fling");
|
world?.markSpatialDirty?.("plushie-fling");
|
||||||
|
|
@ -312,7 +312,7 @@
|
||||||
|
|
||||||
function damageNearbyStructures(worldRef, x, y, radius, amount, breaker = null) {
|
function damageNearbyStructures(worldRef, x, y, radius, amount, breaker = null) {
|
||||||
let hit = 0;
|
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;
|
if (!structure?.isStructure || structure.dead) continue;
|
||||||
const d = distXY(x, y, structure.x, structure.y);
|
const d = distXY(x, y, structure.x, structure.y);
|
||||||
const p = clamp(1 - d / Math.max(1, radius + (structure.r || 12)), 0, 1);
|
const p = clamp(1 - d / Math.max(1, radius + (structure.r || 12)), 0, 1);
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,7 @@ function createPanicEscapeActionSpec() {
|
||||||
},
|
},
|
||||||
start(t, world, ctx = {}) {
|
start(t, world, ctx = {}) {
|
||||||
const threat = ctx.target ?? this.selectTarget(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.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false });
|
||||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.6);
|
t.fearTimer = Math.max(t.fearTimer || 0, 1.6);
|
||||||
return true;
|
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() {
|
function createApproachFriendActionSpec() {
|
||||||
return {
|
return {
|
||||||
id: "approach_friend",
|
id: "approach_friend",
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 };
|
const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 };
|
||||||
const fearMul = opts.fearMultiplier ?? profile.fear ?? 1;
|
const fearMul = opts.fearMultiplier ?? profile.fear ?? 1;
|
||||||
const threat = opts.threat ?? opts.target ?? null;
|
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 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);
|
const healthShock = Math.max(0, Number(opts.hurtTimer || 0) > 0 ? 45 : 0);
|
||||||
if (typeof applyNeedShock === "function") {
|
if (typeof applyNeedShock === "function") {
|
||||||
|
|
|
||||||
|
|
@ -32,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 === "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 === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b";
|
||||||
if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044";
|
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;
|
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) {
|
function actionTextForReason(action = null, tarinai = null) {
|
||||||
const behavior = currentTarinaiBehavior(tarinai);
|
const behavior = currentTarinaiBehavior(tarinai);
|
||||||
const id = action?.id || behavior?.actionId || "";
|
const id = action?.id || behavior?.actionId || "";
|
||||||
|
|
@ -168,6 +181,11 @@ function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
|
||||||
}
|
}
|
||||||
const b = behavior || (currentTarinaiBehavior(tarinai)) || null;
|
const b = behavior || (currentTarinaiBehavior(tarinai)) || null;
|
||||||
if (!b) return "";
|
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();
|
const stored = String(b.text || b.reason || "").trim();
|
||||||
if (stored && options.regenerate !== true) return stored;
|
if (stored && options.regenerate !== true) return stored;
|
||||||
const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null;
|
const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const d = contact.distance;
|
||||||
tarinai.isTarinaiChampion = false;
|
tarinai.isTarinaiChampion = false;
|
||||||
tarinai.tarinaiChampionSince = 0;
|
tarinai.tarinaiChampionSince = 0;
|
||||||
tarinai.becomeZunchiSlave?.({ locked: true, forcedItem: true });
|
tarinai.becomeZunchiSlave?.({ locked: true, forcedItem: true });
|
||||||
|
tarinai.world?.syncTarinaiCountEntry?.(tarinai);
|
||||||
tarinai.bubble("\u9396", 2.4, "rgba(74,64,56,0.82)");
|
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 });
|
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 {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@
|
||||||
if (typeof global.consumeBehaviorTarget !== "function") return null;
|
if (typeof global.consumeBehaviorTarget !== "function") return null;
|
||||||
const type = itemType(item);
|
const type = itemType(item);
|
||||||
const label = typeof global.toolLabel === "function" ? global.toolLabel(type) : type;
|
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);
|
const consumed = global.consumeBehaviorTarget(tarinai, worldRef, item, roleFor(item), 0.18);
|
||||||
if (!consumed) return null;
|
if (!consumed) return null;
|
||||||
const nutrition = global.TarinaiItemRegistry?.food?.nutrition?.(type, 0) ?? 0;
|
const nutrition = global.TarinaiItemRegistry?.food?.nutrition?.(type, 0) ?? 0;
|
||||||
|
|
@ -132,7 +133,9 @@
|
||||||
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
|
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
|
||||||
} else {
|
} else {
|
||||||
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);
|
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);
|
||||||
global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, treatment: MEDICINE_TOOL_TYPES.has(type), source: options.source || "direct" });
|
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";
|
const sourceLabel = options.source === "pinch" ? "\u624b\u6e21\u3057\u3067" : "\u76f4\u63a5";
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,13 @@
|
||||||
emitZunchiDiseaseEffect(dt) {
|
emitZunchiDiseaseEffect(dt) {
|
||||||
if (!this.zunchiDisease || !this.world?.effects) return;
|
if (!this.zunchiDisease || !this.world?.effects) return;
|
||||||
if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (0.48 + (this.zunchiDiseaseSeverity || 0) * 0.62), this)) {
|
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),
|
vx: deterministicRange(this.world, "zunchi-miasma-vx", -5, 5, this),
|
||||||
vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this),
|
vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this),
|
||||||
life: deterministicRange(this.world, "zunchi-miasma-life", 1.6, 2.8, 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),
|
size: deterministicRange(this.world, "zunchi-miasma-size", 12, 26, this) * (1.0 + (this.zunchiDiseaseSeverity || 0) * 0.55),
|
||||||
color: "rgba(16,78,28,0.70)",
|
color: "rgba(16,78,28,0.70)",
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -674,8 +674,8 @@
|
||||||
if (!ant || ant.dead) continue;
|
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);
|
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) || [])]) {
|
for (const it of [...(this.world?.nearbyGrass?.(this.x, this.y, contactRadius + 32, true) || [])]) {
|
||||||
if (!it || it.dead || it.type !== "grass") continue;
|
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 (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");
|
if (this.burnTimer <= 0.01) this.extinguishFire("\u81ea\u7136\u306b\u706b\u304c\u6d88\u3048\u305f");
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,54 @@
|
||||||
const Tarinai = global.Tarinai;
|
const Tarinai = global.Tarinai;
|
||||||
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js");
|
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js");
|
||||||
|
|
||||||
function relationIdSetFor(tarinai) {
|
const relationPeerCache = new WeakMap();
|
||||||
const ids = new Set(Object.keys(tarinai?.relationships || {}));
|
|
||||||
for (const other of tarinai?.world?.tarinai || []) {
|
function relationPeerEntry(tarinai) {
|
||||||
if (!other || other === tarinai || !other.id) continue;
|
const relationships = tarinai?.relationships || {};
|
||||||
if (other.relationships?.[tarinai.id]) ids.add(other.id);
|
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({
|
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||||
|
|
@ -169,6 +210,7 @@
|
||||||
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
|
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
|
||||||
this.focusPulseTimer = Math.max(this.focusPulseTimer || 0, 1.0);
|
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 });
|
if (boosted.length) this.recordChangeCause?.("\u305F\u308A\u306A\u3044\u738B\u8005", boosted.join("\u30FB"), { value: +1 });
|
||||||
|
this.world?.syncTarinaiCountEntry?.(this);
|
||||||
this.world?.recordFamily?.(this);
|
this.world?.recordFamily?.(this);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
@ -188,11 +230,30 @@
|
||||||
|
|
||||||
relationTo(id) {
|
relationTo(id) {
|
||||||
if (!id) return relationDefaults();
|
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) 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];
|
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 = "") {
|
adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") {
|
||||||
if (!other || !other.id || other === this) return;
|
if (!other || !other.id || other === this) return;
|
||||||
const rel = this.relationTo(other.id);
|
const rel = this.relationTo(other.id);
|
||||||
|
|
@ -217,9 +278,12 @@
|
||||||
let bestId = null;
|
let bestId = null;
|
||||||
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
|
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
|
||||||
for (const id of relationIdSetFor(this)) {
|
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;
|
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 reverse = kind === "fear" ? null : (other?.relationships?.[this.id] || null);
|
||||||
const score = kind === "fear" ? (rel.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
const score = kind === "fear" ? (rel.fear || 0) : Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
||||||
if (score > bestScore) { bestId = id; bestScore = score; }
|
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;
|
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const id of relationIdSetFor(this)) {
|
for (const id of relationIdSetFor(this)) {
|
||||||
const rel = this.relationships?.[id] || relationDefaults();
|
|
||||||
const other = this.world?.liveTarinaiById?.(id) || null;
|
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;
|
const reverse = other?.relationships?.[this.id] || null;
|
||||||
if (Math.max(rel.affinity || 0, reverse?.affinity || 0) <= minScore) continue;
|
if (Math.max(rel.affinity || 0, reverse?.affinity || 0) <= minScore) continue;
|
||||||
if (liveOnly && (!other || id === this.id)) continue;
|
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
|
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
|
||||||
|
|
@ -264,12 +331,15 @@
|
||||||
}
|
}
|
||||||
let best = null, bestScore = minScore;
|
let best = null, bestScore = minScore;
|
||||||
for (const id of relationIdSetFor(this)) {
|
for (const id of relationIdSetFor(this)) {
|
||||||
const rel = this.relationships?.[id] || relationDefaults();
|
|
||||||
const t = this.world.liveTarinaiById?.(id) || null;
|
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 reverse = t?.relationships?.[this.id] || null;
|
||||||
const score = Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
const score = Math.max(rel.affinity || 0, reverse?.affinity || 0);
|
||||||
if (score <= bestScore) continue;
|
if (score <= bestScore) continue;
|
||||||
if (!t) continue;
|
|
||||||
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||||
best = t;
|
best = t;
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
|
|
@ -286,7 +356,10 @@
|
||||||
if (this.world.time - when > maxAge) continue;
|
if (this.world.time - when > maxAge) continue;
|
||||||
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
|
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
|
||||||
const t = this.world.liveTarinaiById?.(id) || null;
|
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 (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||||
if (when > bestT) { best = t; bestT = when; }
|
if (when > bestT) { best = t; bestT = when; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
diseaseChance(base = 0) {
|
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 === "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);
|
if (this.powerItemMode === "niteropu") return b * (EFFECT_REGISTRY?.modifier?.("niteropu", "diseaseChanceMultiplier", 2.0) ?? 2.0);
|
||||||
return b;
|
return b;
|
||||||
|
|
@ -317,13 +317,13 @@
|
||||||
const type = kind === "niteropu" ? "zunchi_miasma" : (kind === "ammo" ? "fall" : (kind === "protein" ? "fall" : "ring"));
|
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 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));
|
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,
|
vx: (kind === "protein" || kind === "niteropu") ? rand(-18, 18) * intensity : Math.cos(angle) * rand(8, 34) * intensity,
|
||||||
vy,
|
vy,
|
||||||
size: rand(5, 11) * (0.75 + intensity * 0.30),
|
size: rand(5, 11) * (0.75 + intensity * 0.30),
|
||||||
life: rand(0.42, 0.90),
|
life: rand(0.42, 0.90),
|
||||||
color,
|
color,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ function foodPriorityScore(tarinai, item, distance = 0) {
|
||||||
|
|
||||||
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
|
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
|
||||||
if (!tarinai || !world) return false;
|
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) {
|
for (const it of source) {
|
||||||
if (!it || it.dead) continue;
|
if (!it || it.dead) continue;
|
||||||
const type = effectiveFoodTypeForItem(it);
|
const type = effectiveFoodTypeForItem(it);
|
||||||
|
|
@ -176,7 +176,13 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
|
||||||
// crowded world \u3067\u6bce\u56de world.items \u5168\u4f53\u3092\u8db3\u3059\u7d4c\u8def\u3092\u907f\u3051\u308b\u3002
|
// crowded world \u3067\u6bce\u56de world.items \u5168\u4f53\u3092\u8db3\u3059\u7d4c\u8def\u3092\u907f\u3051\u308b\u3002
|
||||||
if (foodLikeRole) {
|
if (foodLikeRole) {
|
||||||
if (world?.itemsOfType) {
|
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)) {
|
} else if (Array.isArray(world?.items)) {
|
||||||
for (const it of world.items) addCandidate(it);
|
for (const it of world.items) addCandidate(it);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,14 @@
|
||||||
this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends.");
|
this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends.");
|
||||||
}
|
}
|
||||||
let grassComfort = 0;
|
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;
|
if (it.type !== "grass" || it.dead) continue;
|
||||||
const d = distXY(this.x, this.y, it.x, it.y);
|
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);
|
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
|
||||||
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
|
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
|
||||||
}
|
}
|
||||||
let nestComfort = 0;
|
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;
|
if (!it || it.dead || it.type !== "nest_box") continue;
|
||||||
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
|
nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const TARINAI_ACTION_LOCK_SECONDS = {
|
||||||
seek_comfort_temperature: 5.8,
|
seek_comfort_temperature: 5.8,
|
||||||
panic_escape: 2.1,
|
panic_escape: 2.1,
|
||||||
hide_at_owned_structure: 6.4,
|
hide_at_owned_structure: 6.4,
|
||||||
|
crowd_escape: 2.4,
|
||||||
approach_friend: 6.4,
|
approach_friend: 6.4,
|
||||||
approach_parent_or_child: 7.0,
|
approach_parent_or_child: 7.0,
|
||||||
approach_mate: 5.6,
|
approach_mate: 5.6,
|
||||||
|
|
@ -77,6 +78,7 @@ const TARINAI_ACTION_PRIORITY = {
|
||||||
birth_ritual: 72,
|
birth_ritual: 72,
|
||||||
approach_mate: 72,
|
approach_mate: 72,
|
||||||
approach_parent_or_child: 66,
|
approach_parent_or_child: 66,
|
||||||
|
crowd_escape: 6,
|
||||||
approach_friend: 66,
|
approach_friend: 66,
|
||||||
eat_food: 50,
|
eat_food: 50,
|
||||||
drink_water: 48,
|
drink_water: 48,
|
||||||
|
|
@ -231,6 +233,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
|
||||||
}
|
}
|
||||||
const panicAction = actions.find(a => a.id === "panic_escape");
|
const panicAction = actions.find(a => a.id === "panic_escape");
|
||||||
if (!panicAction) return false;
|
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 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);
|
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
|
||||||
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, panicAction, reasonText)) {
|
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;
|
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);
|
socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16);
|
||||||
raw.social = socialParts.bond;
|
raw.social = socialParts.bond;
|
||||||
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
|
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
|
||||||
|
|
@ -264,6 +264,7 @@ 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 === "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 === "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 === "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") {
|
} else if (need === "fulfill") {
|
||||||
const p = tarinai?.fulfillReasonParts || {};
|
const p = tarinai?.fulfillReasonParts || {};
|
||||||
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
|
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
|
||||||
|
|
@ -302,7 +303,7 @@ function socialFightMateActionWeights(tarinai) {
|
||||||
// regression guard baseline: return ({ conflict: 34, mate: 24, family: 12, bond: 14
|
// regression guard baseline: return ({ conflict: 34, mate: 24, family: 12, bond: 14
|
||||||
// regression guard baseline: subValue >= threshold && subValue >= socialPull * 0.82
|
// regression guard baseline: subValue >= threshold && subValue >= socialPull * 0.82
|
||||||
function socialSubNeedStartThreshold(subNeed = "") {
|
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) {
|
function shouldStartActionBySubNeed(tarinai, action, needs) {
|
||||||
|
|
@ -319,6 +320,8 @@ function shouldStartActionBySubNeed(tarinai, action, needs) {
|
||||||
const subNeed = action.subNeed;
|
const subNeed = action.subNeed;
|
||||||
const subValue = Number(parts[subNeed] || 0) || 0;
|
const subValue = Number(parts[subNeed] || 0) || 0;
|
||||||
const total = Number(needs?.social || 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 prof = tarinai?.personalityProfile?.() || {};
|
||||||
const cur = tarinai?.currentPersonality || {};
|
const cur = tarinai?.currentPersonality || {};
|
||||||
const modifier = subNeed === "conflict" ? Math.max(0, Number(cur.aggression ?? prof.fight ?? 0) || 0) * 8
|
const modifier = subNeed === "conflict" ? Math.max(0, Number(cur.aggression ?? prof.fight ?? 0) || 0) * 8
|
||||||
|
|
@ -327,14 +330,18 @@ function shouldStartActionBySubNeed(tarinai, action, needs) {
|
||||||
: 0;
|
: 0;
|
||||||
const threshold = Math.max(6, socialSubNeedStartThreshold(subNeed) - modifier);
|
const threshold = Math.max(6, socialSubNeedStartThreshold(subNeed) - modifier);
|
||||||
const isContinuingSameAction = (getTarinaiBehaviorId(tarinai)) === action.id;
|
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") {
|
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;
|
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 (subValue >= threshold) return true;
|
||||||
if (isContinuingSameAction && subValue >= threshold * 0.55) 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) {
|
function conflictNeedsForChoice(selectedAction, candidates, needs) {
|
||||||
|
|
@ -864,6 +871,7 @@ function resolveNeedsCore(dt) {
|
||||||
applyZunchiSlavePersonality(this, { birth: !!options.birth });
|
applyZunchiSlavePersonality(this, { birth: !!options.birth });
|
||||||
this.visibleSpriteId = this.zunchiSlaveSpriteId();
|
this.visibleSpriteId = this.zunchiSlaveSpriteId();
|
||||||
this.spriteLockUntil = 0;
|
this.spriteLockUntil = 0;
|
||||||
|
this.world?.syncTarinaiCountEntry?.(this);
|
||||||
this.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u72b6\u614b");
|
this.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u72b6\u614b");
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@
|
||||||
function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) {
|
function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) {
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestScore = Infinity;
|
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) {
|
for (const item of list) {
|
||||||
if (!item || item.dead || item.type !== type) continue;
|
if (!item || item.dead || item.type !== type) continue;
|
||||||
if (!sleepPlaceAvailableFor(worldRef, tarinai, item)) continue;
|
if (!sleepPlaceAvailableFor(worldRef, tarinai, item)) continue;
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,104 @@ function pickSocialFightMate(world, key, weights, ...seeds) {
|
||||||
return deterministicChance(world, key || "social-fight-mate-choice", fightWeight / (fightWeight + mateWeight), ...seeds, bucket) ? "fight" : "mate";
|
return deterministicChance(world, key || "social-fight-mate-choice", fightWeight / (fightWeight + mateWeight), ...seeds, bucket) ? "fight" : "mate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function socialCrowdThresholds(t) {
|
||||||
|
const cur = t?.currentPersonality || {};
|
||||||
|
const social = clamp(Number(cur.sociability || 0) || 0, -1, 1);
|
||||||
|
const open = clamp(Number(cur.openness || 0) || 0, -1, 1);
|
||||||
|
const social01 = (social + 1) * 0.5;
|
||||||
|
const open01 = (open + 1) * 0.5;
|
||||||
|
const preferred = 4 + social01 * 6;
|
||||||
|
const lower = Math.floor(clamp(preferred - (3 + open01 * 3), 0, 7));
|
||||||
|
const upper = Math.ceil(clamp(preferred + 5 + open01 * 7, Math.max(8, lower + 4), 24));
|
||||||
|
return { lower, upper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSocialCrowdEscapeTarget(t, world = t?.world) {
|
||||||
|
if (!t || !world) return null;
|
||||||
|
const source = typeof world.nearbyTarinai === "function"
|
||||||
|
? (world.nearbyTarinai(t.x, t.y, 118, true) || [])
|
||||||
|
: (world.tarinai || []);
|
||||||
|
let cx = 0, cy = 0, count = 0, inspected = 0;
|
||||||
|
for (const o of source) {
|
||||||
|
if (!o || o === t || o.dead) continue;
|
||||||
|
const dx = o.x - t.x, dy = o.y - t.y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 > 118 * 118 || d2 < 0.0001) continue;
|
||||||
|
const w = 1 / Math.max(24, Math.sqrt(d2));
|
||||||
|
cx += dx * w;
|
||||||
|
cy += dy * w;
|
||||||
|
count++;
|
||||||
|
if (++inspected >= 28) break;
|
||||||
|
}
|
||||||
|
const seed = typeof stableUnit === "function" ? stableUnit(t.familyKey || t.id || "crowd", "crowd-escape-angle") : 0.5;
|
||||||
|
let base = Math.atan2(-cy, -cx);
|
||||||
|
if (!count || !Number.isFinite(base)) base = seed * Math.PI * 2;
|
||||||
|
const pad = Math.max(30, (CONFIG.worldPadding || 30) + (t.radius || 22));
|
||||||
|
const radius = Math.max(14, (t.radius || 22) * 0.72);
|
||||||
|
for (const escapeDistance of [150, 215]) {
|
||||||
|
for (const off of [0, 0.42, -0.42, 0.84, -0.84]) {
|
||||||
|
const a = base + off + (seed - 0.5) * 0.18;
|
||||||
|
const x = clamp(t.x + Math.cos(a) * escapeDistance, pad, world.w - pad);
|
||||||
|
const y = clamp(t.y + Math.sin(a) * escapeDistance, pad, world.h - pad);
|
||||||
|
if (world.pointBlockedByObstacle?.(x, y, radius, { maxChecks: 12, directionalOneWay: true })) continue;
|
||||||
|
return { x, y, dead: false, detour: true, crowdEscape: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOCIAL_CROWD_ESCAPE_REASON = "\u4ed6\u306e\u305f\u308a\u306a\u3044\u304c\u591a\u3059\u304e\u308b\u306e\u3067\u3001\u5c11\u3057\u96e2\u308c\u3088\u3046\u3068\u3057\u3066\u3044\u308b\u3002";
|
||||||
|
|
||||||
|
function updateCrowdEscapeBehavior(t, world) {
|
||||||
|
if (!t || !world) return false;
|
||||||
|
const sample = t.socialCrowdSample || null;
|
||||||
|
const thresholds = socialCrowdThresholds(t);
|
||||||
|
const now = Number(world.time || 0) || 0;
|
||||||
|
const age = sample ? now - (Number(sample.at || 0) || 0) : Infinity;
|
||||||
|
const count = Math.max(0, Number(sample?.count || 0) || 0);
|
||||||
|
const upper = Math.max(thresholds.lower + 4, Number(sample?.upper ?? thresholds.upper) || thresholds.upper);
|
||||||
|
if (age > 3.5 || count < Math.max(thresholds.lower + 2, upper - 1)) return "finished";
|
||||||
|
|
||||||
|
let target = t.target?.crowdEscape ? t.target : null;
|
||||||
|
if (!target || target.dead || distXY(t.x, t.y, target.x, target.y) <= Math.max(28, (t.radius || 20) + 8)) {
|
||||||
|
target = findSocialCrowdEscapeTarget(t, world);
|
||||||
|
if (!target) return "finished";
|
||||||
|
t.target = target;
|
||||||
|
t.setActionState?.("wander", {
|
||||||
|
target,
|
||||||
|
reason: SOCIAL_CROWD_ESCAPE_REASON,
|
||||||
|
wake: true,
|
||||||
|
sleeping: false,
|
||||||
|
actionId: "crowd_escape",
|
||||||
|
actionLabel: "\u6df7\u96d1\u304b\u3089\u5c11\u3057\u96e2\u308c\u3088\u3046\u3068\u3057\u3066\u3044\u308b",
|
||||||
|
need: "social",
|
||||||
|
subNeed: "crowd",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
t.setActionState?.("wander", {
|
||||||
|
target,
|
||||||
|
reason: SOCIAL_CROWD_ESCAPE_REASON,
|
||||||
|
sleeping: false,
|
||||||
|
actionId: "crowd_escape",
|
||||||
|
actionLabel: "\u6df7\u96d1\u304b\u3089\u5c11\u3057\u96e2\u308c\u3088\u3046\u3068\u3057\u3066\u3044\u308b",
|
||||||
|
need: "social",
|
||||||
|
subNeed: "crowd",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof setBehaviorText === "function") setBehaviorText(t, {
|
||||||
|
need: "social",
|
||||||
|
subNeed: "crowd",
|
||||||
|
actionId: "crowd_escape",
|
||||||
|
actionLabel: "\u6df7\u96d1\u304b\u3089\u5c11\u3057\u96e2\u308c\u3088\u3046\u3068\u3057\u3066\u3044\u308b",
|
||||||
|
reasonText: SOCIAL_CROWD_ESCAPE_REASON,
|
||||||
|
target,
|
||||||
|
phase: "perform",
|
||||||
|
source: "behavior",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function triggerFriendContact(t, other, world, dt = 0) {
|
function triggerFriendContact(t, other, world, dt = 0) {
|
||||||
if (!t || !other || !world) return false;
|
if (!t || !other || !world) return false;
|
||||||
const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85;
|
const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85;
|
||||||
|
|
@ -205,6 +303,7 @@ function updatePanicBehavior(t, world, dt, needs) {
|
||||||
const breakerDanger = activePanicBreaker(t, world, 260);
|
const breakerDanger = activePanicBreaker(t, world, 260);
|
||||||
const nearbyDanger = findNearbyDanger(world, t, 260) || null;
|
const nearbyDanger = findNearbyDanger(world, t, 260) || null;
|
||||||
const realDanger = breakerDanger || nearbyDanger || null;
|
const realDanger = breakerDanger || nearbyDanger || null;
|
||||||
|
if (realDanger && realDanger !== t) t.lastPanicThreat = realDanger;
|
||||||
const currentTarget = currentBehaviorTarget(t);
|
const currentTarget = currentBehaviorTarget(t);
|
||||||
const safety = Number(needs?.safety || 0) || 0;
|
const safety = Number(needs?.safety || 0) || 0;
|
||||||
const now = Number(world?.time || 0) || 0;
|
const now = Number(world?.time || 0) || 0;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
|
|
||||||
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
|
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
|
||||||
|
|
||||||
|
const ROUTE_STATES = new Set([
|
||||||
|
"seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent",
|
||||||
|
"seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature",
|
||||||
|
]);
|
||||||
|
|
||||||
function isNestContainerItem(item) {
|
function isNestContainerItem(item) {
|
||||||
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item));
|
return Boolean(global.TarinaiToolRuntime?.isNestContainerItem?.(item));
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +52,7 @@
|
||||||
function nearestKingFleeNestBox(tarinai, maxDist = 460) {
|
function nearestKingFleeNestBox(tarinai, maxDist = 460) {
|
||||||
const worldRef = tarinai?.world;
|
const worldRef = tarinai?.world;
|
||||||
if (!tarinai || !worldRef) return null;
|
if (!tarinai || !worldRef) return null;
|
||||||
const source = typeof worldRef.nearbyItems === "function" ? worldRef.nearbyItems(tarinai.x, tarinai.y, maxDist) : (worldRef.items || []);
|
const source = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyNonGrassItems?.(tarinai.x, tarinai.y, maxDist) || worldRef.nearbyItems(tarinai.x, tarinai.y, maxDist)) : (worldRef.items || []);
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestScore = Infinity;
|
let bestScore = Infinity;
|
||||||
for (const item of source || []) {
|
for (const item of source || []) {
|
||||||
|
|
@ -78,6 +83,7 @@
|
||||||
};
|
};
|
||||||
const now = avoider.world?.time || 0;
|
const now = avoider.world?.time || 0;
|
||||||
avoider.lastNeedShockBreaker = champion;
|
avoider.lastNeedShockBreaker = champion;
|
||||||
|
avoider.lastPanicThreat = champion;
|
||||||
avoider.lastPanicCause = "tarinai_champion";
|
avoider.lastPanicCause = "tarinai_champion";
|
||||||
avoider.lastPanicDetail = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u3044";
|
avoider.lastPanicDetail = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u3044";
|
||||||
avoider.panicStartedAt = now;
|
avoider.panicStartedAt = now;
|
||||||
|
|
@ -103,23 +109,125 @@
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const CROWD_ESCAPE_REASON = "\u4ed6\u306e\u305f\u308a\u306a\u3044\u304c\u591a\u3059\u304e\u308b\u306e\u3067\u3001\u9003\u3052\u3066\u3044\u308b\u3002";
|
||||||
|
const LONELY_REASON = "\u5bc2\u3057\u3055\u3092\u611f\u3058\u3066\u3044\u308b\u3002";
|
||||||
|
|
||||||
|
function crowdThresholds(t) {
|
||||||
|
const cur = t?.currentPersonality || {};
|
||||||
|
const social = clamp(Number(cur.sociability || 0) || 0, -1, 1);
|
||||||
|
const open = clamp(Number(cur.openness || 0) || 0, -1, 1);
|
||||||
|
const social01 = (social + 1) * 0.5;
|
||||||
|
const open01 = (open + 1) * 0.5;
|
||||||
|
// Sociability raises preferred group size. Openness broadens the comfort band,
|
||||||
|
// especially on the crowded side, without turning ordinary colonies into panic.
|
||||||
|
const preferred = 4 + social01 * 6;
|
||||||
|
const lower = Math.floor(clamp(preferred - (3 + open01 * 3), 0, 7));
|
||||||
|
const upper = Math.ceil(clamp(preferred + 5 + open01 * 7, Math.max(8, lower + 4), 24));
|
||||||
|
return { lower, upper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestLonelyCompanion(t, local = [], maxDist = 640) {
|
||||||
|
if (!t?.world) return null;
|
||||||
|
let best = null;
|
||||||
|
let bestD2 = Infinity;
|
||||||
|
const consider = (o) => {
|
||||||
|
if (!o || o === t || o.dead) return;
|
||||||
|
const dx = o.x - t.x, dy = o.y - t.y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < 0.0001 || d2 > maxDist * maxDist || d2 >= bestD2) return;
|
||||||
|
best = o;
|
||||||
|
bestD2 = d2;
|
||||||
|
};
|
||||||
|
for (const entry of local || []) consider(entry?.o || entry);
|
||||||
|
if (!best) {
|
||||||
|
const source = typeof t.world.nearbyTarinai === "function"
|
||||||
|
? (t.world.nearbyTarinai(t.x, t.y, maxDist, true) || [])
|
||||||
|
: (t.world.tarinai || []);
|
||||||
|
// This fallback runs only while lonely. Stop once a good nearby companion is
|
||||||
|
// found instead of sorting or retaining the full wide-radius population.
|
||||||
|
for (const o of source) consider(o);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCrowdEscapeTarget(t, nearby) {
|
||||||
|
const world = t?.world;
|
||||||
|
if (!t || !world) return null;
|
||||||
|
let cx = 0, cy = 0, count = 0;
|
||||||
|
for (const o of nearby || []) {
|
||||||
|
if (!o || o === t || o.dead) continue;
|
||||||
|
const dx = o.x - t.x, dy = o.y - t.y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 > 118 * 118 || d2 < 0.0001) continue;
|
||||||
|
const w = 1 / Math.max(24, Math.sqrt(d2));
|
||||||
|
cx += dx * w;
|
||||||
|
cy += dy * w;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const seed = typeof stableUnit === "function" ? stableUnit(t.familyKey || t.id || "crowd", "crowd-escape-angle") : 0.5;
|
||||||
|
let base = Math.atan2(-cy, -cx);
|
||||||
|
if (!count || !Number.isFinite(base)) base = seed * Math.PI * 2;
|
||||||
|
const pad = Math.max(30, (CONFIG.worldPadding || 30) + (t.radius || 22));
|
||||||
|
const radius = Math.max(14, (t.radius || 22) * 0.72);
|
||||||
|
const offsets = [0, 0.42, -0.42, 0.84, -0.84];
|
||||||
|
for (const dist of [190, 270]) {
|
||||||
|
for (const off of offsets) {
|
||||||
|
const a = base + off + (seed - 0.5) * 0.18;
|
||||||
|
const x = clamp(t.x + Math.cos(a) * dist, pad, world.w - pad);
|
||||||
|
const y = clamp(t.y + Math.sin(a) * dist, pad, world.h - pad);
|
||||||
|
if (world.pointBlockedByObstacle?.(x, y, radius, { maxChecks: 14, directionalOneWay: true })) continue;
|
||||||
|
return { x, y, dead: false, detour: true, crowdEscape: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trySpreadZunchiDisease(source, target, dt, d, salt) {
|
||||||
|
if (!source?.zunchiDisease || !target || target.zunchiDisease || (target.zunchiDiseaseCooldown || 0) > 0 || d >= 54) return false;
|
||||||
|
const tempMul = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(target) : 1;
|
||||||
|
const base = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * tempMul;
|
||||||
|
const chance = target.diseaseChance ? target.diseaseChance(base) : base;
|
||||||
|
if (!deterministicChance(source.world, `social-zunchi-spread-${salt}`, chance, source, target)) return false;
|
||||||
|
if (!target.infectZunchiDisease(source)) return false;
|
||||||
|
source.world.log(`${source.name}\u306e\u305a\u3093\u3061\u75c5\u304c${target.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [target, source] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||||
interactWithOthers(dt) {
|
interactWithOthers(dt) {
|
||||||
let closeCount = 0;
|
const scanLimit = (this.fightTimer > 0.04 || this.birthRitualTimer > 0.04) ? 12 : 8;
|
||||||
const scanLimit = 8;
|
|
||||||
const selfRadius = Math.max(12, Number(this.radius || 22) || 22);
|
const selfRadius = Math.max(12, Number(this.radius || 22) || 22);
|
||||||
const scanRadius = Math.max(96, selfRadius * 2.4 + 88);
|
const scanRadius = Math.max(92, selfRadius * 2.0 + 52);
|
||||||
let scanned = 0;
|
const selfProfile = this.personalityProfile();
|
||||||
|
const detailed = [];
|
||||||
|
// nearbyTarinai() has already paid the spatial lookup cost and returns only
|
||||||
|
// candidates inside the requested radius when precise=true. Do not stop at
|
||||||
|
// an arbitrary iteration count here: the incremental grid intentionally does
|
||||||
|
// not preserve bucket insertion order, so an early break can miss the actual
|
||||||
|
// nearest mate/contact candidates after enough cell crossings. Keep only the
|
||||||
|
// nearest scanLimit entries with a tiny bounded insertion list instead.
|
||||||
for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) {
|
for (const o of this.world.nearbyTarinai(this.x, this.y, scanRadius, true)) {
|
||||||
if (o === this || o.dead) continue;
|
if (o === this || o.dead) continue;
|
||||||
if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break;
|
|
||||||
scanned += 1;
|
|
||||||
const dx = o.x - this.x, dy = o.y - this.y;
|
const dx = o.x - this.x, dy = o.y - this.y;
|
||||||
const d = Math.hypot(dx, dy);
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 > scanRadius * scanRadius || d2 < 0.0001) continue;
|
||||||
|
const entry = { o, dx, dy, d2 };
|
||||||
|
let pos = detailed.length;
|
||||||
|
while (pos > 0 && detailed[pos - 1].d2 > d2) pos--;
|
||||||
|
if (pos < scanLimit) {
|
||||||
|
detailed.splice(pos, 0, entry);
|
||||||
|
if (detailed.length > scanLimit) detailed.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of detailed) {
|
||||||
|
const o = entry.o;
|
||||||
|
const pairLeader = String(this.id) < String(o.id);
|
||||||
|
const dx = entry.dx, dy = entry.dy;
|
||||||
const otherRadius = Math.max(12, Number(o.radius || 22) || 22);
|
const otherRadius = Math.max(12, Number(o.radius || 22) || 22);
|
||||||
const bodyContactRange = Math.max(74, selfRadius + otherRadius + 18);
|
const bodyContactRange = Math.max(74, selfRadius + otherRadius + 18);
|
||||||
if (d < 0.01 || d > bodyContactRange) continue;
|
if (entry.d2 > bodyContactRange * bodyContactRange) continue;
|
||||||
closeCount += 1;
|
const d = Math.sqrt(entry.d2);
|
||||||
|
|
||||||
const champion = this.isTarinaiChampion ? this : (o.isTarinaiChampion ? o : null);
|
const champion = this.isTarinaiChampion ? this : (o.isTarinaiChampion ? o : null);
|
||||||
if (champion && this.isTarinaiChampion !== o.isTarinaiChampion) {
|
if (champion && this.isTarinaiChampion !== o.isTarinaiChampion) {
|
||||||
|
|
@ -153,7 +261,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
|
const fightMochiContact = pairLeader && this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
|
||||||
if (fightMochiContact && (this.world.fightPairCooldownRemaining?.(this, o) || 0) <= 0.04 && this.fightCooldown <= 0.04 && o.fightCooldown <= 0.04 && d <= Math.max(42, this.radius + o.radius + 16)) {
|
if (fightMochiContact && (this.world.fightPairCooldownRemaining?.(this, o) || 0) <= 0.04 && this.fightCooldown <= 0.04 && o.fightCooldown <= 0.04 && d <= Math.max(42, this.radius + o.radius + 16)) {
|
||||||
this.conflictTargetId = o.id;
|
this.conflictTargetId = o.id;
|
||||||
o.conflictTargetId = this.id;
|
o.conflictTargetId = this.id;
|
||||||
|
|
@ -167,53 +275,57 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const zunchiTempMulA = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(o) : 1;
|
if (pairLeader) {
|
||||||
const zunchiSpreadBaseA = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulA;
|
trySpreadZunchiDisease(this, o, dt, d, "a");
|
||||||
const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(zunchiSpreadBaseA) : zunchiSpreadBaseA;
|
trySpreadZunchiDisease(o, this, dt, d, "b");
|
||||||
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-a", zunchiSpreadChanceA, this, o)) {
|
|
||||||
if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
|
|
||||||
}
|
|
||||||
const zunchiTempMulB = typeof global.zunchiDiseaseTemperatureInfectionMultiplier === "function" ? global.zunchiDiseaseTemperatureInfectionMultiplier(this) : 1;
|
|
||||||
const zunchiSpreadBaseB = dt * 0.065 * clamp(1 - d / 58, 0.12, 1) * zunchiTempMulB;
|
|
||||||
const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(zunchiSpreadBaseB) : zunchiSpreadBaseB;
|
|
||||||
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-b", zunchiSpreadChanceB, this, o)) {
|
|
||||||
if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pairBond = this.world.areCoParents?.(this, o);
|
const pairBond = this.world.areCoParents?.(this, o);
|
||||||
const mixedZunchiSlave = !!this.isZunchiSlave !== !!o.isZunchiSlave;
|
const mixedZunchiSlave = !!this.isZunchiSlave !== !!o.isZunchiSlave;
|
||||||
if (mixedZunchiSlave && d < 86) {
|
if (mixedZunchiSlave && d < 86) {
|
||||||
const nonSlave = this.isZunchiSlave ? o : this;
|
if (pairLeader) {
|
||||||
const slave = this.isZunchiSlave ? this : o;
|
const nonSlave = this.isZunchiSlave ? o : this;
|
||||||
const nx = (nonSlave.x - slave.x) / d;
|
const slave = this.isZunchiSlave ? this : o;
|
||||||
const ny = (nonSlave.y - slave.y) / d;
|
const nx = (nonSlave.x - slave.x) / d;
|
||||||
nonSlave.vx += nx * dt * 34;
|
const ny = (nonSlave.y - slave.y) / d;
|
||||||
nonSlave.vy += ny * dt * 34;
|
nonSlave.vx += nx * dt * 34;
|
||||||
if (typeof applyNeedShock === "function") applyNeedShock(nonSlave, { safety: dt * 24, social: dt * 5 }, slave);
|
nonSlave.vy += ny * dt * 34;
|
||||||
nonSlave.lastNeedShockBreaker = slave;
|
if (typeof applyNeedShock === "function") applyNeedShock(nonSlave, { safety: dt * 24, social: dt * 5 }, slave);
|
||||||
nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.22 * nonSlave.personalityProfile().fear);
|
nonSlave.lastNeedShockBreaker = slave;
|
||||||
nonSlave.target = slave;
|
nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.22 * nonSlave.personalityProfile().fear);
|
||||||
nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044";
|
nonSlave.target = slave;
|
||||||
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
|
nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044";
|
||||||
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid");
|
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
|
||||||
if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18);
|
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid");
|
||||||
|
if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const overlap = this.radius + o.radius - d;
|
const overlap = this.radius + o.radius - d;
|
||||||
if (overlap > -2) {
|
if (pairLeader && overlap > -2) {
|
||||||
|
const nx = dx / d, ny = dy / d;
|
||||||
const push = (overlap + 6) * (pairBond ? 0.08 : 0.17);
|
const push = (overlap + 6) * (pairBond ? 0.08 : 0.17);
|
||||||
this.vx -= (dx / d) * push;
|
this.vx -= nx * push;
|
||||||
this.vy -= (dy / d) * push;
|
this.vy -= ny * push;
|
||||||
if (!pairBond && deterministicChance(this.world, "body-overlap-surprise", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
|
o.vx += nx * push;
|
||||||
|
o.vy += ny * push;
|
||||||
|
if (!pairBond) {
|
||||||
|
if (deterministicChance(this.world, "body-overlap-surprise-a", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
|
||||||
|
if (deterministicChance(this.world, "body-overlap-surprise-b", dt * 1.2, o, this)) o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (pairBond && d > 26 && d < 120) {
|
if (pairLeader && pairBond && d > 26 && d < 120) {
|
||||||
this.vx += (dx / d) * dt * 4.8;
|
const nx = dx / d, ny = dy / d;
|
||||||
this.vy += (dy / d) * dt * 4.8;
|
const pull = dt * 4.8;
|
||||||
|
this.vx += nx * pull;
|
||||||
|
this.vy += ny * pull;
|
||||||
|
o.vx -= nx * pull;
|
||||||
|
o.vy -= ny * pull;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (d < Math.max(60, selfRadius + otherRadius + 8)) {
|
if (d < Math.max(60, selfRadius + otherRadius + 8)) {
|
||||||
const rel = this.relationTo(o.id);
|
const rel = this.relationTo(o.id);
|
||||||
const pdef = this.personalityProfile();
|
const pdef = selfProfile;
|
||||||
this.loneliness -= dt * 1.65 * this.trait.social;
|
this.loneliness -= dt * 1.65 * this.trait.social;
|
||||||
this.affection += dt * 0.28 * this.trait.social;
|
this.affection += dt * 0.28 * this.trait.social;
|
||||||
this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby");
|
this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby");
|
||||||
|
|
@ -269,8 +381,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const weights = socialFightMateWeights(this, o, this.world, { mode: "contact", requireBirthReady: true, distance: d, fightRange: 58 });
|
const weights = socialFightMateWeights(this, o, this.world, { mode: "contact", requireBirthReady: true, distance: d, fightRange: 58 });
|
||||||
const combinedStartChance = dt * 0.30 * clamp((weights.totalWeight || 0) / 10, 0, 2.6);
|
const oneDirectionChance = clamp(dt * 0.30 * clamp((weights.totalWeight || 0) / 10, 0, 2.6), 0, 0.95);
|
||||||
if ((weights.totalWeight || 0) > 0 && deterministicChance(this.world, "social-weighted-fight-mate-start", combinedStartChance, this, o)) {
|
// Pair processing now runs once instead of once from each participant. Preserve
|
||||||
|
// the old two-attempt probability: 1 - (1 - p)^2.
|
||||||
|
const combinedStartChance = 1 - (1 - oneDirectionChance) * (1 - oneDirectionChance);
|
||||||
|
if (pairLeader && (weights.totalWeight || 0) > 0 && deterministicChance(this.world, "social-weighted-fight-mate-start", combinedStartChance, this, o)) {
|
||||||
const choice = pickSocialFightMate(this.world, "social-weighted-fight-mate-pick", weights, this, o);
|
const choice = pickSocialFightMate(this.world, "social-weighted-fight-mate-pick", weights, this, o);
|
||||||
if (choice === "fight") {
|
if (choice === "fight") {
|
||||||
this.conflictTargetId = o.id;
|
this.conflictTargetId = o.id;
|
||||||
|
|
@ -291,12 +406,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (closeCount > 8) {
|
|
||||||
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 5, safety: dt * 3 * this.personalityProfile().fear });
|
|
||||||
this.energy -= dt * 0.25;
|
|
||||||
this.fearTimer = Math.max(this.fearTimer, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.loneliness < 12 && deterministicChance(this.world, "nearby-calm-log", dt * 0.002, this)) {
|
if (this.loneliness < 12 && deterministicChance(this.world, "nearby-calm-log", dt * 0.002, this)) {
|
||||||
this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] });
|
this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] });
|
||||||
}
|
}
|
||||||
|
|
@ -474,12 +583,11 @@
|
||||||
|
|
||||||
if (this.target && !this.target.dead) {
|
if (this.target && !this.target.dead) {
|
||||||
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : (this.state === "play_seesaw" ? (globalThis.TarinaiSeesawSystem?.seatWorld?.(this.target, this.seesawSeatIndex) || this.target) : this.target);
|
const baseMoveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : (this.state === "play_seesaw" ? (globalThis.TarinaiSeesawSystem?.seatWorld?.(this.target, this.seesawSeatIndex) || this.target) : this.target);
|
||||||
const routeStates = new Set(["seek_food", "seek_water", "seek_bed", "seek_material", "build", "follow_parent", "seek_friend", "seek_enemy", "fight", "play_ball", "play_seesaw", "ant_attack", "seek_temperature"]);
|
|
||||||
const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
|
const routeTargetItem = this.target?.shelterItem || this.target?.hostItem || this.target;
|
||||||
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
|
const pathPadding = Math.max(18, (this.radius || 20) * 0.82);
|
||||||
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
|
const targetFoodType = this.target?.type === "duplicator" ? this.target?.storedFoodType : this.target?.type;
|
||||||
const zundaFoodTarget = this.state === "seek_food" && (targetFoodType === "sweet" || targetFoodType === "zunda_juice");
|
const zundaFoodTarget = this.state === "seek_food" && (targetFoodType === "sweet" || targetFoodType === "zunda_juice");
|
||||||
const waypoint = routeStates.has(this.state) && this.world?.findTarinaiPathWaypoint
|
const waypoint = ROUTE_STATES.has(this.state) && this.world?.findTarinaiPathWaypoint
|
||||||
? this.world.findTarinaiPathWaypoint(this, baseMoveTarget, { targetItem: routeTargetItem, padding: pathPadding, state: this.state, preferFullPath: zundaFoodTarget })
|
? this.world.findTarinaiPathWaypoint(this, baseMoveTarget, { targetItem: routeTargetItem, padding: pathPadding, state: this.state, preferFullPath: zundaFoodTarget })
|
||||||
: null;
|
: null;
|
||||||
if (this.state === "seek_bed" && this.target?.type === "grass_bed") {
|
if (this.state === "seek_bed" && this.target?.type === "grass_bed") {
|
||||||
|
|
@ -707,7 +815,9 @@
|
||||||
const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason, opts) : (reason || "\u4e8b\u6545");
|
const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason, opts) : (reason || "\u4e8b\u6545");
|
||||||
this.dead = true;
|
this.dead = true;
|
||||||
this.deathReason = finalReason;
|
this.deathReason = finalReason;
|
||||||
|
this.world.syncTarinaiCountEntry?.(this);
|
||||||
this.world.deadCount += 1;
|
this.world.deadCount += 1;
|
||||||
|
globalThis.TarinaiAchievements?.evaluateEvent?.(this.world, "population", { tarinai: this, delta: -1, reason: finalReason });
|
||||||
globalThis.TarinaiAchievements?.recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) });
|
globalThis.TarinaiAchievements?.recordDeath?.({ world: this.world, tarinai: this, reason: finalReason, fromDamage: Boolean(opts.fromDamage), achievementDangerKill: Boolean(opts.achievementDangerKill) });
|
||||||
this.world.lastDeathAt = this.world.time || 0;
|
this.world.lastDeathAt = this.world.time || 0;
|
||||||
if (!Array.isArray(this.world.recentDeathTimes)) this.world.recentDeathTimes = [];
|
if (!Array.isArray(this.world.recentDeathTimes)) this.world.recentDeathTimes = [];
|
||||||
|
|
@ -736,6 +846,7 @@
|
||||||
this.world.markDead(this, finalReason);
|
this.world.markDead(this, finalReason);
|
||||||
this.releasedLiveToken = this.liveToken || 0;
|
this.releasedLiveToken = this.liveToken || 0;
|
||||||
this.world.releaseTarinaiLiveId?.(this);
|
this.world.releaseTarinaiLiveId?.(this);
|
||||||
|
this.world.noteTarinaiDeathForRuntimeCleanup?.(this.id);
|
||||||
audio.death();
|
audio.death();
|
||||||
this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] });
|
this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u6b7b\u56e0: ${finalReason}`, "death", { participants: [this] });
|
||||||
if (this.world.selected === this) this.world.selected = null;
|
if (this.world.selected === this) this.world.selected = null;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
return pressure.ids.has(tarinai.id || tarinai.familyKey || tarinai);
|
return pressure.ids.has(tarinai.id || tarinai.familyKey || tarinai);
|
||||||
}
|
}
|
||||||
const radius = Math.max(150, (Number(tarinai.radius) || 22) + 190);
|
const radius = Math.max(150, (Number(tarinai.radius) || 22) + 190);
|
||||||
const source = worldRef.nearbyItems?.(tarinai.x || 0, tarinai.y || 0, radius, true) || worldRef.items || [];
|
const source = worldRef.nearbyNonGrassItems?.(tarinai.x || 0, tarinai.y || 0, radius, true) || worldRef.items || [];
|
||||||
for (const it of source) {
|
for (const it of source) {
|
||||||
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
|
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
|
||||||
const reach = global.TarinaiMechanicalSystem.reach(it) || Math.max(64, it.r || 64);
|
const reach = global.TarinaiMechanicalSystem.reach(it) || Math.max(64, it.r || 64);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ function bindUI() {
|
||||||
renderToolPalette();
|
renderToolPalette();
|
||||||
loadLogPushSettings();
|
loadLogPushSettings();
|
||||||
syncLogPushControls();
|
syncLogPushControls();
|
||||||
|
if (uiCache.logPushEnabled) window.setTimeout(() => window.TarinaiAchievements?.recordPushNotificationsEnabled?.({ world, source: "push-setting-load" }), 0);
|
||||||
syncAudioControls();
|
syncAudioControls();
|
||||||
syncVisualSettingsPanel();
|
syncVisualSettingsPanel();
|
||||||
window.TarinaiTooltips.applyToolPalette();
|
window.TarinaiTooltips.applyToolPalette();
|
||||||
|
|
@ -12,8 +13,17 @@ function bindUI() {
|
||||||
function isActiveToolMode() {
|
function isActiveToolMode() {
|
||||||
return Boolean(world?.tool && world.tool !== "observe");
|
return Boolean(world?.tool && world.tool !== "observe");
|
||||||
}
|
}
|
||||||
function clickedInsideGameOrToolUi(target) {
|
function clearSelectedToolSilently() {
|
||||||
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target) || ui.mobileModeControls?.contains?.(target) || ui.mobileRotateControls?.contains?.(target) || ui.backToGameBtn?.contains?.(target));
|
if (!isActiveToolMode()) return false;
|
||||||
|
world.pendingLinkEndpoint = null;
|
||||||
|
world.copyBuffer = null;
|
||||||
|
selectTool("observe");
|
||||||
|
for (const button of uiCache.toolButtons || []) button.classList.remove("selected");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function isInteractiveUiTarget(target) {
|
||||||
|
if (!target?.closest) return false;
|
||||||
|
return Boolean(target.closest("button, a, input, select, textarea, label, summary, [role='button'], [contenteditable='true'], [data-tool]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function panelSectionForTab(tab = "") {
|
function panelSectionForTab(tab = "") {
|
||||||
|
|
@ -160,14 +170,19 @@ function bindUI() {
|
||||||
const colonyLimitControls = (kind) => kind === "tarinai"
|
const colonyLimitControls = (kind) => kind === "tarinai"
|
||||||
? { slider: ui.tarinaiPopulationLimitSlider, input: ui.tarinaiPopulationLimitInput }
|
? { slider: ui.tarinaiPopulationLimitSlider, input: ui.tarinaiPopulationLimitInput }
|
||||||
: { slider: ui.objectLimitSlider, input: ui.objectLimitInput };
|
: { slider: ui.objectLimitSlider, input: ui.objectLimitInput };
|
||||||
|
const COLONY_LIMIT_MAX = 300;
|
||||||
|
const COLONY_LIMIT_INFINITY_SLOT = 301;
|
||||||
const syncColonyLimitControls = (kind, value) => {
|
const syncColonyLimitControls = (kind, value) => {
|
||||||
const { slider, input } = colonyLimitControls(kind);
|
const { slider, input } = colonyLimitControls(kind);
|
||||||
const limit = Math.max(0, Math.floor(Number(value || 0) || 0));
|
const rawLimit = Math.max(0, Math.floor(Number(value || 0) || 0));
|
||||||
if (input && document.activeElement !== input) input.value = limit > 0 ? String(limit) : "";
|
const limit = rawLimit > 0 ? Math.min(COLONY_LIMIT_MAX, rawLimit) : 0;
|
||||||
|
if (input && document.activeElement !== input) {
|
||||||
|
input.value = limit > 0 ? String(limit) : "";
|
||||||
|
input.setAttribute("aria-valuetext", limit > 0 ? String(limit) : "\u221e");
|
||||||
|
}
|
||||||
if (slider && document.activeElement !== slider) {
|
if (slider && document.activeElement !== slider) {
|
||||||
const max = Math.max(0, Number(slider.max || 0) || 0);
|
slider.value = String(limit > 0 ? limit : COLONY_LIMIT_INFINITY_SLOT);
|
||||||
slider.value = String(max > 0 ? Math.min(limit, max) : limit);
|
slider.setAttribute("aria-valuetext", limit > 0 ? String(limit) : "\u221e");
|
||||||
slider.setAttribute("aria-valuetext", limit > 0 ? String(limit) : "\u4e0a\u9650\u306a\u3057");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.syncColonyLimitControls = syncColonyLimitControls;
|
window.syncColonyLimitControls = syncColonyLimitControls;
|
||||||
|
|
@ -176,8 +191,9 @@ function bindUI() {
|
||||||
const { slider, input } = colonyLimitControls(kind);
|
const { slider, input } = colonyLimitControls(kind);
|
||||||
const sourceEl = String(source).startsWith("slider") ? slider : input;
|
const sourceEl = String(source).startsWith("slider") ? slider : input;
|
||||||
if (!sourceEl) return;
|
if (!sourceEl) return;
|
||||||
const raw = sourceEl.value?.trim?.() === "" ? 0 : Number(sourceEl.value);
|
const raw = sourceEl.value?.trim?.() === "" ? COLONY_LIMIT_INFINITY_SLOT : Number(sourceEl.value);
|
||||||
const value = Math.max(0, Math.floor(Number(raw) || 0));
|
const requested = Math.floor(Number(raw) || 0);
|
||||||
|
const value = requested <= 0 || requested >= COLONY_LIMIT_INFINITY_SLOT ? 0 : Math.min(COLONY_LIMIT_MAX, requested);
|
||||||
if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value);
|
if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value);
|
||||||
else world.setObjectLimit?.(value);
|
else world.setObjectLimit?.(value);
|
||||||
syncColonyLimitControls(kind, value);
|
syncColonyLimitControls(kind, value);
|
||||||
|
|
@ -350,12 +366,14 @@ function bindUI() {
|
||||||
audio.uiFold?.();
|
audio.uiFold?.();
|
||||||
const collapsed = !card.classList.contains("collapsed");
|
const collapsed = !card.classList.contains("collapsed");
|
||||||
markCollapsibleCardState(card, collapsed, { user: true });
|
markCollapsibleCardState(card, collapsed, { user: true });
|
||||||
|
if (!collapsed && card.classList.contains("colony-card")) renderStats?.({ force: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ui.colonyChartTabs?.addEventListener("click", (e) => {
|
ui.colonyChartTabs?.addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest("button[data-chart]");
|
const btn = e.target.closest("button[data-chart]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
uiCache.chartMode = btn.dataset.chart || "population";
|
uiCache.chartMode = btn.dataset.chart || "population";
|
||||||
|
window.TarinaiAchievements?.recordStatsButtonPress?.({ world, control: `chart:${uiCache.chartMode}` });
|
||||||
for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
|
for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
|
||||||
uiCache.lastChartDraw = "";
|
uiCache.lastChartDraw = "";
|
||||||
renderStats();
|
renderStats();
|
||||||
|
|
@ -368,6 +386,7 @@ function bindUI() {
|
||||||
if (!(uiCache.chartHiddenSeries instanceof Set)) uiCache.chartHiddenSeries = new Set();
|
if (!(uiCache.chartHiddenSeries instanceof Set)) uiCache.chartHiddenSeries = new Set();
|
||||||
if (uiCache.chartHiddenSeries.has(key)) uiCache.chartHiddenSeries.delete(key);
|
if (uiCache.chartHiddenSeries.has(key)) uiCache.chartHiddenSeries.delete(key);
|
||||||
else uiCache.chartHiddenSeries.add(key);
|
else uiCache.chartHiddenSeries.add(key);
|
||||||
|
window.TarinaiAchievements?.recordStatsButtonPress?.({ world, control: `series:${key}` });
|
||||||
uiCache.lastChartDraw = "";
|
uiCache.lastChartDraw = "";
|
||||||
audio.uiClick?.();
|
audio.uiClick?.();
|
||||||
renderStats();
|
renderStats();
|
||||||
|
|
@ -378,6 +397,7 @@ function bindUI() {
|
||||||
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
|
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
|
||||||
audio.notify?.();
|
audio.notify?.();
|
||||||
saveLogPushSettings();
|
saveLogPushSettings();
|
||||||
|
if (uiCache.logPushEnabled) window.TarinaiAchievements?.recordPushNotificationsEnabled?.({ world, source: "push-toggle" });
|
||||||
showToast(uiCache.logPushEnabled ? "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
showToast(uiCache.logPushEnabled ? "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||||
});
|
});
|
||||||
ui.logPushControls?.addEventListener("change", (e) => {
|
ui.logPushControls?.addEventListener("change", (e) => {
|
||||||
|
|
@ -534,15 +554,17 @@ function bindUI() {
|
||||||
showToast(on ? "\u52b9\u679c\u97f3\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u52b9\u679c\u97f3\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
showToast(on ? "\u52b9\u679c\u97f3\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u52b9\u679c\u97f3\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
||||||
});
|
});
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (ui.soundMenu?.contains(e.target) || ui.visualSettingsMenu?.contains(e.target)) return;
|
const target = e.target;
|
||||||
if (typeof closeSoundPanel === "function") closeSoundPanel();
|
if (!(ui.soundMenu?.contains(target) || ui.visualSettingsMenu?.contains(target))) {
|
||||||
closeVisualSettingsPanel();
|
if (typeof closeSoundPanel === "function") closeSoundPanel();
|
||||||
if (!ui.topMenu?.contains(e.target)) closeTopMenu();
|
closeVisualSettingsPanel();
|
||||||
});
|
if (!ui.topMenu?.contains(target)) closeTopMenu();
|
||||||
document.addEventListener("click", (e) => {
|
}
|
||||||
if (!isActiveToolMode()) return;
|
// Normal controls keep the selected tool. A blank UI click exits placement
|
||||||
if (clickedInsideGameOrToolUi(e.target)) return;
|
// mode; canvas clicks are still handled as tool actions.
|
||||||
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
|
if (isActiveToolMode() && !canvas?.contains?.(target) && !isInteractiveUiTarget(target)) {
|
||||||
|
clearSelectedToolSilently();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); ui.creditsDialog?.classList.remove("hidden"); });
|
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); closeTopMenu(); ui.creditsDialog?.classList.remove("hidden"); });
|
||||||
ui.creditsCloseBtn?.addEventListener("click", () => ui.creditsDialog?.classList.add("hidden"));
|
ui.creditsCloseBtn?.addEventListener("click", () => ui.creditsDialog?.classList.add("hidden"));
|
||||||
|
|
@ -627,10 +649,7 @@ function bindUI() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (tool === "clear_tool") {
|
if (tool === "clear_tool") {
|
||||||
world.pendingLinkEndpoint = null;
|
clearSelectedToolSilently();
|
||||||
world.copyBuffer = null;
|
|
||||||
selectTool("observe");
|
|
||||||
for (const b of uiCache.toolButtons || []) b.classList.remove("selected");
|
|
||||||
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
|
showToast("\u9053\u5177\u9078\u629E\u3092\u89E3\u9664\u3057\u307E\u3057\u305F\u3002");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,30 +18,73 @@ function colonyChartObjectKeys() {
|
||||||
return ["grass", "zunchi", "trace", "grass_bed", "water"];
|
return ["grass", "zunchi", "trace", "grass_bed", "water"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStats() {
|
function colonyStatsUiVisible() {
|
||||||
const alive = world.tarinai;
|
if (typeof document !== "undefined" && document.hidden) return false;
|
||||||
const isDiseased = (t) => Boolean(t?.zunchiDisease || t?.sleepDisease || t?.explosionDisease || t?.fightDisease);
|
const card = ui?.colonyChart?.closest?.(".colony-card") || null;
|
||||||
const lifeRatio = (t) => Math.max(0, Math.min(1, (Number(t?.age || 0) || 0) / Math.max(1, Number(t?.lifeSpan || 1) || 1)));
|
if (!card) return Boolean(ui?.colonyChart);
|
||||||
const sickCount = alive.reduce((n, t) => n + (isDiseased(t) ? 1 : 0), 0);
|
if (card.classList?.contains?.("hidden") || card.classList?.contains?.("collapsed")) return false;
|
||||||
const juvenileCount = alive.reduce((n, t) => n + (lifeRatio(t) <= 0.10 ? 1 : 0), 0);
|
return true;
|
||||||
const elderCount = alive.reduce((n, t) => n + (lifeRatio(t) >= 0.80 ? 1 : 0), 0);
|
}
|
||||||
const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0;
|
|
||||||
|
function collectDynamicColonyStats() {
|
||||||
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||||
const needAverages = Object.fromEntries(needKeys.map(key => [`need_${key}`, avg(t => Number(t.needs?.[key] || 0) || 0)]));
|
const needSums = Object.fromEntries(needKeys.map(key => [key, 0]));
|
||||||
const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15);
|
let liveCount = 0;
|
||||||
const values = {
|
let sickCount = 0;
|
||||||
pop: alive.length,
|
let juvenileCount = 0;
|
||||||
|
let elderCount = 0;
|
||||||
|
let stressSum = 0;
|
||||||
|
for (const t of world.tarinai || []) {
|
||||||
|
if (!t || t.dead) continue;
|
||||||
|
liveCount += 1;
|
||||||
|
if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) sickCount += 1;
|
||||||
|
const ratio = Math.max(0, Math.min(1, (Number(t.age || 0) || 0) / Math.max(1, Number(t.lifeSpan || 1) || 1)));
|
||||||
|
if (ratio <= 0.10) juvenileCount += 1;
|
||||||
|
if (ratio >= 0.80) elderCount += 1;
|
||||||
|
stressSum += Number(t.stress || 0) || 0;
|
||||||
|
for (const key of needKeys) needSums[key] += Number(t.needs?.[key] || 0) || 0;
|
||||||
|
}
|
||||||
|
const divisor = Math.max(1, liveCount);
|
||||||
|
const dynamic = {
|
||||||
sick: sickCount,
|
sick: sickCount,
|
||||||
juvenile: juvenileCount,
|
juvenile: juvenileCount,
|
||||||
elder: elderCount,
|
elder: elderCount,
|
||||||
|
stress: liveCount ? stressSum / divisor : 0,
|
||||||
|
ants: (world.ants || []).reduce((n, ant) => n + (ant && !ant.dead ? 1 : 0), 0),
|
||||||
|
...Object.fromEntries(needKeys.map(key => [`need_${key}`, liveCount ? needSums[key] / divisor : 0])),
|
||||||
|
};
|
||||||
|
world._colonyStatsDynamicCache = dynamic;
|
||||||
|
return dynamic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(options = {}) {
|
||||||
|
const colonyVisible = options.force === true || colonyStatsUiVisible();
|
||||||
|
// Selected-data and family views are separate surfaces. Keep them fresh
|
||||||
|
// without paying for colony aggregation while the colony card is hidden.
|
||||||
|
if (!colonyVisible) {
|
||||||
|
if (ui?.selectedCard && !ui.selectedCard.classList.contains("hidden")) renderSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = world.tarinaiCounts?.() || world.rebuildTarinaiCountCache?.("stats") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
|
||||||
|
const liveCount = Math.max(0, Number(counts.alive || 0) || 0);
|
||||||
|
const temp = world.currentTemperature ?? world.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15);
|
||||||
|
const dynamic = collectDynamicColonyStats();
|
||||||
|
const values = {
|
||||||
|
pop: liveCount,
|
||||||
|
zunchiSlaves: Math.max(0, Number(counts.zunchiSlaves || 0) || 0),
|
||||||
|
tarinaiKings: Math.max(0, Number(counts.tarinaiKings || 0) || 0),
|
||||||
|
sick: dynamic.sick || 0,
|
||||||
|
juvenile: dynamic.juvenile || 0,
|
||||||
|
elder: dynamic.elder || 0,
|
||||||
dead: world.deadCount,
|
dead: world.deadCount,
|
||||||
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
|
birthEvents: Math.max(0, Number(world.birthEventCount || 0) || 0),
|
||||||
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
|
deathEvents: Math.max(0, Number(world.deadCount || 0) || 0),
|
||||||
stress: avg(t => t.stress),
|
stress: dynamic.stress || 0,
|
||||||
temperature: Number(temp) || 0,
|
temperature: Number(temp) || 0,
|
||||||
ants: (world.ants || []).reduce((n, ant) => n + (ant && !ant.dead ? 1 : 0), 0),
|
ants: dynamic.ants || 0,
|
||||||
...needAverages,
|
...dynamic,
|
||||||
objects: { ...(world.itemCounts || {}) },
|
objects: world.itemCounts || {},
|
||||||
};
|
};
|
||||||
setTextIfChanged(ui.statSick, "sick", values.sick);
|
setTextIfChanged(ui.statSick, "sick", values.sick);
|
||||||
setTextIfChanged(ui.statJuvenile, "juvenile", values.juvenile);
|
setTextIfChanged(ui.statJuvenile, "juvenile", values.juvenile);
|
||||||
|
|
@ -58,7 +101,7 @@ function renderStats() {
|
||||||
if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false;
|
if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false;
|
||||||
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
|
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
|
||||||
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
|
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
|
||||||
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length;
|
const objectCount = world.activeObjectCount?.() ?? 0;
|
||||||
if (typeof window.syncColonyLimitControls === "function") {
|
if (typeof window.syncColonyLimitControls === "function") {
|
||||||
window.syncColonyLimitControls("tarinai", tarinaiLimit);
|
window.syncColonyLimitControls("tarinai", tarinaiLimit);
|
||||||
window.syncColonyLimitControls("object", objectLimit);
|
window.syncColonyLimitControls("object", objectLimit);
|
||||||
|
|
@ -72,7 +115,7 @@ function renderStats() {
|
||||||
window.TarinaiGroundUI.update(world);
|
window.TarinaiGroundUI.update(world);
|
||||||
updateColonyHistory(values);
|
updateColonyHistory(values);
|
||||||
drawColonyChart(values);
|
drawColonyChart(values);
|
||||||
renderSelected();
|
if (ui?.selectedCard && !ui.selectedCard.classList.contains("hidden")) renderSelected();
|
||||||
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +145,8 @@ function updateColonyHistory(values) {
|
||||||
count: 0,
|
count: 0,
|
||||||
sums: {
|
sums: {
|
||||||
pop: 0,
|
pop: 0,
|
||||||
|
zunchiSlaves: 0,
|
||||||
|
tarinaiKings: 0,
|
||||||
sick: 0,
|
sick: 0,
|
||||||
juvenile: 0,
|
juvenile: 0,
|
||||||
elder: 0,
|
elder: 0,
|
||||||
|
|
@ -124,6 +169,8 @@ function updateColonyHistory(values) {
|
||||||
const row = {
|
const row = {
|
||||||
t: bucket.end,
|
t: bucket.end,
|
||||||
pop: bucket.sums.pop / bucket.count,
|
pop: bucket.sums.pop / bucket.count,
|
||||||
|
zunchiSlaves: bucket.sums.zunchiSlaves / bucket.count,
|
||||||
|
tarinaiKings: bucket.sums.tarinaiKings / bucket.count,
|
||||||
sick: bucket.sums.sick / bucket.count,
|
sick: bucket.sums.sick / bucket.count,
|
||||||
juvenile: bucket.sums.juvenile / bucket.count,
|
juvenile: bucket.sums.juvenile / bucket.count,
|
||||||
elder: bucket.sums.elder / bucket.count,
|
elder: bucket.sums.elder / bucket.count,
|
||||||
|
|
@ -154,6 +201,8 @@ function updateColonyHistory(values) {
|
||||||
uiCache.chartDeathEventSeen = currentDeathEvents;
|
uiCache.chartDeathEventSeen = currentDeathEvents;
|
||||||
bucket.count += 1;
|
bucket.count += 1;
|
||||||
bucket.sums.pop += values.pop || 0;
|
bucket.sums.pop += values.pop || 0;
|
||||||
|
bucket.sums.zunchiSlaves += values.zunchiSlaves || 0;
|
||||||
|
bucket.sums.tarinaiKings += values.tarinaiKings || 0;
|
||||||
bucket.sums.sick += values.sick || 0;
|
bucket.sums.sick += values.sick || 0;
|
||||||
bucket.sums.juvenile += values.juvenile || 0;
|
bucket.sums.juvenile += values.juvenile || 0;
|
||||||
bucket.sums.elder += values.elder || 0;
|
bucket.sums.elder += values.elder || 0;
|
||||||
|
|
@ -174,6 +223,8 @@ function chartRows(values) {
|
||||||
return [{
|
return [{
|
||||||
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
|
t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0),
|
||||||
pop: 0,
|
pop: 0,
|
||||||
|
zunchiSlaves: 0,
|
||||||
|
tarinaiKings: 0,
|
||||||
sick: 0,
|
sick: 0,
|
||||||
juvenile: 0,
|
juvenile: 0,
|
||||||
elder: 0,
|
elder: 0,
|
||||||
|
|
@ -320,6 +371,8 @@ function chartSeries(mode = "population") {
|
||||||
{ key: "elder", label: "\u8001\u4f53", color: "#8a7054" },
|
{ key: "elder", label: "\u8001\u4f53", color: "#8a7054" },
|
||||||
{ key: "births", label: "\u8a95\u751f\u6570", color: "#d48ac2" },
|
{ key: "births", label: "\u8a95\u751f\u6570", color: "#d48ac2" },
|
||||||
{ key: "deaths", label: "\u6b7b\u4ea1\u6570", color: "#4d4d56" },
|
{ key: "deaths", label: "\u6b7b\u4ea1\u6570", color: "#4d4d56" },
|
||||||
|
{ key: "zunchiSlaves", label: "\u305a\u3093\u3061\u3069\u308c\u3044", color: "#8f6a44" },
|
||||||
|
{ key: "tarinaiKings", label: "\u305f\u308a\u306a\u3044\u738b", color: "#d4a62a" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
const actions = global.TarinaiPointerActionSystem.create(ctx);
|
const actions = global.TarinaiPointerActionSystem.create(ctx);
|
||||||
|
|
||||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
window.addEventListener("contextmenu", (e) => {
|
||||||
|
const now = performance?.now?.() ?? Date.now();
|
||||||
|
if (uiCache.panning || now <= Number(uiCache.suppressContextMenuUntil || 0)) e.preventDefault();
|
||||||
|
}, true);
|
||||||
canvas.addEventListener("mousedown", (e) => {
|
canvas.addEventListener("mousedown", (e) => {
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
const p = ctx.screenToWorldEvent(e);
|
const p = ctx.screenToWorldEvent(e);
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,7 @@
|
||||||
|
|
||||||
function beginPan(clientX, clientY, button = null) {
|
function beginPan(clientX, clientY, button = null) {
|
||||||
uiCache.panning = true;
|
uiCache.panning = true;
|
||||||
|
if (button === 2) uiCache.suppressContextMenuUntil = 0;
|
||||||
uiCache.panMoved = false;
|
uiCache.panMoved = false;
|
||||||
uiCache.panButton = button;
|
uiCache.panButton = button;
|
||||||
uiCache.panStartX = clientX;
|
uiCache.panStartX = clientX;
|
||||||
|
|
@ -560,8 +561,11 @@
|
||||||
function endPan(button = null) {
|
function endPan(button = null) {
|
||||||
if (!uiCache.panning) return false;
|
if (!uiCache.panning) return false;
|
||||||
if (uiCache.panButton !== null && button !== null && button !== uiCache.panButton) return false;
|
if (uiCache.panButton !== null && button !== null && button !== uiCache.panButton) return false;
|
||||||
|
const endedButton = uiCache.panButton;
|
||||||
|
const moved = Boolean(uiCache.panMoved);
|
||||||
uiCache.panning = false;
|
uiCache.panning = false;
|
||||||
uiCache.panButton = null;
|
uiCache.panButton = null;
|
||||||
|
if (endedButton === 2 && moved) uiCache.suppressContextMenuUntil = (performance?.now?.() ?? Date.now()) + 650;
|
||||||
canvas.style.cursor = cursorForTool(world.tool);
|
canvas.style.cursor = cursorForTool(world.tool);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const APP_VERSION = "39.16.75";
|
const APP_VERSION = "39.16.98";
|
||||||
const APP_BUILD = "tarinai-ui-v39-16-75";
|
const APP_BUILD = "tarinai-ui-v39-16-95";
|
||||||
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||||
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
||||||
|
|
||||||
|
|
|
||||||
11
js/world.js
11
js/world.js
|
|
@ -7,6 +7,7 @@ class World { constructor() {
|
||||||
this.viewportH = 720;
|
this.viewportH = 720;
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
this.day = 1;
|
this.day = 1;
|
||||||
|
this.elapsedDays = 0;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.speed = 1;
|
this.speed = 1;
|
||||||
this.tool = "observe";
|
this.tool = "observe";
|
||||||
|
|
@ -55,6 +56,7 @@ class World { constructor() {
|
||||||
this.spatialFoodScratch = [];
|
this.spatialFoodScratch = [];
|
||||||
this.spatialHazardScratch = [];
|
this.spatialHazardScratch = [];
|
||||||
this.spatialVersion = 0;
|
this.spatialVersion = 0;
|
||||||
|
this.routingObstacleVersion = 0;
|
||||||
this.spatialDirty = true;
|
this.spatialDirty = true;
|
||||||
this.terrainDirty = true;
|
this.terrainDirty = true;
|
||||||
this.terrainVersion = 1;
|
this.terrainVersion = 1;
|
||||||
|
|
@ -92,6 +94,7 @@ class World { constructor() {
|
||||||
this.achievementNaturalSlaveGenerations = [];
|
this.achievementNaturalSlaveGenerations = [];
|
||||||
this.achievementNaturalKingGenerations = [];
|
this.achievementNaturalKingGenerations = [];
|
||||||
this.achievementPlayerPlacementCount = 0;
|
this.achievementPlayerPlacementCount = 0;
|
||||||
|
this.achievementManualTarinaiAddedCount = 0;
|
||||||
this.achievementDirectFeedCount = 0;
|
this.achievementDirectFeedCount = 0;
|
||||||
this.achievementRobotCleanCount = 0;
|
this.achievementRobotCleanCount = 0;
|
||||||
this.achievementEnemyAntKills = 0;
|
this.achievementEnemyAntKills = 0;
|
||||||
|
|
@ -114,6 +117,14 @@ class World { constructor() {
|
||||||
this.relationNotices = {};
|
this.relationNotices = {};
|
||||||
this.resolvedFightIds = {};
|
this.resolvedFightIds = {};
|
||||||
this.fightPairCooldowns = {};
|
this.fightPairCooldowns = {};
|
||||||
|
this.deadTarinaiSinceRuntimePrune = 0;
|
||||||
|
this.tarinaiRuntimePrunePending = false;
|
||||||
|
this._deadTarinaiIdsPendingCleanup = new Set();
|
||||||
|
// Hot colony counts are maintained by mutation hooks. Rebuild only after
|
||||||
|
// bulk restore/reset paths that bypass the normal add/remove helpers.
|
||||||
|
this._tarinaiCountCache = { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
|
||||||
|
this._tarinaiCountDirty = false;
|
||||||
|
this._colonyStatsDynamicCache = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,68 @@
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const World = global.World;
|
const World = global.World;
|
||||||
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
|
if (!World) throw new Error("World is not available for mixin: world_combat_effects.js");
|
||||||
|
const EFFECT_POOL_TYPES = new Set(["ring", "fight", "flame", "fall"]);
|
||||||
|
const EFFECT_POOL_LIMIT_PER_TYPE = 96;
|
||||||
|
const effectPool = new Map();
|
||||||
|
|
||||||
|
function acquireEffect(type, x, y, options) {
|
||||||
|
const key = String(type || "");
|
||||||
|
const bucket = EFFECT_POOL_TYPES.has(key) ? effectPool.get(key) : null;
|
||||||
|
const effect = bucket?.length ? bucket.pop() : new Effect(key, x, y, options);
|
||||||
|
if (bucket?.length >= 0) effect.reset(key, x, y, options);
|
||||||
|
return effect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseEffect(effect) {
|
||||||
|
const key = String(effect?.type || "");
|
||||||
|
if (!effect || effect._effectPooled || !EFFECT_POOL_TYPES.has(key)) return false;
|
||||||
|
let bucket = effectPool.get(key);
|
||||||
|
if (!bucket) effectPool.set(key, bucket = []);
|
||||||
|
if (bucket.length >= EFFECT_POOL_LIMIT_PER_TYPE) return false;
|
||||||
|
effect._effectPooled = true;
|
||||||
|
bucket.push(effect);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||||
|
spawnEffect(type, x, y, options = {}) {
|
||||||
|
const opts = options || {};
|
||||||
|
const importance = Math.max(0, Math.min(3, Number(opts.importance ?? (type === "explosion" || type === "electric_shock" || type === "bubble" || type === "heart" ? 3 : type === "ring" || type === "fall" ? 2 : 1))));
|
||||||
|
if (importance <= 1 && opts.allowOffscreen !== true) {
|
||||||
|
const margin = Math.max(80, Number(opts.offscreenMargin || 140));
|
||||||
|
const vw = typeof this.visibleWorldW === "function" ? this.visibleWorldW() : this.viewportW || this.w;
|
||||||
|
const vh = typeof this.visibleWorldH === "function" ? this.visibleWorldH() : this.viewportH || this.h;
|
||||||
|
const left = Number(this.cameraX || 0) - margin;
|
||||||
|
const top = Number(this.cameraY || 0) - margin;
|
||||||
|
if (x < left || y < top || x > left + vw + margin * 2 || y > top + vh + margin * 2) {
|
||||||
|
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
|
this._effectSpawnStats = this._effectSpawnStats || {};
|
||||||
|
this._effectSpawnStats.offscreenCulled = (this._effectSpawnStats.offscreenCulled || 0) + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const effect = acquireEffect(type, x, y, opts);
|
||||||
|
effect._effectImportance = importance;
|
||||||
|
this.effects.push(effect);
|
||||||
|
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
|
this._effectSpawnStats = this._effectSpawnStats || {};
|
||||||
|
this._effectSpawnStats.requested = (this._effectSpawnStats.requested || 0) + 1;
|
||||||
|
if (EFFECT_POOL_TYPES.has(String(type || ""))) this._effectSpawnStats.poolEligible = (this._effectSpawnStats.poolEligible || 0) + 1;
|
||||||
|
}
|
||||||
|
return effect;
|
||||||
|
},
|
||||||
|
|
||||||
|
releaseEffect(effect) {
|
||||||
|
const released = releaseEffect(effect);
|
||||||
|
if (released) {
|
||||||
|
if (global.TarinaiPerf?.diagnosticsEnabled?.() === true) {
|
||||||
|
this._effectSpawnStats = this._effectSpawnStats || {};
|
||||||
|
this._effectSpawnStats.pooled = (this._effectSpawnStats.pooled || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
},
|
||||||
damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) {
|
damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) {
|
||||||
let hit = 0;
|
let hit = 0;
|
||||||
const candidates = this.nearbyAnts?.(x, y, radius + 24, true) || this.ants || [];
|
const candidates = this.nearbyAnts?.(x, y, radius + 24, true) || this.ants || [];
|
||||||
|
|
@ -68,7 +129,7 @@
|
||||||
pin.spinVelocity = clamp((pin.spinVelocity || 0) + deterministicRange(this, `${salt}-pin-spin`, -18, 18, source || pin, pin), -38, 38);
|
pin.spinVelocity = clamp((pin.spinVelocity || 0) + deterministicRange(this, `${salt}-pin-spin`, -18, 18, source || pin, pin), -38, 38);
|
||||||
pin.noStickUntil = (this.time || 0) + 0.22;
|
pin.noStickUntil = (this.time || 0) + 0.22;
|
||||||
pin.lastPokedAt = this.time || 0;
|
pin.lastPokedAt = this.time || 0;
|
||||||
this.effects.push(new Effect("ring", pin.x, pin.y, { size: Math.max(12, (pin.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.46)" }));
|
this.spawnEffect("ring", pin.x, pin.y, { size: Math.max(12, (pin.r || 8) * 1.8), life: 0.18, color: "rgba(255,230,116,0.46)" });
|
||||||
blastedPins += 1;
|
blastedPins += 1;
|
||||||
}
|
}
|
||||||
return blastedPins;
|
return blastedPins;
|
||||||
|
|
@ -87,22 +148,22 @@
|
||||||
it.amount = 0;
|
it.amount = 0;
|
||||||
const x = it.x, y = it.y;
|
const x = it.x, y = it.y;
|
||||||
const blastRadius = 240 * (it.blastScale || 1);
|
const blastRadius = 240 * (it.blastScale || 1);
|
||||||
this.effects.push(new Effect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" }));
|
this.spawnEffect("explosion", x, y, { size: 86, life: 1.05, color: "rgba(255,182,66,0.92)" });
|
||||||
this.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" }));
|
this.spawnEffect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" });
|
||||||
for (let r = 0; r < 4; r++) {
|
for (let r = 0; r < 4; r++) {
|
||||||
this.effects.push(new Effect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
|
this.spawnEffect("ring", x, y, { size: 30 + r * 24, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" });
|
||||||
}
|
}
|
||||||
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
|
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
|
||||||
for (let i = 0; i < 34; i++) {
|
for (let i = 0; i < 34; i++) {
|
||||||
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "firecracker-spark-angle", -0.10, 0.10, it, i);
|
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "firecracker-spark-angle", -0.10, 0.10, it, i);
|
||||||
const speed = deterministicRange(this, "firecracker-spark-speed", 90, 260, it, i);
|
const speed = deterministicRange(this, "firecracker-spark-speed", 90, 260, it, i);
|
||||||
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "firecracker-spark-x", 2, 22, it, i), y + Math.sin(a) * deterministicRange(this, "firecracker-spark-y", 2, 22, it, i), {
|
this.spawnEffect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "firecracker-spark-x", 2, 22, it, i), y + Math.sin(a) * deterministicRange(this, "firecracker-spark-y", 2, 22, it, i), {
|
||||||
vx: Math.cos(a) * speed,
|
vx: Math.cos(a) * speed,
|
||||||
vy: Math.sin(a) * speed,
|
vy: Math.sin(a) * speed,
|
||||||
size: deterministicRange(this, "firecracker-spark-size", 8, i % 3 === 0 ? 24 : 20, it, i),
|
size: deterministicRange(this, "firecracker-spark-size", 8, i % 3 === 0 ? 24 : 20, it, i),
|
||||||
life: deterministicRange(this, "firecracker-spark-life", 0.26, 0.72, it, i),
|
life: deterministicRange(this, "firecracker-spark-life", 0.26, 0.72, it, i),
|
||||||
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
|
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
for (const t of this.tarinai) {
|
for (const t of this.tarinai) {
|
||||||
if (t.dead) continue;
|
if (t.dead) continue;
|
||||||
|
|
@ -177,7 +238,7 @@
|
||||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "firecracker-ball-spin", -24, 24, it, ball), -38, 38);
|
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "firecracker-ball-spin", -24, 24, it, ball), -38, 38);
|
||||||
ball.lastPokedAt = this.time || 0;
|
ball.lastPokedAt = this.time || 0;
|
||||||
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
|
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
|
||||||
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
|
this.spawnEffect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" });
|
||||||
blastedBalls += 1;
|
blastedBalls += 1;
|
||||||
}
|
}
|
||||||
const blastedPins = this.blastLoosePinsFrom(x, y, blastRadius, it, it.blastScale || 1, "firecracker");
|
const blastedPins = this.blastLoosePinsFrom(x, y, blastRadius, it, it.blastScale || 1, "firecracker");
|
||||||
|
|
@ -193,22 +254,22 @@
|
||||||
const blastRadius = 240 * blastScale;
|
const blastRadius = 240 * blastScale;
|
||||||
t.explosionDisease = false;
|
t.explosionDisease = false;
|
||||||
t.explosionDiseaseTimer = 0;
|
t.explosionDiseaseTimer = 0;
|
||||||
this.effects.push(new Effect("explosion", x, y, { size: 86 * blastScale, life: 1.05, color: "rgba(255,182,66,0.92)" }));
|
this.spawnEffect("explosion", x, y, { size: 86 * blastScale, life: 1.05, color: "rgba(255,182,66,0.92)" });
|
||||||
this.effects.push(new Effect("explosion", x, y, { size: 128 * blastScale, life: 0.82, color: "rgba(255,92,42,0.70)" }));
|
this.spawnEffect("explosion", x, y, { size: 128 * blastScale, life: 0.82, color: "rgba(255,92,42,0.70)" });
|
||||||
for (let r = 0; r < 4; r++) {
|
for (let r = 0; r < 4; r++) {
|
||||||
this.effects.push(new Effect("ring", x, y, { size: (30 + r * 24) * blastScale, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" }));
|
this.spawnEffect("ring", x, y, { size: (30 + r * 24) * blastScale, life: 0.48 + r * 0.11, color: r % 2 ? "rgba(255,116,62,0.78)" : "rgba(255,240,124,0.88)" });
|
||||||
}
|
}
|
||||||
this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale);
|
this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale);
|
||||||
for (let i = 0; i < 34; i++) {
|
for (let i = 0; i < 34; i++) {
|
||||||
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
|
const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
|
||||||
const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
|
const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
|
||||||
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "disease-explosion-spark-x", 2, 22, t, i) * blastScale, y + Math.sin(a) * deterministicRange(this, "disease-explosion-spark-y", 2, 22, t, i) * blastScale, {
|
this.spawnEffect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "disease-explosion-spark-x", 2, 22, t, i) * blastScale, y + Math.sin(a) * deterministicRange(this, "disease-explosion-spark-y", 2, 22, t, i) * blastScale, {
|
||||||
vx: Math.cos(a) * speed,
|
vx: Math.cos(a) * speed,
|
||||||
vy: Math.sin(a) * speed,
|
vy: Math.sin(a) * speed,
|
||||||
size: deterministicRange(this, "disease-explosion-spark-size", 8, i % 3 === 0 ? 24 : 20, t, i) * blastScale,
|
size: deterministicRange(this, "disease-explosion-spark-size", 8, i % 3 === 0 ? 24 : 20, t, i) * blastScale,
|
||||||
life: deterministicRange(this, "disease-explosion-spark-life", 0.26, 0.72, t, i),
|
life: deterministicRange(this, "disease-explosion-spark-life", 0.26, 0.72, t, i),
|
||||||
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
|
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
for (const o of this.tarinai) {
|
for (const o of this.tarinai) {
|
||||||
if (!o || o.dead || o === t) continue;
|
if (!o || o.dead || o === t) continue;
|
||||||
|
|
@ -272,7 +333,7 @@
|
||||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38);
|
ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38);
|
||||||
ball.lastPokedAt = this.time || 0;
|
ball.lastPokedAt = this.time || 0;
|
||||||
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
|
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
|
||||||
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
|
this.spawnEffect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" });
|
||||||
}
|
}
|
||||||
this.blastLoosePinsFrom(x, y, blastRadius, t, blastScale, "disease-explosion");
|
this.blastLoosePinsFrom(x, y, blastRadius, t, blastScale, "disease-explosion");
|
||||||
t.die("\u7206\u767a\u75c5");
|
t.die("\u7206\u767a\u75c5");
|
||||||
|
|
@ -358,7 +419,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deterministicChance(this, "water-hose-ring", 0.72, x, y)) {
|
if (deterministicChance(this, "water-hose-ring", 0.72, x, y)) {
|
||||||
this.effects.push(new Effect("ring", x + deterministicRange(this, "water-hose-ring-x", -18, 18, x, y), y + deterministicRange(this, "water-hose-ring-y", -12, 12, x, y), { size: deterministicRange(this, "water-hose-ring-size", 8, 22, x, y), life: deterministicRange(this, "water-hose-ring-life", 0.18, 0.32, x, y), color: "rgba(128,204,238,0.60)" }));
|
this.spawnEffect("ring", x + deterministicRange(this, "water-hose-ring-x", -18, 18, x, y), y + deterministicRange(this, "water-hose-ring-y", -12, 12, x, y), { size: deterministicRange(this, "water-hose-ring-size", 8, 22, x, y), life: deterministicRange(this, "water-hose-ring-life", 0.18, 0.32, x, y), color: "rgba(128,204,238,0.60)" });
|
||||||
}
|
}
|
||||||
if (cleaned > 0.25) {
|
if (cleaned > 0.25) {
|
||||||
this.drawListDirty = true;
|
this.drawListDirty = true;
|
||||||
|
|
@ -445,60 +506,60 @@
|
||||||
if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
|
if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return;
|
||||||
if (text === "z") text = "Zzz...";
|
if (text === "z") text = "Zzz...";
|
||||||
audio.bubble(text);
|
audio.bubble(text);
|
||||||
this.effects.push(new Effect("bubble", x, y, {
|
this.spawnEffect("bubble", x, y, {
|
||||||
vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
|
vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
|
||||||
vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
|
vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
|
||||||
life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
|
life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
|
||||||
size: 8,
|
size: 8,
|
||||||
text,
|
text,
|
||||||
color,
|
color,
|
||||||
}));
|
});
|
||||||
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
|
this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
spawnHeadbuttEffect(x, y) {
|
spawnHeadbuttEffect(x, y) {
|
||||||
this.effects.push(new Effect("fight", x, y, {
|
this.spawnEffect("fight", x, y, {
|
||||||
size: deterministicRange(this, "headbutt-effect-size", 10, 16, x, y),
|
size: deterministicRange(this, "headbutt-effect-size", 10, 16, x, y),
|
||||||
life: deterministicRange(this, "headbutt-effect-life", 0.20, 0.34, x, y),
|
life: deterministicRange(this, "headbutt-effect-life", 0.20, 0.34, x, y),
|
||||||
color: "rgba(105, 62, 34, 0.86)",
|
color: "rgba(105, 62, 34, 0.86)",
|
||||||
}));
|
});
|
||||||
if (deterministicChance(this, "headbutt-audio", 0.5, x, y)) audio.fight();
|
if (deterministicChance(this, "headbutt-audio", 0.5, x, y)) audio.fight();
|
||||||
},
|
},
|
||||||
|
|
||||||
spawnFallEffect(x, y, scale = 1) {
|
spawnFallEffect(x, y, scale = 1) {
|
||||||
if ((this.effectCounts.fall || 0) > 18) return;
|
if ((this.effectCounts.fall || 0) > 18) return;
|
||||||
this.effects.push(new Effect("fall", x, y, {
|
this.spawnEffect("fall", x, y, {
|
||||||
vx: deterministicRange(this, "fall-effect-vx", -12, 12, x, y),
|
vx: deterministicRange(this, "fall-effect-vx", -12, 12, x, y),
|
||||||
vy: deterministicRange(this, "fall-effect-vy", -4, 3, x, y),
|
vy: deterministicRange(this, "fall-effect-vy", -4, 3, x, y),
|
||||||
size: deterministicRange(this, "fall-effect-size", 20, 32, x, y) * scale,
|
size: deterministicRange(this, "fall-effect-size", 20, 32, x, y) * scale,
|
||||||
life: deterministicRange(this, "fall-effect-life", 0.48, 0.72, x, y),
|
life: deterministicRange(this, "fall-effect-life", 0.48, 0.72, x, y),
|
||||||
color: "rgba(154, 124, 80, 0.58)",
|
color: "rgba(154, 124, 80, 0.58)",
|
||||||
}));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
spawnEatEffect(x, y, color = "#f1dfb7") {
|
spawnEatEffect(x, y, color = "#f1dfb7") {
|
||||||
if (deterministicChance(this, "eat-effect", 0.70, x, y, color)) {
|
if (deterministicChance(this, "eat-effect", 0.70, x, y, color)) {
|
||||||
this.effects.push(new Effect("eat", x + deterministicRange(this, "eat-effect-x", -4, 4, x, y, color), y + deterministicRange(this, "eat-effect-y", -4, 4, x, y, color), {
|
this.spawnEffect("eat", x + deterministicRange(this, "eat-effect-x", -4, 4, x, y, color), y + deterministicRange(this, "eat-effect-y", -4, 4, x, y, color), {
|
||||||
vx: deterministicRange(this, "eat-effect-vx", -10, 10, x, y, color),
|
vx: deterministicRange(this, "eat-effect-vx", -10, 10, x, y, color),
|
||||||
vy: deterministicRange(this, "eat-effect-vy", -18, -3, x, y, color),
|
vy: deterministicRange(this, "eat-effect-vy", -18, -3, x, y, color),
|
||||||
size: deterministicRange(this, "eat-effect-size", 2.2, 4.2, x, y, color),
|
size: deterministicRange(this, "eat-effect-size", 2.2, 4.2, x, y, color),
|
||||||
life: deterministicRange(this, "eat-effect-life", 0.18, 0.32, x, y, color),
|
life: deterministicRange(this, "eat-effect-life", 0.18, 0.32, x, y, color),
|
||||||
color,
|
color,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
if (deterministicChance(this, "eat-ring", 0.08, x, y, color)) {
|
if (deterministicChance(this, "eat-ring", 0.08, x, y, color)) {
|
||||||
this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color }));
|
this.spawnEffect("ring", x, y, { size: 5, life: 0.20, color });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
spawnBleedEffect(x, y) {
|
spawnBleedEffect(x, y) {
|
||||||
this.effects.push(new Effect("bleed", x + deterministicRange(this, "bleed-effect-x", -3, 3, x, y), y + deterministicRange(this, "bleed-effect-y", -2, 3, x, y), {
|
this.spawnEffect("bleed", x + deterministicRange(this, "bleed-effect-x", -3, 3, x, y), y + deterministicRange(this, "bleed-effect-y", -2, 3, x, y), {
|
||||||
vx: deterministicRange(this, "bleed-effect-vx", -8, 8, x, y),
|
vx: deterministicRange(this, "bleed-effect-vx", -8, 8, x, y),
|
||||||
vy: deterministicRange(this, "bleed-effect-vy", 3, 14, x, y),
|
vy: deterministicRange(this, "bleed-effect-vy", 3, 14, x, y),
|
||||||
size: deterministicRange(this, "bleed-effect-size", 2.0, 3.8, x, y),
|
size: deterministicRange(this, "bleed-effect-size", 2.0, 3.8, x, y),
|
||||||
life: deterministicRange(this, "bleed-effect-life", 0.34, 0.62, x, y),
|
life: deterministicRange(this, "bleed-effect-life", 0.34, 0.62, x, y),
|
||||||
color: "rgba(80, 172, 55, 0.78)",
|
color: "rgba(80, 172, 55, 0.78)",
|
||||||
}));
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
isSleepFurniture(it) {
|
isSleepFurniture(it) {
|
||||||
|
|
@ -735,7 +796,7 @@
|
||||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * ((isBalloon ? 2.4 : 5.6) + ball.pokeCombo * (isBalloon ? 0.55 : 1.15)), -38, 38);
|
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * ((isBalloon ? 2.4 : 5.6) + ball.pokeCombo * (isBalloon ? 0.55 : 1.15)), -38, 38);
|
||||||
ball.amount = Math.max(ball.amount || 999, 999);
|
ball.amount = Math.max(ball.amount || 999, 999);
|
||||||
audio.ballHit?.();
|
audio.ballHit?.();
|
||||||
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" }));
|
this.spawnEffect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" });
|
||||||
if (!isBalloon) this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" });
|
if (!isBalloon) this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,10 @@
|
||||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||||
nearest(entity, types, maxDist = Infinity) {
|
nearest(entity, types, maxDist = Infinity) {
|
||||||
let best = null, bestD = maxDist;
|
let best = null, bestD = maxDist;
|
||||||
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
|
const includeGrass = Array.isArray(types) && types.includes("grass");
|
||||||
|
const candidates = Number.isFinite(maxDist)
|
||||||
|
? (includeGrass ? this.nearbyItems(entity.x, entity.y, maxDist) : (this.nearbyNonGrassItems?.(entity.x, entity.y, maxDist) || this.nearbyItems(entity.x, entity.y, maxDist)))
|
||||||
|
: this.items;
|
||||||
for (const it of candidates) {
|
for (const it of candidates) {
|
||||||
if (it.dead || !types.includes(it.type)) continue;
|
if (it.dead || !types.includes(it.type)) continue;
|
||||||
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
|
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue;
|
||||||
|
|
@ -574,7 +577,7 @@
|
||||||
const qr = Math.ceil(inflatedRadius / q);
|
const qr = Math.ceil(inflatedRadius / q);
|
||||||
const cacheLimit = Math.min(72, Math.max(limit + 6, Math.ceil(limit * 1.35)));
|
const cacheLimit = Math.min(72, Math.max(limit + 6, Math.ceil(limit * 1.35)));
|
||||||
const cacheKey = cacheable
|
const cacheKey = cacheable
|
||||||
? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`
|
? `${this.routingObstacleVersion || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`
|
||||||
: "";
|
: "";
|
||||||
if (cacheable) {
|
if (cacheable) {
|
||||||
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
||||||
|
|
@ -775,9 +778,10 @@
|
||||||
const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8;
|
const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8;
|
||||||
const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, hitRadius * 2.8) : Math.max(10, hitRadius * 0.92);
|
const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, hitRadius * 2.8) : Math.max(10, hitRadius * 0.92);
|
||||||
const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush);
|
const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush);
|
||||||
const push = r.oriented && insidePenetration > 0
|
const rawPush = r.oriented && insidePenetration > 0
|
||||||
? Math.min(insidePenetration + hitRadius + slop, maxPush)
|
? Math.min(insidePenetration + hitRadius + slop, maxPush)
|
||||||
: Math.min(hitRadius - d + slop, maxPush);
|
: Math.min(hitRadius - d + slop, maxPush);
|
||||||
|
const push = rawPush;
|
||||||
t.x += nx * push;
|
t.x += nx * push;
|
||||||
t.y += ny * push;
|
t.y += ny * push;
|
||||||
t.lastSolidObstacleCollisionSource = r.item || null;
|
t.lastSolidObstacleCollisionSource = r.item || null;
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
const p = worldRef.pointer;
|
const p = worldRef.pointer;
|
||||||
if (!p?.inside) return null;
|
if (!p?.inside) return null;
|
||||||
let best = null, bestD = Infinity;
|
let best = null, bestD = Infinity;
|
||||||
for (const it of worldRef.nearbyItems(p.x, p.y, 140)) {
|
for (const it of worldRef.nearbyNonGrassItems?.(p.x, p.y, 140) || worldRef.nearbyItems?.(p.x, p.y, 140) || []) {
|
||||||
if (!it || it.dead || it.type !== "grass_bed") continue;
|
if (!it || it.dead || it.type !== "grass_bed") continue;
|
||||||
const d = distXY(p.x, p.y, it.x, it.y);
|
const d = distXY(p.x, p.y, it.x, it.y);
|
||||||
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
|
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
|
||||||
|
|
@ -152,6 +152,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
this.setViewportSize(this.viewportW, this.viewportH);
|
this.setViewportSize(this.viewportW, this.viewportH);
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
this.day = 1;
|
this.day = 1;
|
||||||
|
this.elapsedDays = 0;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.speed = 1;
|
this.speed = 1;
|
||||||
this.toolSize = this.toolSize || "medium";
|
this.toolSize = this.toolSize || "medium";
|
||||||
|
|
@ -181,12 +182,19 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||||
global.TarinaiAchievements?.resetWorldProgress?.(this);
|
global.TarinaiAchievements?.resetWorldProgress?.(this);
|
||||||
|
|
||||||
generatePresetWorld(this, field, preset, seedPopulation);
|
this._suspendAchievementEvents = true;
|
||||||
|
try {
|
||||||
|
generatePresetWorld(this, field, preset, seedPopulation);
|
||||||
|
} finally {
|
||||||
|
this._suspendAchievementEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rebuildTarinaiCountCache?.("reset");
|
||||||
this.updateItemCounts();
|
this.updateItemCounts();
|
||||||
this.enforceGrassLimit?.("reset-grass-limit");
|
this.enforceGrassLimit?.("reset-grass-limit");
|
||||||
this.updateItemCounts();
|
this.updateItemCounts();
|
||||||
this.updateEffectCounts();
|
this.updateEffectCounts();
|
||||||
|
global.TarinaiAchievements?.evaluateEvent?.(this, "restore", { source: "reset" });
|
||||||
this.markTerrainDirty?.("reset");
|
this.markTerrainDirty?.("reset");
|
||||||
this.rebuildSpatial(true);
|
this.rebuildSpatial(true);
|
||||||
const label = resetPresetLabel(preset);
|
const label = resetPresetLabel(preset);
|
||||||
|
|
@ -514,18 +522,55 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
if (opts.normalize !== false) this.normalizeFamily();
|
if (opts.normalize !== false) this.normalizeFamily();
|
||||||
const family = this.family || {};
|
const family = this.family || {};
|
||||||
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
|
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
|
||||||
if (!nodes.length) return;
|
if (!nodes.length) return false;
|
||||||
const components = globalThis.TarinaiFamilyGraph.connectedComponents
|
const components = globalThis.TarinaiFamilyGraph.connectedComponents
|
||||||
? globalThis.TarinaiFamilyGraph.connectedComponents(nodes)
|
? globalThis.TarinaiFamilyGraph.connectedComponents(nodes)
|
||||||
: [];
|
: [];
|
||||||
const remove = components.length
|
const remove = new Set();
|
||||||
? components.filter(comp => !comp.some(n => family[n.id]?.alive)).flat().map(n => n.id)
|
for (const component of components) {
|
||||||
: [];
|
const members = component.map(n => family[n.id]).filter(Boolean);
|
||||||
if (!remove.length) return;
|
if (!members.length) continue;
|
||||||
|
if (!members.some(n => n.alive)) {
|
||||||
|
for (const n of members) remove.add(n.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune generation-by-generation inside each family component. A fully
|
||||||
|
// dead generation may disappear only when there is no living older
|
||||||
|
// generation above it. This preserves the visible lineage of surviving
|
||||||
|
// ancestors while allowing extinct leading generations to be discarded.
|
||||||
|
const oldestLivingGeneration = Math.min(...members.filter(n => n.alive).map(n => Math.max(1, Number(n.generation) || 1)));
|
||||||
|
const byGeneration = new Map();
|
||||||
|
for (const n of members) {
|
||||||
|
const generation = Math.max(1, Number(n.generation) || 1);
|
||||||
|
if (!byGeneration.has(generation)) byGeneration.set(generation, []);
|
||||||
|
byGeneration.get(generation).push(n);
|
||||||
|
}
|
||||||
|
for (const generation of Array.from(byGeneration.keys()).sort((a, b) => a - b)) {
|
||||||
|
if (generation >= oldestLivingGeneration) break;
|
||||||
|
const generationMembers = byGeneration.get(generation) || [];
|
||||||
|
if (generationMembers.length && generationMembers.every(n => !n.alive)) {
|
||||||
|
for (const n of generationMembers) remove.add(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remove.size) return false;
|
||||||
for (const id of remove) delete family[id];
|
for (const id of remove) delete family[id];
|
||||||
|
const known = new Set(Object.keys(family));
|
||||||
|
for (const n of Object.values(family)) {
|
||||||
|
n.parents = Array.from(new Set((n.parents || []).filter(id => known.has(id))));
|
||||||
|
n.children = Array.from(new Set((n.children || []).filter(id => known.has(id))));
|
||||||
|
}
|
||||||
|
for (const t of this.tarinai || []) {
|
||||||
|
if (!t || t.dead) continue;
|
||||||
|
t.parents = Array.from(new Set((t.parents || []).filter(id => known.has(id))));
|
||||||
|
t.children = Array.from(new Set((t.children || []).filter(id => known.has(id))));
|
||||||
|
}
|
||||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||||
this.markFamilyTreeDirty("family-prune");
|
this.markFamilyTreeDirty("family-generation-prune");
|
||||||
this.familyCleanVersion = this.familyVersion || 0;
|
this.familyCleanVersion = this.familyVersion || 0;
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
addTarinai(opts = {}) {
|
addTarinai(opts = {}) {
|
||||||
|
|
@ -566,6 +611,8 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
}
|
}
|
||||||
this.assignTarinaiLiveId(t);
|
this.assignTarinaiLiveId(t);
|
||||||
this.tarinai.push(t);
|
this.tarinai.push(t);
|
||||||
|
this.syncTarinaiCountEntry?.(t);
|
||||||
|
if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "population", { tarinai: t, delta: 1, reason: "add-tarinai" });
|
||||||
this.drawListDirty = true;
|
this.drawListDirty = true;
|
||||||
if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t);
|
if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t);
|
||||||
this.maxGeneration = Math.max(this.maxGeneration, t.generation);
|
this.maxGeneration = Math.max(this.maxGeneration, t.generation);
|
||||||
|
|
@ -580,7 +627,7 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
const p = this.pointer;
|
const p = this.pointer;
|
||||||
if (!p?.inside) return null;
|
if (!p?.inside) return null;
|
||||||
let best = null, bestD = Infinity;
|
let best = null, bestD = Infinity;
|
||||||
for (const it of this.nearbyItems(p.x, p.y, 140)) {
|
for (const it of this.nearbyNonGrassItems?.(p.x, p.y, 140) || this.nearbyItems?.(p.x, p.y, 140) || []) {
|
||||||
if (!it || it.dead || !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(it)) continue;
|
if (!it || it.dead || !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(it)) continue;
|
||||||
const d = distXY(p.x, p.y, it.x, it.y);
|
const d = distXY(p.x, p.y, it.x, it.y);
|
||||||
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
|
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
|
||||||
|
|
@ -970,11 +1017,11 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * targetProfile.fear);
|
target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * targetProfile.fear);
|
||||||
actor.adjustRelation(target, -0.05, 0.06, "intimidate");
|
actor.adjustRelation(target, -0.05, 0.06, "intimidate");
|
||||||
target.adjustRelation(actor, -0.12, 0.30 * targetProfile.fear, "intimidate");
|
target.adjustRelation(actor, -0.12, 0.30 * targetProfile.fear, "intimidate");
|
||||||
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
|
this.spawnEffect("ring", actor.x, actor.y - actor.radius * 0.65, {
|
||||||
life: 0.34,
|
life: 0.34,
|
||||||
size: actor.radius * 0.62,
|
size: actor.radius * 0.62,
|
||||||
color: "rgba(154, 88, 42, 0.74)",
|
color: "rgba(154, 88, 42, 0.74)",
|
||||||
}));
|
});
|
||||||
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)");
|
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)");
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
@ -1033,11 +1080,11 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
actor.intimidateTargetId = target.id;
|
actor.intimidateTargetId = target.id;
|
||||||
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
|
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
|
||||||
setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
|
setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
|
||||||
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
|
this.spawnEffect("ring", actor.x, actor.y - actor.radius * 0.65, {
|
||||||
life: 0.42,
|
life: 0.42,
|
||||||
size: actor.radius * 0.55,
|
size: actor.radius * 0.55,
|
||||||
color: "rgba(145, 106, 55, 0.70)",
|
color: "rgba(145, 106, 55, 0.70)",
|
||||||
}));
|
});
|
||||||
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
|
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
|
||||||
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.28, 0.52, actor, target));
|
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.28, 0.52, actor, target));
|
||||||
target.fearTimer = Math.max(target.fearTimer, 0.65 * targetProfile.fear);
|
target.fearTimer = Math.max(target.fearTimer, 0.65 * targetProfile.fear);
|
||||||
|
|
@ -1129,11 +1176,11 @@ function inheritedChampionPersonality(seed = "", parents = [], opts = {}) {
|
||||||
a.vx -= dx / d * deterministicRange(this, "fight-pulse-knockback-ax", 32, 62, a, b); a.vy -= dy / d * deterministicRange(this, "fight-pulse-knockback-ay", 32, 62, a, b);
|
a.vx -= dx / d * deterministicRange(this, "fight-pulse-knockback-ax", 32, 62, a, b); a.vy -= dy / d * deterministicRange(this, "fight-pulse-knockback-ay", 32, 62, a, b);
|
||||||
b.vx += dx / d * deterministicRange(this, "fight-pulse-knockback-bx", 32, 62, a, b); b.vy += dy / d * deterministicRange(this, "fight-pulse-knockback-by", 32, 62, a, b);
|
b.vx += dx / d * deterministicRange(this, "fight-pulse-knockback-bx", 32, 62, a, b); b.vy += dy / d * deterministicRange(this, "fight-pulse-knockback-by", 32, 62, a, b);
|
||||||
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
|
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
|
||||||
this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
|
this.spawnEffect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
|
||||||
size: deterministicRange(this, "fight-pulse-effect-size", 12, 20, a, b),
|
size: deterministicRange(this, "fight-pulse-effect-size", 12, 20, a, b),
|
||||||
life: deterministicRange(this, "fight-pulse-effect-life", 0.32, 0.52, a, b),
|
life: deterministicRange(this, "fight-pulse-effect-life", 0.32, 0.52, a, b),
|
||||||
color: "rgba(116, 73, 38, 0.82)",
|
color: "rgba(116, 73, 38, 0.82)",
|
||||||
}));
|
});
|
||||||
audio.fight();
|
audio.fight();
|
||||||
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
|
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
|
||||||
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
|
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||||
grassBlockedAt(x, y, self = null, opts = {}) {
|
grassBlockedAt(x, y, self = null, opts = {}) {
|
||||||
const ignoreSoft = Boolean(opts?.ignoreSoftPlacement);
|
const ignoreSoft = Boolean(opts?.ignoreSoftPlacement);
|
||||||
for (const it of this.nearbyItems(x, y, 96)) {
|
for (const it of (this.nearbyNonGrassItems?.(x, y, 96) || this.nearbyItems(x, y, 96))) {
|
||||||
if (it === self || it.dead) continue;
|
if (it === self || it.dead) continue;
|
||||||
if (ignoreSoft && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
|
if (ignoreSoft && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
|
||||||
for (const r of this.solidObstacleRects(it)) {
|
for (const r of this.solidObstacleRects(it)) {
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
||||||
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
|
for (const it of (this.nearbyGrass?.(x, y, Math.max(42, minGap + 16)) || this.nearbyItems(x, y, Math.max(42, minGap + 16)))) {
|
||||||
if (it === self || it.dead || it.type !== "grass") continue;
|
if (it === self || it.dead || it.type !== "grass") continue;
|
||||||
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,113 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Layer: world/pathfinding
|
// Layer: world/pathfinding
|
||||||
// Grid and detour waypoint helpers for obstacle-aware creature routing.
|
// Throttled actor-local obstacle-aware routing for creature movement.
|
||||||
(function (global) {
|
(function (global) {
|
||||||
const World = global.World;
|
const World = global.World;
|
||||||
if (!World) return;
|
if (!World) return;
|
||||||
|
|
||||||
|
const ROUTE_TARGET_CELL = 56;
|
||||||
|
|
||||||
|
const routeHash = (value) => {
|
||||||
|
const s = String(value ?? "");
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0;
|
||||||
|
return h >>> 0;
|
||||||
|
};
|
||||||
|
const routeBias = (seed, x, y, amplitude = 14) => {
|
||||||
|
let h = seed ^ Math.imul((Math.round(x) | 0) + 0x9e37, 0x85ebca6b) ^ Math.imul((Math.round(y) | 0) + 0x7f4a, 0xc2b2ae35);
|
||||||
|
h ^= h >>> 16;
|
||||||
|
h = Math.imul(h, 0x7feb352d);
|
||||||
|
h ^= h >>> 15;
|
||||||
|
return (((h >>> 0) / 4294967295) * 2 - 1) * amplitude;
|
||||||
|
};
|
||||||
|
const cellKey = (x, y, size) => `${Math.floor(x / size)},${Math.floor(y / size)}`;
|
||||||
|
const routingContext = (world, actor, target, opts = {}) => {
|
||||||
|
if (!actor || !target) return null;
|
||||||
|
const ax = Number(actor.x), ay = Number(actor.y), tx = Number(target.x), ty = Number(target.y);
|
||||||
|
if (![ax, ay, tx, ty].every(Number.isFinite)) return null;
|
||||||
|
const targetItem = opts.targetItem || target.hostItem || (target.isStructure || target.type ? target : null);
|
||||||
|
const exclude = opts.exclude || targetItem || null;
|
||||||
|
const radius = Math.max(8, Number(actor.radius || 20) || 20);
|
||||||
|
const requestedPadding = Number(opts.padding || 0) || radius * 0.78;
|
||||||
|
const padding = Math.max(20, requestedPadding, radius * 0.95);
|
||||||
|
const targetId = targetItem?.id || target.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
||||||
|
const spatialVersion = world.routingObstacleVersion || 0;
|
||||||
|
return {
|
||||||
|
ax, ay, tx, ty, targetItem, exclude, radius, padding,
|
||||||
|
pointClearance: Math.max(10, padding * 0.72),
|
||||||
|
targetId,
|
||||||
|
targetBucket: cellKey(tx, ty, ROUTE_TARGET_CELL),
|
||||||
|
spatialVersion,
|
||||||
|
seed: routeHash(actor.id ?? actor.uid ?? actor.name ?? ""),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const routeCheckInterval = (ctx, opts = {}) => {
|
||||||
|
const state = String(opts.state || "");
|
||||||
|
const base = state === "fight" || state === "seek_enemy" || state === "ant_attack"
|
||||||
|
? 0.18
|
||||||
|
: state === "follow_parent"
|
||||||
|
? 0.24
|
||||||
|
: Math.hypot(ctx.tx - ctx.ax, ctx.ty - ctx.ay) > 900 ? 0.58 : 0.42;
|
||||||
|
return base * (0.9 + ((ctx.seed & 255) / 255) * 0.2);
|
||||||
|
};
|
||||||
|
const cacheIdentityValid = (cache, ctx) => Boolean(cache
|
||||||
|
&& cache.targetId === ctx.targetId
|
||||||
|
&& cache.targetBucket === ctx.targetBucket
|
||||||
|
&& cache.spatialVersion === ctx.spatialVersion);
|
||||||
|
const cacheResult = (cache, targetId) => {
|
||||||
|
if (!cache || cache.mode === "direct" || cache.mode === "none") return null;
|
||||||
|
if (!Number.isFinite(cache.x) || !Number.isFinite(cache.y)) return null;
|
||||||
|
return {
|
||||||
|
x: cache.x, y: cache.y, dead: false, detour: true, routeTargetId: targetId,
|
||||||
|
...(cache.mode === "grid" ? { gridPath: true } : null),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const cacheGeometryValid = (world, cache, ctx) => {
|
||||||
|
if (cache.mode === "direct") return !world.pathBlockedByFence?.(ctx.ax, ctx.ay, ctx.tx, ctx.ty, ctx.padding, { exclude: ctx.exclude });
|
||||||
|
if (cache.mode === "none") return false;
|
||||||
|
return Number.isFinite(cache.x) && Number.isFinite(cache.y)
|
||||||
|
&& !world.pointBlockedByObstacle?.(cache.x, cache.y, ctx.pointClearance, { exclude: ctx.exclude, maxChecks: 20, directionalOneWay: true })
|
||||||
|
&& !world.pathBlockedByFence?.(ctx.ax, ctx.ay, cache.x, cache.y, ctx.padding, { exclude: ctx.exclude });
|
||||||
|
};
|
||||||
|
const setActorRouteCache = (world, actor, ctx, opts, route, ttl = 1.1) => {
|
||||||
|
const now = world.time || 0;
|
||||||
|
actor._routeCache = {
|
||||||
|
mode: route.mode,
|
||||||
|
x: route.x,
|
||||||
|
y: route.y,
|
||||||
|
targetId: ctx.targetId,
|
||||||
|
targetBucket: ctx.targetBucket,
|
||||||
|
spatialVersion: ctx.spatialVersion,
|
||||||
|
nextCheckAt: now + routeCheckInterval(ctx, opts),
|
||||||
|
expiresAt: now + ttl,
|
||||||
|
};
|
||||||
|
return cacheResult(actor._routeCache, ctx.targetId);
|
||||||
|
};
|
||||||
|
const reuseActorRoute = (world, actor, ctx, opts) => {
|
||||||
|
const cache = actor._routeCache;
|
||||||
|
if (!cacheIdentityValid(cache, ctx)) return undefined;
|
||||||
|
const now = world.time || 0;
|
||||||
|
if (now < (cache.nextCheckAt || 0)) return cacheResult(cache, ctx.targetId);
|
||||||
|
if (now < (cache.expiresAt || 0) && cacheGeometryValid(world, cache, ctx)) {
|
||||||
|
cache.nextCheckAt = now + routeCheckInterval(ctx, opts);
|
||||||
|
return cacheResult(cache, ctx.targetId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||||
findTarinaiPathWaypoint(actor, target, opts = {}) {
|
findTarinaiPathWaypoint(actor, target, opts = {}) {
|
||||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
const ctx = routingContext(this, actor, target, opts);
|
||||||
const tx = Number(target.x);
|
if (!ctx) return null;
|
||||||
const ty = Number(target.y);
|
const { ax, ay, tx, ty, targetItem, exclude, radius: rr, padding, pointClearance, targetId, spatialVersion, seed } = ctx;
|
||||||
const ax = Number(actor.x);
|
|
||||||
const ay = Number(actor.y);
|
|
||||||
const targetItem = opts?.targetItem || target?.hostItem || (target?.isStructure || target?.type ? target : null);
|
|
||||||
const exclude = opts?.exclude || targetItem || null;
|
|
||||||
const rr = Math.max(8, Number(actor.radius || 20) || 20);
|
|
||||||
const requestedPadding = Number(opts.padding || 0) || rr * 0.78;
|
|
||||||
const padding = Math.max(20, requestedPadding, rr * 0.95);
|
|
||||||
const pointClearance = Math.max(10, padding * 0.72);
|
|
||||||
const directClear = !this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude });
|
|
||||||
if (directClear) {
|
|
||||||
actor._pathWaypoint = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetId = targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
const ownRoute = reuseActorRoute(this, actor, ctx, opts);
|
||||||
const cached = actor._pathWaypoint || null;
|
if (ownRoute !== undefined) return ownRoute;
|
||||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
|
||||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
if (!this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude })) {
|
||||||
&& !this.pointBlockedByObstacle(cached.x, cached.y, pointClearance, { exclude, maxChecks: 18, directionalOneWay: true })
|
const route = { mode: "direct" };
|
||||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
return setActorRouteCache(this, actor, ctx, opts, route, 0.72);
|
||||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const midX = (ax + tx) * 0.5;
|
const midX = (ax + tx) * 0.5;
|
||||||
|
|
@ -41,8 +117,7 @@
|
||||||
const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45;
|
const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45;
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
const add = (x, y, sourceRect, grade = 0) => {
|
const add = (x, y, sourceRect, grade = 0) => {
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
if (!Number.isFinite(x) || !Number.isFinite(y) || x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return;
|
||||||
if (x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return;
|
|
||||||
candidates.push({ x, y, sourceRect, grade });
|
candidates.push({ x, y, sourceRect, grade });
|
||||||
};
|
};
|
||||||
const worldPoint = (r, lx, ly) => {
|
const worldPoint = (r, lx, ly) => {
|
||||||
|
|
@ -57,18 +132,15 @@
|
||||||
if (r.oriented) {
|
if (r.oriented) {
|
||||||
const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad;
|
const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad;
|
||||||
const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad;
|
const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad;
|
||||||
for (const [lx, ly, grade] of [[-hw, -hh, 0], [hw, -hh, 0], [hw, hh, 0], [-hw, hh, 0], [0, -hh, 1], [hw, 0, 1], [0, hh, 1], [-hw, 0, 1]]) {
|
for (const [lx, ly, grade] of [[-hw,-hh,0],[hw,-hh,0],[hw,hh,0],[-hw,hh,0],[0,-hh,1],[hw,0,1],[0,hh,1],[-hw,0,1]]) {
|
||||||
const p = worldPoint(r, lx, ly);
|
const p = worldPoint(r, lx, ly);
|
||||||
add(p.x, p.y, r, grade);
|
add(p.x, p.y, r, grade);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const left = Number(r.left || 0) - pad;
|
const left = Number(r.left || 0) - pad, right = Number(r.right || 0) + pad;
|
||||||
const right = Number(r.right || 0) + pad;
|
const top = Number(r.top || 0) - pad, bottom = Number(r.bottom || 0) + pad;
|
||||||
const top = Number(r.top || 0) - pad;
|
const cx = (left + right) * 0.5, cy = (top + bottom) * 0.5;
|
||||||
const bottom = Number(r.bottom || 0) + pad;
|
for (const [x, y, grade] of [[left,top,0],[right,top,0],[right,bottom,0],[left,bottom,0],[cx,top,1],[right,cy,1],[cx,bottom,1],[left,cy,1]]) add(x, y, r, grade);
|
||||||
const cx = (left + right) * 0.5;
|
|
||||||
const cy = (top + bottom) * 0.5;
|
|
||||||
for (const [x, y, grade] of [[left, top, 0], [right, top, 0], [right, bottom, 0], [left, bottom, 0], [cx, top, 1], [right, cy, 1], [cx, bottom, 1], [left, cy, 1]]) add(x, y, r, grade);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -79,117 +151,142 @@
|
||||||
? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }
|
? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }
|
||||||
: { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding };
|
: { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding };
|
||||||
if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue;
|
if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue;
|
||||||
addRectCandidates(rect, blockers);
|
addRectCandidates(rect, blockers++);
|
||||||
blockers += 1;
|
|
||||||
if (blockers >= 4) break;
|
if (blockers >= 4) break;
|
||||||
}
|
}
|
||||||
if (!candidates.length) return null;
|
if (!candidates.length) {
|
||||||
|
const route = { mode: "none" };
|
||||||
|
return setActorRouteCache(this, actor, ctx, opts, route, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
let best = null;
|
let best = null, bestScore = Infinity;
|
||||||
let bestScore = Infinity;
|
const old = actor._routeCache && Number.isFinite(actor._routeCache.x) && Number.isFinite(actor._routeCache.y) ? actor._routeCache : null;
|
||||||
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
|
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
if (this.pointBlockedByObstacle(c.x, c.y, pointClearance, { exclude, maxChecks: 20, directionalOneWay: true })) continue;
|
if (this.pointBlockedByObstacle(c.x, c.y, pointClearance, { exclude, maxChecks: 20, directionalOneWay: true })) continue;
|
||||||
const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude });
|
if (this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude })) continue;
|
||||||
if (firstBlocked) continue;
|
|
||||||
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
|
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
|
||||||
const d1 = Math.hypot(c.x - ax, c.y - ay);
|
const d1 = Math.hypot(c.x - ax, c.y - ay), d2 = Math.hypot(tx - c.x, ty - c.y);
|
||||||
const d2 = Math.hypot(tx - c.x, ty - c.y);
|
|
||||||
const progress = totalD - d2;
|
const progress = totalD - d2;
|
||||||
const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0;
|
const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0;
|
||||||
const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias;
|
const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias + routeBias(seed, c.x, c.y);
|
||||||
if (score < bestScore) { best = { ...c, secondBlocked }; bestScore = score; }
|
if (score < bestScore) { best = { ...c, secondBlocked }; bestScore = score; }
|
||||||
}
|
}
|
||||||
if (!best || (best.secondBlocked && opts.preferFullPath)) {
|
if (!best || (best.secondBlocked && opts.preferFullPath)) {
|
||||||
const grid = this.findGridPathWaypoint?.(actor, target, { ...opts, targetItem, exclude, padding, targetId });
|
const grid = this.findGridPathWaypoint?.(actor, target, { ...opts, targetItem, exclude, padding, targetId, skipRouteReuse: true });
|
||||||
if (grid) return grid;
|
if (grid) return grid;
|
||||||
if (!best) return null;
|
if (!best) {
|
||||||
|
const route = { mode: "none" };
|
||||||
|
return setActorRouteCache(this, actor, ctx, opts, route, 0.35);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
actor._pathWaypoint = { x: best.x, y: best.y, targetId, until: (this.time || 0) + 0.95 };
|
const route = { mode: "detour", x: best.x, y: best.y };
|
||||||
return { x: best.x, y: best.y, dead: false, detour: true, routeTargetId: targetId };
|
return setActorRouteCache(this, actor, ctx, opts, route);
|
||||||
},
|
},
|
||||||
|
|
||||||
findGridPathWaypoint(actor, target, opts = {}) {
|
findGridPathWaypoint(actor, target, opts = {}) {
|
||||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
const ctx = routingContext(this, actor, target, opts);
|
||||||
const ax = Number(actor.x), ay = Number(actor.y), tx = Number(target.x), ty = Number(target.y);
|
if (!ctx) return null;
|
||||||
const targetId = opts.targetId || opts.targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
const { ax, ay, tx, ty, targetId, padding, pointClearance, exclude, radius: actorRadius, seed } = ctx;
|
||||||
const cached = actor._gridPathWaypoint || null;
|
if (!opts.skipRouteReuse) {
|
||||||
const actorRadius = Math.max(8, Number(actor.radius || 20) || 20);
|
const ownRoute = reuseActorRoute(this, actor, ctx, opts);
|
||||||
const requestedPadding = Number(opts.padding || 0) || actorRadius * 0.78;
|
if (ownRoute !== undefined) return ownRoute;
|
||||||
const padding = Math.max(20, requestedPadding, actorRadius * 0.95);
|
|
||||||
const pointClearance = Math.max(10, padding * 0.72);
|
|
||||||
const exclude = opts.exclude || opts.targetItem || target || null;
|
|
||||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
|
||||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
|
||||||
&& !this.pointBlockedByObstacle?.(cached.x, cached.y, pointClearance, { exclude, maxChecks: 20, directionalOneWay: true })
|
|
||||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
|
||||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dTotal = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
const dTotal = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
||||||
const step = Math.max(42, Math.min(72, Number(opts.gridStep || 0) || dTotal / 7));
|
const step = Math.max(42, Math.min(72, Number(opts.gridStep || 0) || dTotal / 7));
|
||||||
const padWorld = (CONFIG.worldPadding || 28) + Math.max(8, Number(actor.radius || 20) * 0.45);
|
const padWorld = (CONFIG.worldPadding || 28) + Math.max(8, actorRadius * 0.45);
|
||||||
const margin = Math.max(190, step * 3.2);
|
const margin = Math.max(190, step * 3.2);
|
||||||
const minX = Math.max(padWorld, Math.min(ax, tx) - margin);
|
const minX = Math.max(padWorld, Math.min(ax, tx) - margin), maxX = Math.min(this.w - padWorld, Math.max(ax, tx) + margin);
|
||||||
const maxX = Math.min(this.w - padWorld, Math.max(ax, tx) + margin);
|
const minY = Math.max(padWorld, Math.min(ay, ty) - margin), maxY = Math.min(this.h - padWorld, Math.max(ay, ty) + margin);
|
||||||
const minY = Math.max(padWorld, Math.min(ay, ty) - margin);
|
|
||||||
const maxY = Math.min(this.h - padWorld, Math.max(ay, ty) + margin);
|
|
||||||
const cols = Math.max(2, Math.min(18, Math.ceil((maxX - minX) / step) + 1));
|
const cols = Math.max(2, Math.min(18, Math.ceil((maxX - minX) / step) + 1));
|
||||||
const rows = Math.max(2, Math.min(18, Math.ceil((maxY - minY) / step) + 1));
|
const rows = Math.max(2, Math.min(18, Math.ceil((maxY - minY) / step) + 1));
|
||||||
const px = (ix) => cols <= 1 ? minX : minX + (maxX - minX) * ix / (cols - 1);
|
const count = cols * rows;
|
||||||
const py = (iy) => rows <= 1 ? minY : minY + (maxY - minY) * iy / (rows - 1);
|
const xs = new Float64Array(cols), ys = new Float64Array(rows);
|
||||||
const nearest = (x, y) => ({ ix: clamp(Math.round((x - minX) / Math.max(1, maxX - minX) * (cols - 1)), 0, cols - 1), iy: clamp(Math.round((y - minY) / Math.max(1, maxY - minY) * (rows - 1)), 0, rows - 1) });
|
for (let i = 0; i < cols; i++) xs[i] = cols <= 1 ? minX : minX + (maxX - minX) * i / (cols - 1);
|
||||||
const start = nearest(ax, ay);
|
for (let i = 0; i < rows; i++) ys[i] = rows <= 1 ? minY : minY + (maxY - minY) * i / (rows - 1);
|
||||||
const goal = nearest(tx, ty);
|
const nearest = (x, y) => ({
|
||||||
const key = (ix, iy) => `${ix},${iy}`;
|
ix: clamp(Math.round((x - minX) / Math.max(1, maxX - minX) * (cols - 1)), 0, cols - 1),
|
||||||
const inBounds = (ix, iy) => ix >= 0 && iy >= 0 && ix < cols && iy < rows;
|
iy: clamp(Math.round((y - minY) / Math.max(1, maxY - minY) * (rows - 1)), 0, rows - 1),
|
||||||
const pointOpen = (ix, iy) => {
|
});
|
||||||
if (!inBounds(ix, iy)) return false;
|
const start = nearest(ax, ay), goal = nearest(tx, ty);
|
||||||
if (ix === start.ix && iy === start.iy) return true;
|
const startId = start.iy * cols + start.ix, goalId = goal.iy * cols + goal.ix;
|
||||||
if (ix === goal.ix && iy === goal.iy) return true;
|
const pointCache = new Int8Array(count); pointCache.fill(-1);
|
||||||
return !this.pointBlockedByObstacle?.(px(ix), py(iy), pointClearance, { exclude, maxChecks: 18, directionalOneWay: true });
|
const edgeCache = new Int8Array(count * 8); edgeCache.fill(-1);
|
||||||
};
|
const bestG = new Float64Array(count); bestG.fill(Infinity);
|
||||||
const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude });
|
const scoreF = new Float64Array(count); scoreF.fill(Infinity);
|
||||||
const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);
|
const parent = new Int16Array(count); parent.fill(-1);
|
||||||
const open = [{ ...start, g: 0, f: h(start.ix, start.iy), parent: null }];
|
const inOpen = new Uint8Array(count);
|
||||||
const bestByKey = new Map([[key(start.ix, start.iy), open[0]]]);
|
const open = [];
|
||||||
let found = null;
|
|
||||||
let guard = 0;
|
|
||||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||||||
|
const idOf = (ix, iy) => iy * cols + ix;
|
||||||
|
const pointOpen = (ix, iy) => {
|
||||||
|
if (ix < 0 || iy < 0 || ix >= cols || iy >= rows) return false;
|
||||||
|
const id = idOf(ix, iy);
|
||||||
|
if (id === startId || id === goalId) return true;
|
||||||
|
if (pointCache[id] >= 0) return pointCache[id] === 1;
|
||||||
|
const ok = !this.pointBlockedByObstacle?.(xs[ix], ys[iy], pointClearance, { exclude, maxChecks: 18, directionalOneWay: true });
|
||||||
|
pointCache[id] = ok ? 1 : 0;
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
const edgeOpen = (aId, bId) => {
|
||||||
|
const aix = aId % cols, aiy = (aId / cols) | 0, bix = bId % cols, biy = (bId / cols) | 0;
|
||||||
|
const dx = bix - aix, dy = biy - aiy;
|
||||||
|
const dir = dx === 1 ? (dy === 0 ? 0 : dy === 1 ? 4 : dy === -1 ? 5 : -1)
|
||||||
|
: dx === -1 ? (dy === 0 ? 1 : dy === 1 ? 6 : dy === -1 ? 7 : -1)
|
||||||
|
: dx === 0 ? (dy === 1 ? 2 : dy === -1 ? 3 : -1) : -1;
|
||||||
|
if (dir < 0) return !this.pathBlockedByFence?.(xs[aix], ys[aiy], xs[bix], ys[biy], padding, { exclude });
|
||||||
|
const ci = aId * 8 + dir;
|
||||||
|
if (edgeCache[ci] >= 0) return edgeCache[ci] === 1;
|
||||||
|
const ok = !this.pathBlockedByFence?.(xs[aix], ys[aiy], xs[bix], ys[biy], padding, { exclude });
|
||||||
|
edgeCache[ci] = ok ? 1 : 0;
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
const heuristic = (id) => {
|
||||||
|
const ix = id % cols, iy = (id / cols) | 0;
|
||||||
|
return Math.hypot(xs[ix] - tx, ys[iy] - ty) + routeBias(seed, xs[ix], ys[iy], 8);
|
||||||
|
};
|
||||||
|
const pushOpen = (id) => {
|
||||||
|
if (!inOpen[id]) { inOpen[id] = 1; open.push(id); }
|
||||||
|
};
|
||||||
|
bestG[startId] = 0;
|
||||||
|
scoreF[startId] = heuristic(startId);
|
||||||
|
pushOpen(startId);
|
||||||
|
let foundId = -1, guard = 0;
|
||||||
while (open.length && guard++ < 420) {
|
while (open.length && guard++ < 420) {
|
||||||
open.sort((a, b) => a.f - b.f);
|
let bestAt = 0;
|
||||||
const cur = open.shift();
|
for (let i = 1; i < open.length; i++) if (scoreF[open[i]] < scoreF[open[bestAt]]) bestAt = i;
|
||||||
if (cur.ix === goal.ix && cur.iy === goal.iy) { found = cur; break; }
|
const curId = open[bestAt];
|
||||||
for (const [dx, dy] of dirs) {
|
open[bestAt] = open[open.length - 1];
|
||||||
const nx = cur.ix + dx, ny = cur.iy + dy;
|
open.pop();
|
||||||
|
inOpen[curId] = 0;
|
||||||
|
if (curId === goalId) { foundId = curId; break; }
|
||||||
|
const cix = curId % cols, ciy = (curId / cols) | 0;
|
||||||
|
for (let dir = 0; dir < dirs.length; dir++) {
|
||||||
|
const [dx, dy] = dirs[dir], nx = cix + dx, ny = ciy + dy;
|
||||||
if (!pointOpen(nx, ny)) continue;
|
if (!pointOpen(nx, ny)) continue;
|
||||||
const nb = { ix: nx, iy: ny };
|
const nbId = idOf(nx, ny), diagonal = dx && dy;
|
||||||
const diagonal = dx && dy;
|
|
||||||
if (diagonal) {
|
if (diagonal) {
|
||||||
const sideA = { ix: cur.ix + dx, iy: cur.iy };
|
const sideAId = idOf(cix + dx, ciy), sideBId = idOf(cix, ciy + dy);
|
||||||
const sideB = { ix: cur.ix, iy: cur.iy + dy };
|
if (!pointOpen(cix + dx, ciy) || !pointOpen(cix, ciy + dy)) continue;
|
||||||
if (!pointOpen(sideA.ix, sideA.iy) || !pointOpen(sideB.ix, sideB.iy)) continue;
|
if (!edgeOpen(curId, sideAId) || !edgeOpen(curId, sideBId) || !edgeOpen(sideAId, nbId) || !edgeOpen(sideBId, nbId)) continue;
|
||||||
if (!edgeOpen(cur, sideA) || !edgeOpen(cur, sideB) || !edgeOpen(sideA, nb) || !edgeOpen(sideB, nb)) continue;
|
|
||||||
}
|
}
|
||||||
if (!edgeOpen(cur, nb)) continue;
|
if (!edgeOpen(curId, nbId)) continue;
|
||||||
const ng = cur.g + (diagonal ? 1.42 : 1) * step;
|
const ng = bestG[curId] + (diagonal ? 1.42 : 1) * step;
|
||||||
const k = key(nx, ny);
|
if (bestG[nbId] <= ng) continue;
|
||||||
const oldNode = bestByKey.get(k);
|
bestG[nbId] = ng;
|
||||||
if (oldNode && oldNode.g <= ng) continue;
|
parent[nbId] = curId;
|
||||||
const node = { ix: nx, iy: ny, g: ng, f: ng + h(nx, ny), parent: cur };
|
scoreF[nbId] = ng + heuristic(nbId);
|
||||||
bestByKey.set(k, node);
|
pushOpen(nbId);
|
||||||
open.push(node);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!found) return null;
|
if (foundId < 0) return null;
|
||||||
const path = [];
|
const path = [];
|
||||||
for (let n = found; n; n = n.parent) path.push(n);
|
for (let id = foundId; id >= 0; id = parent[id]) path.push(id);
|
||||||
path.reverse();
|
path.reverse();
|
||||||
const chosen = path[Math.min(path.length - 1, Math.max(1, path.length > 3 ? 2 : 1))];
|
const chosenId = path[Math.min(path.length - 1, Math.max(1, path.length > 3 ? 2 : 1))];
|
||||||
if (!chosen) return null;
|
if (!Number.isInteger(chosenId)) return null;
|
||||||
const wx = px(chosen.ix), wy = py(chosen.iy);
|
const route = { mode: "grid", x: xs[chosenId % cols], y: ys[(chosenId / cols) | 0] };
|
||||||
actor._gridPathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
return setActorRouteCache(this, actor, ctx, opts, route, 1.2);
|
||||||
actor._pathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
|
||||||
return { x: wx, y: wy, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
})(typeof window !== "undefined" ? window : globalThis);
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
|
|
|
||||||
|
|
@ -1292,11 +1292,11 @@
|
||||||
const pad = CONFIG.worldPadding || 30;
|
const pad = CONFIG.worldPadding || 30;
|
||||||
if (rects.length) {
|
if (rects.length) {
|
||||||
for (const rect of rects) {
|
for (const rect of rects) {
|
||||||
if (rect.left < pad || rect.top < 0 || rect.right > this.w - pad || rect.bottom > this.h - pad) return true;
|
if (rect.left < pad || rect.top < 0 || rect.right > this.w - pad || rect.bottom > this.h) return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
||||||
if (item.x - radius < pad || item.y - radius < 0 || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
|
if (item.x - radius < pad || item.y - radius < 0 || item.x + radius > this.w - pad || item.y + radius > this.h) return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
|
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
|
||||||
|
|
@ -1332,13 +1332,13 @@
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
x: clamp(x, pad + left, this.w - pad - right),
|
x: clamp(x, pad + left, this.w - pad - right),
|
||||||
y: clamp(y, top, this.h - pad - bottom),
|
y: clamp(y, top, this.h - bottom),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
||||||
return {
|
return {
|
||||||
x: clamp(x, pad + radius, this.w - pad - radius),
|
x: clamp(x, pad + radius, this.w - pad - radius),
|
||||||
y: clamp(y, pad + radius, this.h - pad - radius),
|
y: clamp(y, radius, this.h - radius),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1598,6 +1598,7 @@
|
||||||
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });
|
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });
|
||||||
if (!t) { showToast(`\u305f\u308a\u306a\u3044\u500b\u4f53\u6570\u306f${this.tarinaiPopulationLimit}\u5339\u307e\u3067\u3067\u3059\u3002`); return; }
|
if (!t) { showToast(`\u305f\u308a\u306a\u3044\u500b\u4f53\u6570\u306f${this.tarinaiPopulationLimit}\u5339\u307e\u3067\u3067\u3059\u3002`); return; }
|
||||||
t.wanderAngle = spot.angle;
|
t.wanderAngle = spot.angle;
|
||||||
|
global.TarinaiAchievements?.recordManualTarinaiAdded?.({ world: this, tarinai: t, tool: "new" });
|
||||||
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
|
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1616,11 +1617,13 @@
|
||||||
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
|
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
|
||||||
global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
|
global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
|
||||||
}
|
}
|
||||||
|
// When a duplicator and a Tarinai overlap, the duplicator load slot wins.
|
||||||
|
// This makes it possible to set its food without first moving the Tarinai.
|
||||||
|
if (this.directSetDuplicatorAt?.(x, y, itemType)) return;
|
||||||
const directFeeding = global.TarinaiDirectFeedingSystem;
|
const directFeeding = global.TarinaiDirectFeedingSystem;
|
||||||
const directTarget = directFeeding?.isDirectFeedType?.(rawItem)
|
const directTarget = directFeeding?.isDirectFeedType?.(rawItem)
|
||||||
? directFeeding.findTargetAt?.(this, x, y)
|
? directFeeding.findTargetAt?.(this, x, y)
|
||||||
: null;
|
: null;
|
||||||
// A Tarinai under the pointer takes precedence over a duplicator load slot.
|
|
||||||
if (directTarget) {
|
if (directTarget) {
|
||||||
rawItem.x = x;
|
rawItem.x = x;
|
||||||
rawItem.y = y;
|
rawItem.y = y;
|
||||||
|
|
@ -1628,7 +1631,6 @@
|
||||||
if (given) this._forceImmediateToolVisualRefresh?.("tool-change");
|
if (given) this._forceImmediateToolVisualRefresh?.("tool-change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.directSetDuplicatorAt?.(x, y, itemType)) return;
|
|
||||||
// Placement uses the same footprint that the preview validates.
|
// Placement uses the same footprint that the preview validates.
|
||||||
const placed = this.placeItem(rawItem, true);
|
const placed = this.placeItem(rawItem, true);
|
||||||
if (!placed) return;
|
if (!placed) return;
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,18 @@
|
||||||
aiUsed: 0,
|
aiUsed: 0,
|
||||||
envUsed: 0,
|
envUsed: 0,
|
||||||
};
|
};
|
||||||
this.workStats = {
|
const diagnostics = global.TarinaiPerf?.diagnosticsEnabled?.() === true;
|
||||||
aiRuns: 0,
|
this.workStats = diagnostics ? {
|
||||||
aiSkips: 0,
|
aiRuns: 0, aiSkips: 0, envRuns: 0, envSkips: 0,
|
||||||
envRuns: 0,
|
collisionRuns: 0, collisionSkips: 0, bedConflictRuns: 0, bedConflictSkips: 0,
|
||||||
envSkips: 0,
|
} : null;
|
||||||
collisionRuns: 0,
|
this.spatialDirtyReasonCountsThisFrame = diagnostics ? {} : null;
|
||||||
collisionSkips: 0,
|
this.spatialRebuildReasonCountsThisFrame = diagnostics ? {} : null;
|
||||||
bedConflictRuns: 0,
|
|
||||||
bedConflictSkips: 0,
|
|
||||||
};
|
|
||||||
this.spatialDirtyReasonCountsThisFrame = {};
|
|
||||||
this.spatialRebuildReasonCountsThisFrame = {};
|
|
||||||
this.deferredSpatialReadsThisFrame = 0;
|
this.deferredSpatialReadsThisFrame = 0;
|
||||||
this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
|
this.terrainDirtyStatsThisFrame = diagnostics ? { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} } : null;
|
||||||
this.nearbyQueryStatsThisFrame = { obstacleRectHits: 0, obstacleRectMisses: 0, obstacleRectBypass: 0, obstacleRectBuilt: 0 };
|
this.nearbyQueryStatsThisFrame = diagnostics ? { obstacleRectHits: 0, obstacleRectMisses: 0, obstacleRectBypass: 0, obstacleRectBuilt: 0 } : null;
|
||||||
this.constraintDirtyStatsThisFrame = { marked: 0, coalesced: 0 };
|
this.constraintDirtyStatsThisFrame = diagnostics ? { marked: 0, coalesced: 0 } : null;
|
||||||
|
this.processTarinaiRuntimeCachePruneStep?.(48);
|
||||||
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -182,7 +178,7 @@
|
||||||
&& (this.spatialDynamicItemsDirty || this.spatialTarinaiDirty || this.spatialAntDirty);
|
&& (this.spatialDynamicItemsDirty || this.spatialTarinaiDirty || this.spatialAntDirty);
|
||||||
if (canDeferTarinaiOnly || canDeferMobileOnly) {
|
if (canDeferTarinaiOnly || canDeferMobileOnly) {
|
||||||
this.deferredSpatialDirtyReason = reason;
|
this.deferredSpatialDirtyReason = reason;
|
||||||
this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1;
|
if (this.spatialDirtyReasonCountsThisFrame) this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,6 +201,39 @@
|
||||||
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
|
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
nearbyGrass(x, y, radius, precise = false) {
|
||||||
|
this.ensureSpatial?.("nearby-grass");
|
||||||
|
const out = this.spatialGrassScratch || (this.spatialGrassScratch = []);
|
||||||
|
out.length = 0;
|
||||||
|
if (this.spatial?.grassCells) return this.spatial.nearby(this.spatial.grassCells, x, y, radius, out, precise);
|
||||||
|
// Compatibility fallback for an old/restored SpatialGrid instance. This
|
||||||
|
// path should disappear after the next normal spatial rebuild.
|
||||||
|
const source = this.nearbyItems(x, y, radius, precise);
|
||||||
|
for (const item of source) if (item?.type === "grass" && !item.dead) out.push(item);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
nearbyNonGrassItems(x, y, radius, precise = false) {
|
||||||
|
this.ensureSpatial?.("nearby-non-grass-items");
|
||||||
|
const out = this.spatialNonGrassItemScratch || (this.spatialNonGrassItemScratch = []);
|
||||||
|
out.length = 0;
|
||||||
|
if (this.spatial?.nearbyInto && this.spatial.staticNonGrassItemCells && this.spatial.dynamicNonGrassItemCells) {
|
||||||
|
this.spatial.nearbyInto(this.spatial.staticNonGrassItemCells, x, y, radius, out, precise);
|
||||||
|
this.spatial.nearbyInto(this.spatial.dynamicNonGrassItemCells, x, y, radius, out, precise);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Compatibility fallback for older/restored SpatialGrid instances.
|
||||||
|
const source = this.nearbyItems(x, y, radius, precise);
|
||||||
|
let write = 0;
|
||||||
|
for (let read = 0; read < source.length; read++) {
|
||||||
|
const item = source[read];
|
||||||
|
if (item?.type === "grass") continue;
|
||||||
|
out[write++] = item;
|
||||||
|
}
|
||||||
|
out.length = write;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
nearbyTarinai(x, y, radius, precise = false) {
|
nearbyTarinai(x, y, radius, precise = false) {
|
||||||
this.ensureSpatial?.("nearby-tarinai");
|
this.ensureSpatial?.("nearby-tarinai");
|
||||||
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
|
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
|
||||||
|
|
@ -247,17 +276,22 @@
|
||||||
const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems;
|
const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems;
|
||||||
if (full) {
|
if (full) {
|
||||||
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
|
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
|
||||||
|
this.renderStaticVersion = (this.renderStaticVersion || 0) + 1;
|
||||||
} else {
|
} else {
|
||||||
const staticDirty = Boolean(this.spatialStaticItemsDirty);
|
const staticDirty = Boolean(this.spatialStaticItemsDirty);
|
||||||
const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty);
|
const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty);
|
||||||
const tarinaiDirty = Boolean(this.spatialTarinaiDirty);
|
const tarinaiDirty = Boolean(this.spatialTarinaiDirty);
|
||||||
const antDirty = Boolean(this.spatialAntDirty);
|
const antDirty = Boolean(this.spatialAntDirty);
|
||||||
if (staticDirty) this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false });
|
if (staticDirty) {
|
||||||
|
this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false });
|
||||||
|
this.renderStaticVersion = (this.renderStaticVersion || 0) + 1;
|
||||||
|
}
|
||||||
if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false });
|
if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false });
|
||||||
if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai);
|
if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai);
|
||||||
if (antDirty) this.spatial.rebuildAnts(this.ants || []);
|
if (antDirty) this.spatial.rebuildAnts(this.ants || []);
|
||||||
this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1;
|
if (this.spatialRebuildReasonCountsThisFrame) this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1;
|
||||||
}
|
}
|
||||||
|
if (full || this.spatialStaticItemsDirty || this.spatialDynamicItemsDirty) this.spatial.rebuildLinkRenderItems?.(this.items);
|
||||||
} finally {
|
} finally {
|
||||||
if (end) end();
|
if (end) end();
|
||||||
}
|
}
|
||||||
|
|
@ -268,11 +302,15 @@
|
||||||
this.spatialAntDirty = false;
|
this.spatialAntDirty = false;
|
||||||
this.spatialDirtyReason = "";
|
this.spatialDirtyReason = "";
|
||||||
this.spatialVersion = (this.spatialVersion || 0) + 1;
|
this.spatialVersion = (this.spatialVersion || 0) + 1;
|
||||||
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
|
if (this.spatialRebuildReasonCountsThisFrame) {
|
||||||
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
|
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
|
||||||
|
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
|
||||||
|
}
|
||||||
this.lastSpatialRebuildReason = reason;
|
this.lastSpatialRebuildReason = reason;
|
||||||
if (!this.spatialRebuildReasonCountsThisFrame) this.spatialRebuildReasonCountsThisFrame = {};
|
if (this.spatialRebuildReasonCountsThisFrame) {
|
||||||
this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] = (this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
|
const key = String(reason || "manual");
|
||||||
|
this.spatialRebuildReasonCountsThisFrame[key] = (this.spatialRebuildReasonCountsThisFrame[key] || 0) + 1;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -290,24 +328,80 @@
|
||||||
|
|
||||||
normalizeColonyLimit(value = 0) {
|
normalizeColonyLimit(value = 0) {
|
||||||
const n = Math.floor(Number(value) || 0);
|
const n = Math.floor(Number(value) || 0);
|
||||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
return Number.isFinite(n) && n > 0 ? Math.min(300, n) : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
rebuildTarinaiCountCache(reason = "manual") {
|
||||||
|
const cache = this._tarinaiCountCache || (this._tarinaiCountCache = { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 });
|
||||||
|
cache.alive = 0;
|
||||||
|
cache.zunchiSlaves = 0;
|
||||||
|
cache.tarinaiKings = 0;
|
||||||
|
for (const t of this.tarinai || []) {
|
||||||
|
if (!t) continue;
|
||||||
|
const alive = !t.dead;
|
||||||
|
const slave = alive && Boolean(t.isZunchiSlave);
|
||||||
|
const king = alive && Boolean(t.isTarinaiChampion);
|
||||||
|
t._countedAlive = alive;
|
||||||
|
t._countedZunchiSlave = slave;
|
||||||
|
t._countedTarinaiKing = king;
|
||||||
|
if (!alive) continue;
|
||||||
|
cache.alive += 1;
|
||||||
|
if (slave) cache.zunchiSlaves += 1;
|
||||||
|
if (king) cache.tarinaiKings += 1;
|
||||||
|
}
|
||||||
|
this._tarinaiCountDirty = false;
|
||||||
|
return cache;
|
||||||
|
},
|
||||||
|
|
||||||
|
tarinaiCounts() {
|
||||||
|
if (this._tarinaiCountDirty || !this._tarinaiCountCache) return this.rebuildTarinaiCountCache?.("lazy") || { alive: 0, zunchiSlaves: 0, tarinaiKings: 0 };
|
||||||
|
return this._tarinaiCountCache;
|
||||||
|
},
|
||||||
|
|
||||||
|
markTarinaiCountsDirty(reason = "manual") {
|
||||||
|
this._tarinaiCountDirty = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
syncTarinaiCountEntry(t) {
|
||||||
|
if (!t) return this.tarinaiCounts?.() || null;
|
||||||
|
if (this._tarinaiCountDirty || !this._tarinaiCountCache) return null;
|
||||||
|
const cache = this._tarinaiCountCache;
|
||||||
|
const alive = !t.dead;
|
||||||
|
const slave = alive && Boolean(t.isZunchiSlave);
|
||||||
|
const king = alive && Boolean(t.isTarinaiChampion);
|
||||||
|
const prevAlive = Boolean(t._countedAlive);
|
||||||
|
const prevSlave = Boolean(t._countedZunchiSlave);
|
||||||
|
const prevKing = Boolean(t._countedTarinaiKing);
|
||||||
|
if (alive !== prevAlive) cache.alive = Math.max(0, cache.alive + (alive ? 1 : -1));
|
||||||
|
if (slave !== prevSlave) cache.zunchiSlaves = Math.max(0, cache.zunchiSlaves + (slave ? 1 : -1));
|
||||||
|
if (king !== prevKing) cache.tarinaiKings = Math.max(0, cache.tarinaiKings + (king ? 1 : -1));
|
||||||
|
t._countedAlive = alive;
|
||||||
|
t._countedZunchiSlave = slave;
|
||||||
|
t._countedTarinaiKing = king;
|
||||||
|
return cache;
|
||||||
},
|
},
|
||||||
|
|
||||||
activeTarinaiCount() {
|
activeTarinaiCount() {
|
||||||
let count = 0;
|
return Math.max(0, Number(this.tarinaiCounts?.().alive || 0) || 0);
|
||||||
for (const t of this.tarinai || []) if (t && !t.dead) count += 1;
|
|
||||||
return count;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
activeObjectCount() {
|
activeObjectCount() {
|
||||||
|
if (!this.itemBucketsDirty && Number.isFinite(this._activeObjectCountCache)) return this._activeObjectCountCache;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const item of this.items || []) if (item && !item.dead) count += 1;
|
for (const item of this.items || []) if (item && !item.dead) count += 1;
|
||||||
|
this._activeObjectCountCache = count;
|
||||||
return count;
|
return count;
|
||||||
},
|
},
|
||||||
|
|
||||||
canAddTarinai(count = 1) {
|
canAddTarinai(count = 1) {
|
||||||
const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit);
|
const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit);
|
||||||
return limit <= 0 || this.activeTarinaiCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
|
if (limit <= 0) return true;
|
||||||
|
// Creation is infrequent compared with per-frame stats reads. Count live
|
||||||
|
// entries directly here so external/debug mutations cannot stale the hot
|
||||||
|
// differential counter and bypass the hard population limit.
|
||||||
|
let alive = 0;
|
||||||
|
for (const t of this.tarinai || []) if (t && !t.dead) alive += 1;
|
||||||
|
return alive + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
|
||||||
},
|
},
|
||||||
|
|
||||||
canAddObjects(count = 1) {
|
canAddObjects(count = 1) {
|
||||||
|
|
@ -333,14 +427,29 @@
|
||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
this.markItemBucketsDirty?.(reason);
|
this.markItemBucketsDirty?.(reason);
|
||||||
this.markSpatialDirty?.(reason);
|
this.markSpatialDirty?.(reason);
|
||||||
if (opts.terrain !== false) {
|
if (opts.terrain !== false && (item.type === "grass" || item.type === "trace" || item.type === "splat")) {
|
||||||
if (item.type === "grass" || item.type === "trace" || item.type === "splat") this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason);
|
this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason);
|
||||||
else this.markTerrainDirty?.(reason);
|
|
||||||
}
|
}
|
||||||
if (opts.countNow !== false && item.type) this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
if (opts.countNow !== false && item.type) {
|
||||||
|
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
||||||
|
item._worldCountedActive = true;
|
||||||
|
if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache += 1;
|
||||||
|
} else {
|
||||||
|
item._worldCountedActive = false;
|
||||||
|
}
|
||||||
|
if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: 1, reason });
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
noteItemInactive(item, reason = "item-inactive", notifyAchievements = true) {
|
||||||
|
if (!item || item._worldCountedActive !== true) return false;
|
||||||
|
item._worldCountedActive = false;
|
||||||
|
if (item.type) this.itemCounts[item.type] = Math.max(0, (Number(this.itemCounts[item.type] || 0) || 0) - 1);
|
||||||
|
if (Number.isFinite(this._activeObjectCountCache)) this._activeObjectCountCache = Math.max(0, this._activeObjectCountCache - 1);
|
||||||
|
if (notifyAchievements && !this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { item, delta: -1, reason });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
itemById(id) {
|
itemById(id) {
|
||||||
if (id == null || id === "") return null;
|
if (id == null || id === "") return null;
|
||||||
this.ensureItemBuckets?.("item-by-id");
|
this.ensureItemBuckets?.("item-by-id");
|
||||||
|
|
@ -366,12 +475,16 @@
|
||||||
if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map();
|
if (!this.itemOwnerBuckets) this.itemOwnerBuckets = new Map();
|
||||||
if (!this.carriedPlushies) this.carriedPlushies = [];
|
if (!this.carriedPlushies) this.carriedPlushies = [];
|
||||||
for (const key of Object.keys(counts)) counts[key] = 0;
|
for (const key of Object.keys(counts)) counts[key] = 0;
|
||||||
|
for (const it of this.items || []) if (it) it._worldCountedActive = false;
|
||||||
this.itemTypeBuckets.clear();
|
this.itemTypeBuckets.clear();
|
||||||
this.itemIdMap.clear();
|
this.itemIdMap.clear();
|
||||||
this.itemOwnerBuckets.clear();
|
this.itemOwnerBuckets.clear();
|
||||||
this.carriedPlushies.length = 0;
|
this.carriedPlushies.length = 0;
|
||||||
|
let activeObjectCount = 0;
|
||||||
for (const it of this.items) {
|
for (const it of this.items) {
|
||||||
if (!it || it.dead) continue;
|
if (!it || it.dead) continue;
|
||||||
|
activeObjectCount += 1;
|
||||||
|
it._worldCountedActive = true;
|
||||||
counts[it.type] = (counts[it.type] || 0) + 1;
|
counts[it.type] = (counts[it.type] || 0) + 1;
|
||||||
if (it.id != null) this.itemIdMap.set(it.id, it);
|
if (it.id != null) this.itemIdMap.set(it.id, it);
|
||||||
if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it);
|
if (it.isStructure && it.type === "plushie" && it.carriedById) this.carriedPlushies.push(it);
|
||||||
|
|
@ -390,6 +503,7 @@
|
||||||
}
|
}
|
||||||
bucket.push(it);
|
bucket.push(it);
|
||||||
}
|
}
|
||||||
|
this._activeObjectCountCache = activeObjectCount;
|
||||||
this.itemBucketsDirty = false;
|
this.itemBucketsDirty = false;
|
||||||
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
|
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
|
||||||
},
|
},
|
||||||
|
|
@ -403,29 +517,276 @@
|
||||||
compactItems() {
|
compactItems() {
|
||||||
const before = this.items.length;
|
const before = this.items.length;
|
||||||
let write = 0;
|
let write = 0;
|
||||||
|
let terrainChanged = false;
|
||||||
for (let read = 0; read < this.items.length; read++) {
|
for (let read = 0; read < this.items.length; read++) {
|
||||||
const it = this.items[read];
|
const it = this.items[read];
|
||||||
if (it.type === "mirror") continue;
|
const removed = !it || it.type === "mirror" || it.dead;
|
||||||
if (!it.dead) this.items[write++] = it;
|
if (removed) {
|
||||||
|
if (it) this.noteItemInactive?.(it, "compact-items", false);
|
||||||
|
if (it && (it.type === "grass" || it.type === "trace" || it.type === "splat")) terrainChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.items[write++] = it;
|
||||||
}
|
}
|
||||||
this.items.length = write;
|
this.items.length = write;
|
||||||
const changed = before !== this.items.length;
|
const changed = before !== this.items.length;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
this._activeObjectCountCache = write;
|
||||||
this.markSpatialDirty?.("compact-items");
|
this.markSpatialDirty?.("compact-items");
|
||||||
this.markTerrainDirty?.("compact-items");
|
if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items");
|
||||||
this.markItemBucketsDirty?.("compact-items");
|
this.markItemBucketsDirty?.("compact-items");
|
||||||
|
if (!this._suspendAchievementEvents) global.TarinaiAchievements?.evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" });
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pruneTarinaiRuntimeCaches(reason = "maintenance", opts = {}) {
|
||||||
|
const now = Number(this.time || 0) || 0;
|
||||||
|
const live = (this.tarinai || []).filter(t => t && !t.dead);
|
||||||
|
const aggressive = opts.aggressive === true;
|
||||||
|
const resetRelationPeerCaches = aggressive || opts.resetRelationPeerCaches === true;
|
||||||
|
const pendingDeadIds = this._deadTarinaiIdsPendingCleanup instanceof Set
|
||||||
|
? this._deadTarinaiIdsPendingCleanup
|
||||||
|
: (this._deadTarinaiIdsPendingCleanup = new Set());
|
||||||
|
let liveIds = null;
|
||||||
|
|
||||||
|
// Normal cleanup removes only IDs known to have died since the last prune.
|
||||||
|
// This avoids enumerating every relationship entry after each death batch.
|
||||||
|
if (aggressive) {
|
||||||
|
liveIds = new Set(live.map(t => t.id).filter(Boolean));
|
||||||
|
for (const t of live) {
|
||||||
|
const relationships = t.relationships && typeof t.relationships === "object" ? t.relationships : null;
|
||||||
|
if (!relationships) {
|
||||||
|
if (resetRelationPeerCaches) t.relationCache = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let removed = 0;
|
||||||
|
const next = {};
|
||||||
|
for (const [id, rel] of Object.entries(relationships)) {
|
||||||
|
if (liveIds.has(id) && id !== t.id) next[id] = rel;
|
||||||
|
else removed += 1;
|
||||||
|
}
|
||||||
|
if (removed > 0 || resetRelationPeerCaches) {
|
||||||
|
t.relationships = next;
|
||||||
|
t.relationCache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pendingDeadIds.size > 0) {
|
||||||
|
for (const t of live) {
|
||||||
|
if (typeof t.pruneDeadRelationshipRefs === "function") {
|
||||||
|
t.pruneDeadRelationshipRefs(pendingDeadIds);
|
||||||
|
} else if (t.relationships && typeof t.relationships === "object") {
|
||||||
|
// Restore/tests may temporarily use plain entity records rather than
|
||||||
|
// Tarinai instances. Keep the same dead-ID-only cleanup semantics.
|
||||||
|
for (const id of pendingDeadIds) delete t.relationships[id];
|
||||||
|
}
|
||||||
|
if (resetRelationPeerCaches) t.relationCache = null;
|
||||||
|
}
|
||||||
|
} else if (resetRelationPeerCaches) {
|
||||||
|
for (const t of live) t.relationCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.liveTarinai instanceof Map) {
|
||||||
|
for (const [id, entry] of this.liveTarinai) {
|
||||||
|
if (!entry?.target || entry.target.dead || (aggressive && liveIds && !liveIds.has(id))) this.liveTarinai.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tarinaiCollisionMemo instanceof Map) {
|
||||||
|
if (aggressive) this.tarinaiCollisionMemo = new Map();
|
||||||
|
else for (const [key, at] of this.tarinaiCollisionMemo) if (now - Number(at || 0) > 2.0) this.tarinaiCollisionMemo.delete(key);
|
||||||
|
}
|
||||||
|
if (this.relationNotices && typeof this.relationNotices === "object") {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, at] of Object.entries(this.relationNotices)) {
|
||||||
|
if (now - Number(at || 0) <= 120) next[key] = at;
|
||||||
|
}
|
||||||
|
this.relationNotices = next;
|
||||||
|
}
|
||||||
|
if (this.fightPairCooldowns && typeof this.fightPairCooldowns === "object") {
|
||||||
|
if (aggressive) {
|
||||||
|
this.fightPairCooldowns = {};
|
||||||
|
} else {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, until] of Object.entries(this.fightPairCooldowns)) {
|
||||||
|
if (Number(until || 0) > now) next[key] = until;
|
||||||
|
}
|
||||||
|
this.fightPairCooldowns = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aggressive || now - Number(this._lastResolvedFightIdsResetAt || 0) >= 30) {
|
||||||
|
this.resolvedFightIds = {};
|
||||||
|
this._lastResolvedFightIdsResetAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeadIds.clear();
|
||||||
|
|
||||||
|
if (aggressive) {
|
||||||
|
this._mechanicalCrowdPressure = null;
|
||||||
|
this.tarinaiCollisionMemo = new Map();
|
||||||
|
this.relationNotices = {};
|
||||||
|
this.resolvedFightIds = {};
|
||||||
|
this.fightPairCooldowns = {};
|
||||||
|
this._solidObstacleRectQueryCache = new Map();
|
||||||
|
this.spatialTarinaiScratch = [];
|
||||||
|
this.spatial?.rebuildTarinai?.(this.tarinai || []);
|
||||||
|
this._renderVisibleTarinaiScratch = [];
|
||||||
|
this._renderVisibleSeen = new Set();
|
||||||
|
this._renderDynamicBack = [];
|
||||||
|
this._renderDynamicLayered = [];
|
||||||
|
this._renderDynamicLayerPool = [];
|
||||||
|
this._visibleRenderStack = null;
|
||||||
|
this.drawList = [];
|
||||||
|
this.drawListDirty = true;
|
||||||
|
this.markSpatialDirty?.("tarinai-runtime-shrink");
|
||||||
|
this._tarinaiHighSpeedCollisionCursor = 0;
|
||||||
|
global.TarinaiHistory?.trimMemory?.(this, { targetBytes: 8 * 1024 * 1024, keepRecent: 10 });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
queueTarinaiRuntimeCachePrune(reason = "death-threshold", opts = {}) {
|
||||||
|
if (this._tarinaiRuntimePruneJob) return false;
|
||||||
|
const pending = this._deadTarinaiIdsPendingCleanup instanceof Set
|
||||||
|
? this._deadTarinaiIdsPendingCleanup
|
||||||
|
: (this._deadTarinaiIdsPendingCleanup = new Set());
|
||||||
|
const aggressive = opts.aggressive === true;
|
||||||
|
if (!aggressive && pending.size === 0) return false;
|
||||||
|
const live = this.tarinai || [];
|
||||||
|
this._tarinaiRuntimePruneJob = {
|
||||||
|
reason,
|
||||||
|
aggressive,
|
||||||
|
resetRelationPeerCaches: aggressive || opts.resetRelationPeerCaches === true,
|
||||||
|
deadIds: new Set(pending),
|
||||||
|
liveIds: aggressive ? new Set(live.filter(t => t && !t.dead && t.id).map(t => t.id)) : null,
|
||||||
|
cursor: 0,
|
||||||
|
};
|
||||||
|
this.deadTarinaiSinceRuntimePrune = 0;
|
||||||
|
this.tarinaiRuntimePrunePending = false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
processTarinaiRuntimeCachePruneStep(limit = 48) {
|
||||||
|
const job = this._tarinaiRuntimePruneJob;
|
||||||
|
if (!job) return false;
|
||||||
|
const live = this.tarinai || [];
|
||||||
|
let processed = 0;
|
||||||
|
while (job.cursor < live.length && processed < Math.max(1, limit | 0)) {
|
||||||
|
const t = live[job.cursor++];
|
||||||
|
processed += 1;
|
||||||
|
if (!t || t.dead) continue;
|
||||||
|
if (job.aggressive) {
|
||||||
|
const relationships = t.relationships && typeof t.relationships === "object" ? t.relationships : null;
|
||||||
|
if (relationships) {
|
||||||
|
const next = {};
|
||||||
|
for (const [id, rel] of Object.entries(relationships)) {
|
||||||
|
if (job.liveIds.has(id) && id !== t.id) next[id] = rel;
|
||||||
|
}
|
||||||
|
t.relationships = next;
|
||||||
|
}
|
||||||
|
if (job.resetRelationPeerCaches) t.relationCache = null;
|
||||||
|
} else if (job.deadIds.size > 0) {
|
||||||
|
if (typeof t.pruneDeadRelationshipRefs === "function") t.pruneDeadRelationshipRefs(job.deadIds);
|
||||||
|
else if (t.relationships && typeof t.relationships === "object") {
|
||||||
|
for (const id of job.deadIds) delete t.relationships[id];
|
||||||
|
}
|
||||||
|
if (job.resetRelationPeerCaches) t.relationCache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (job.cursor < live.length) return false;
|
||||||
|
|
||||||
|
const now = Number(this.time || 0) || 0;
|
||||||
|
if (this.liveTarinai instanceof Map) {
|
||||||
|
for (const [id, entry] of this.liveTarinai) {
|
||||||
|
if (!entry?.target || entry.target.dead || (job.aggressive && job.liveIds && !job.liveIds.has(id))) this.liveTarinai.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.tarinaiCollisionMemo instanceof Map) {
|
||||||
|
if (job.aggressive) this.tarinaiCollisionMemo = new Map();
|
||||||
|
else for (const [key, at] of this.tarinaiCollisionMemo) if (now - Number(at || 0) > 2.0) this.tarinaiCollisionMemo.delete(key);
|
||||||
|
}
|
||||||
|
if (this.relationNotices && typeof this.relationNotices === "object") {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, at] of Object.entries(this.relationNotices)) if (now - Number(at || 0) <= 120) next[key] = at;
|
||||||
|
this.relationNotices = next;
|
||||||
|
}
|
||||||
|
if (this.fightPairCooldowns && typeof this.fightPairCooldowns === "object") {
|
||||||
|
if (job.aggressive) this.fightPairCooldowns = {};
|
||||||
|
else {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, until] of Object.entries(this.fightPairCooldowns)) if (Number(until || 0) > now) next[key] = until;
|
||||||
|
this.fightPairCooldowns = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (job.aggressive || now - Number(this._lastResolvedFightIdsResetAt || 0) >= 30) {
|
||||||
|
this.resolvedFightIds = {};
|
||||||
|
this._lastResolvedFightIdsResetAt = now;
|
||||||
|
}
|
||||||
|
const pending = this._deadTarinaiIdsPendingCleanup;
|
||||||
|
if (pending instanceof Set) for (const id of job.deadIds) pending.delete(id);
|
||||||
|
|
||||||
|
if (job.aggressive) {
|
||||||
|
this._mechanicalCrowdPressure = null;
|
||||||
|
this.tarinaiCollisionMemo = new Map();
|
||||||
|
this.relationNotices = {};
|
||||||
|
this.resolvedFightIds = {};
|
||||||
|
this.fightPairCooldowns = {};
|
||||||
|
this._solidObstacleRectQueryCache = new Map();
|
||||||
|
this.spatialTarinaiScratch = [];
|
||||||
|
this._renderVisibleTarinaiScratch = [];
|
||||||
|
this._renderVisibleSeen = new Set();
|
||||||
|
this._renderDynamicBack = [];
|
||||||
|
this._renderDynamicLayered = [];
|
||||||
|
this._renderDynamicLayerPool = [];
|
||||||
|
this._visibleRenderStack = null;
|
||||||
|
this.drawList = [];
|
||||||
|
this.drawListDirty = true;
|
||||||
|
this.spatial?.rebuildTarinai?.(this.tarinai || []);
|
||||||
|
this._tarinaiHighSpeedCollisionCursor = 0;
|
||||||
|
global.TarinaiHistory?.trimMemory?.(this, { targetBytes: 8 * 1024 * 1024, keepRecent: 10 });
|
||||||
|
}
|
||||||
|
this._tarinaiRuntimePruneJob = null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
noteTarinaiDeathForRuntimeCleanup(tarinaiId = "") {
|
||||||
|
if (this.tarinaiRuntimePrunePending !== true) this.tarinaiRuntimePrunePending = false;
|
||||||
|
if (!(this._deadTarinaiIdsPendingCleanup instanceof Set)) this._deadTarinaiIdsPendingCleanup = new Set();
|
||||||
|
if (tarinaiId) this._deadTarinaiIdsPendingCleanup.add(String(tarinaiId));
|
||||||
|
this.deadTarinaiSinceRuntimePrune = Math.max(0, Number(this.deadTarinaiSinceRuntimePrune || 0) || 0) + 1;
|
||||||
|
const livingPopulation = this.liveTarinai instanceof Map
|
||||||
|
? this.liveTarinai.size
|
||||||
|
: Math.max(0, (this.tarinai?.length || 0) - this.deadTarinaiSinceRuntimePrune);
|
||||||
|
const cleanupThreshold = Math.max(20, Math.floor(livingPopulation * 0.15));
|
||||||
|
if (this.deadTarinaiSinceRuntimePrune >= cleanupThreshold) this.tarinaiRuntimePrunePending = true;
|
||||||
|
return this.tarinaiRuntimePrunePending === true;
|
||||||
|
},
|
||||||
|
|
||||||
compactTarinai() {
|
compactTarinai() {
|
||||||
|
const before = this.tarinai.length;
|
||||||
let write = 0;
|
let write = 0;
|
||||||
for (let read = 0; read < this.tarinai.length; read++) {
|
for (let read = 0; read < this.tarinai.length; read++) {
|
||||||
const t = this.tarinai[read];
|
const t = this.tarinai[read];
|
||||||
if (!t.dead) this.tarinai[write++] = t;
|
if (t && !t.dead) this.tarinai[write++] = t;
|
||||||
}
|
}
|
||||||
if (this.tarinai.length !== write) this.drawListDirty = true;
|
const changed = before !== write;
|
||||||
this.tarinai.length = write;
|
this.tarinai.length = write;
|
||||||
|
const previousPeak = Math.max(Number(this._tarinaiRuntimeHighWater || 0) || 0, before);
|
||||||
|
this._tarinaiRuntimeHighWater = Math.max(write, previousPeak);
|
||||||
|
if (changed) {
|
||||||
|
this.drawListDirty = true;
|
||||||
|
this.markSpatialDirty?.("compact-tarinai");
|
||||||
|
const collapsed = previousPeak >= 80 && write <= previousPeak * 0.72 && previousPeak - write >= 24;
|
||||||
|
const cleanupRequested = this.tarinaiRuntimePrunePending === true;
|
||||||
|
if (collapsed || cleanupRequested) {
|
||||||
|
this.queueTarinaiRuntimeCachePrune?.("death-threshold-runtime-cache-prune", {
|
||||||
|
aggressive: collapsed,
|
||||||
|
resetRelationPeerCaches: true,
|
||||||
|
});
|
||||||
|
if (collapsed) this._tarinaiRuntimeHighWater = Math.max(write, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
},
|
},
|
||||||
|
|
||||||
compactAnts() {
|
compactAnts() {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,15 @@
|
||||||
return clamp(v, CONFIG.climateTemperatureMin ?? -20, CONFIG.climateTemperatureMax ?? 50);
|
return clamp(v, CONFIG.climateTemperatureMin ?? -20, CONFIG.climateTemperatureMax ?? 50);
|
||||||
},
|
},
|
||||||
seasonIndex() {
|
seasonIndex() {
|
||||||
return Math.floor((((Number(this.day) || 1) - 1) % 20) / 5);
|
const elapsedDays = Number.isFinite(Number(this.elapsedDays)) ? Math.max(0, Number(this.elapsedDays) || 0) : Math.max(0, (Number(this.day) || 1) - 1);
|
||||||
|
return Math.floor((elapsedDays % 20) / 5);
|
||||||
},
|
},
|
||||||
seasonLabel() {
|
seasonLabel() {
|
||||||
return ["\u6625", "\u590F", "\u79CB", "\u51AC"][this.seasonIndex()] || "\u6625";
|
return ["\u6625", "\u590F", "\u79CB", "\u51AC"][this.seasonIndex()] || "\u6625";
|
||||||
},
|
},
|
||||||
seasonDay() {
|
seasonDay() {
|
||||||
return ((((Number(this.day) || 1) - 1) % 5) + 1);
|
const elapsedDays = Number.isFinite(Number(this.elapsedDays)) ? Math.max(0, Number(this.elapsedDays) || 0) : Math.max(0, (Number(this.day) || 1) - 1);
|
||||||
|
return (elapsedDays % 5) + 1;
|
||||||
},
|
},
|
||||||
seasonDayString() {
|
seasonDayString() {
|
||||||
return `${this.seasonLabel()}${this.seasonDay()}\u65E5`;
|
return `${this.seasonLabel()}${this.seasonDay()}\u65E5`;
|
||||||
|
|
@ -44,14 +46,19 @@
|
||||||
},
|
},
|
||||||
annualTemperatureOffset() {
|
annualTemperatureOffset() {
|
||||||
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
||||||
const totalDays = Math.max(0, Number(this.time || 0) / dayLength);
|
const progress = ((Number(this.time || 0) % dayLength) + dayLength) % dayLength / dayLength;
|
||||||
|
const completedDays = Number.isFinite(Number(this.elapsedDays)) ? Math.max(0, Number(this.elapsedDays) || 0) : Math.max(0, Math.floor(Number(this.time || 0) / dayLength));
|
||||||
|
const totalDays = completedDays + progress;
|
||||||
const phase = ((totalDays % 20) + 20) % 20 / 20;
|
const phase = ((totalDays % 20) + 20) % 20 / 20;
|
||||||
const range = Number.isFinite(Number(this.temperatureAnnualRange)) ? Number(this.temperatureAnnualRange) : 30;
|
const range = Number.isFinite(Number(this.temperatureAnnualRange)) ? Number(this.temperatureAnnualRange) : 30;
|
||||||
const wave = Math.sin(phase * Math.PI * 2 - Math.PI * 0.25);
|
const wave = Math.sin(phase * Math.PI * 2 - Math.PI * 0.25);
|
||||||
return wave * range * 0.5;
|
return wave * range * 0.5;
|
||||||
},
|
},
|
||||||
seasonTemperatureBias() {
|
seasonTemperatureBias() {
|
||||||
return this.seasonIndex?.() === 1 ? (CONFIG.summerTemperatureBoost ?? 5) : 0;
|
// Seasons are derived from elapsed days, but no season receives a flat
|
||||||
|
// temperature offset. Annual and daily waves remain the only climate
|
||||||
|
// temperature variation.
|
||||||
|
return 0;
|
||||||
},
|
},
|
||||||
dailyTemperatureWave(progress = 0) {
|
dailyTemperatureWave(progress = 0) {
|
||||||
const hour = ((((Number(progress) || 0) % 1) + 1) % 1) * 24;
|
const hour = ((((Number(progress) || 0) % 1) + 1) % 1) * 24;
|
||||||
|
|
@ -126,7 +133,7 @@
|
||||||
if ((this.groundType || "soil") === "ice") temp -= 5;
|
if ((this.groundType || "soil") === "ice") temp -= 5;
|
||||||
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
||||||
const fanRange = 320;
|
const fanRange = 320;
|
||||||
for (const it of this.nearbyItems(x, y, Math.max(range + 8, fanRange + 40))) {
|
for (const it of this.nearbyNonGrassItems?.(x, y, Math.max(range + 8, fanRange + 40)) || this.nearbyItems(x, y, Math.max(range + 8, fanRange + 40))) {
|
||||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue;
|
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue;
|
||||||
if (global.TarinaiSignalSystem?.powerOverride?.(it) === false) continue;
|
if (global.TarinaiSignalSystem?.powerOverride?.(it) === false) continue;
|
||||||
if (it.type === "fan") {
|
if (it.type === "fan") {
|
||||||
|
|
@ -141,7 +148,7 @@
|
||||||
temp += (it.type === "stove" ? 10 : -10) * edge;
|
temp += (it.type === "stove" ? 10 : -10) * edge;
|
||||||
}
|
}
|
||||||
const nestWarmthLimit = 5;
|
const nestWarmthLimit = 5;
|
||||||
for (const box of this.nearbyItems?.(x, y, 86) || []) {
|
for (const box of this.nearbyNonGrassItems?.(x, y, 86) || []) {
|
||||||
if (!box || box.dead || box.type !== "nest_box") continue;
|
if (!box || box.dead || box.type !== "nest_box") continue;
|
||||||
const r = Math.max(36, Number(box.r || 42) || 42);
|
const r = Math.max(36, Number(box.r || 42) || 42);
|
||||||
const base = this.nestBoxBaseRect?.(box) || null;
|
const base = this.nestBoxBaseRect?.(box) || null;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@
|
||||||
}
|
}
|
||||||
if (!candidates.length) { if (!options.silentEmpty) showToast("\u7bc4\u56f2\u5185\u306b\u6d88\u305b\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return true; }
|
if (!candidates.length) { if (!options.silentEmpty) showToast("\u7bc4\u56f2\u5185\u306b\u6d88\u305b\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return true; }
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
const deleteOperationId = `area-delete:${Math.max(0, Number(this.time || 0) || 0)}:${Math.random()}`;
|
||||||
const kindCounts = new Map();
|
const kindCounts = new Map();
|
||||||
for (const target of candidates) {
|
for (const target of candidates) {
|
||||||
if (!target || target.dead) continue;
|
if (!target || target.dead) continue;
|
||||||
|
|
@ -100,7 +101,7 @@
|
||||||
wake: true,
|
wake: true,
|
||||||
});
|
});
|
||||||
if (!removed) continue;
|
if (!removed) continue;
|
||||||
global.TarinaiAchievements?.recordPlayerDeletion?.({ world: this, item: target, tool: "area_delete" });
|
global.TarinaiAchievements?.recordPlayerDeletion?.({ world: this, item: target, tool: "area_delete", operationId: deleteOperationId });
|
||||||
removedCount += 1;
|
removedCount += 1;
|
||||||
const label = toolLabel(target.type);
|
const label = toolLabel(target.type);
|
||||||
kindCounts.set(label, (kindCounts.get(label) || 0) + 1);
|
kindCounts.set(label, (kindCounts.get(label) || 0) + 1);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
fearful: { id: "fearful", label: "\u6050\u6016", description: "\u5b89\u5168\u3092\u6c42\u3081\u308b\u500b\u4f53\u304c\u591a\u304f\u3001\u5371\u967a\u3092\u907f\u3051\u3088\u3046\u3068\u3057\u3066\u3044\u307e\u3059\u3002", effects: { personality: {} } },
|
fearful: { id: "fearful", label: "\u6050\u6016", description: "\u5b89\u5168\u3092\u6c42\u3081\u308b\u500b\u4f53\u304c\u591a\u304f\u3001\u5371\u967a\u3092\u907f\u3051\u3088\u3046\u3068\u3057\u3066\u3044\u307e\u3059\u3002", effects: { personality: {} } },
|
||||||
weakened: { id: "weakened", label: "\u8870\u5f31", description: "\u4f53\u529b\u3084\u5143\u6c17\u304c\u843d\u3061\u3001\u52d5\u304d\u304c\u5f31\u304f\u306a\u3063\u3066\u3044\u307e\u3059\u3002", effects: { personality: {} } },
|
weakened: { id: "weakened", label: "\u8870\u5f31", description: "\u4f53\u529b\u3084\u5143\u6c17\u304c\u843d\u3061\u3001\u52d5\u304d\u304c\u5f31\u304f\u306a\u3063\u3066\u3044\u307e\u3059\u3002", effects: { personality: {} } },
|
||||||
disease_spread: { id: "disease_spread", label: "\u75c5\u6c17\u8513\u5ef6", description: "\u75c5\u6c17\u306e\u500b\u4f53\u304c\u76ee\u7acb\u3061\u3001\u8abf\u5b50\u3092\u5d29\u3057\u3084\u3059\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
disease_spread: { id: "disease_spread", label: "\u75c5\u6c17\u8513\u5ef6", description: "\u75c5\u6c17\u306e\u500b\u4f53\u304c\u76ee\u7acb\u3061\u3001\u8abf\u5b50\u3092\u5d29\u3057\u3084\u3059\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
||||||
overcrowded: { id: "overcrowded", label: "\u904e\u5bc6", description: "\u500b\u4f53\u540c\u58eb\u306e\u8ddd\u96e2\u304c\u8fd1\u304f\u3001\u3076\u3064\u304b\u308a\u3084\u3059\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
|
||||||
isolated: { id: "isolated", label: "\u5b64\u7acb", description: "\u4ef2\u9593\u304c\u5c11\u306a\u3044\u3001\u307e\u305f\u306f\u8ddd\u96e2\u304c\u96e2\u308c\u3066\u5bc2\u3057\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
isolated: { id: "isolated", label: "\u5b64\u7acb", description: "\u4ef2\u9593\u304c\u5c11\u306a\u3044\u3001\u307e\u305f\u306f\u8ddd\u96e2\u304c\u96e2\u308c\u3066\u5bc2\u3057\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
||||||
breeding: { id: "breeding", label: "\u7e41\u6b96", description: "\u7e41\u6b96\u3084\u89aa\u5bc6\u306a\u884c\u52d5\u304c\u8d77\u3053\u308a\u3084\u3059\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
breeding: { id: "breeding", label: "\u7e41\u6b96", description: "\u7e41\u6b96\u3084\u89aa\u5bc6\u306a\u884c\u52d5\u304c\u8d77\u3053\u308a\u3084\u3059\u3044\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
||||||
happy: { id: "happy", label: "\u5e78\u798f", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5c11\u306a\u304f\u3001\u4f59\u88d5\u306e\u3042\u308b\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
happy: { id: "happy", label: "\u5e78\u798f", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5c11\u306a\u304f\u3001\u4f59\u88d5\u306e\u3042\u308b\u72b6\u614b\u3067\u3059\u3002", effects: { personality: {} } },
|
||||||
|
|
@ -84,7 +83,7 @@
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
const limit = Math.max(1, Number(options.limit || 180) || 180);
|
const limit = Math.max(1, Number(options.limit || 180) || 180);
|
||||||
const effectLimit = Math.max(0, Number(options.effectLimit || 12) || 12);
|
const effectLimit = Math.max(0, Number(options.effectLimit || 12) || 12);
|
||||||
for (const it of this.nearbyItems(x, y, radius + 60)) {
|
for (const it of (this.nearbyNonGrassItems?.(x, y, radius + 60) || this.nearbyItems(x, y, radius + 60))) {
|
||||||
if (moved >= limit) break;
|
if (moved >= limit) break;
|
||||||
if (!it || it.dead || it.type !== "zunchi") continue;
|
if (!it || it.dead || it.type !== "zunchi") continue;
|
||||||
let dx = it.x - x;
|
let dx = it.x - x;
|
||||||
|
|
@ -101,7 +100,7 @@
|
||||||
it.amount = Math.max(10, (it.amount || 80) - p * 8);
|
it.amount = Math.max(10, (it.amount || 80) - p * 8);
|
||||||
it.stage = "fresh";
|
it.stage = "fresh";
|
||||||
moved += 1;
|
moved += 1;
|
||||||
if (moved < effectLimit) this.effects.push(new Effect("zunchi_miasma", it.x, it.y - 4, { vx: (it.vx || 0) * 0.15, vy: (it.vy || 0) * 0.15 - 18, size: rand(9, 18), life: rand(0.36, 0.62), color: "rgba(58,102,38,0.48)" }));
|
if (moved < effectLimit) this.spawnEffect("zunchi_miasma", it.x, it.y - 4, { vx: (it.vx || 0) * 0.15, vy: (it.vy || 0) * 0.15 - 18, size: rand(9, 18), life: rand(0.36, 0.62), color: "rgba(58,102,38,0.48)" });
|
||||||
}
|
}
|
||||||
if (moved) this.drawListDirty = true;
|
if (moved) this.drawListDirty = true;
|
||||||
return moved;
|
return moved;
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@
|
||||||
const World = global.World;
|
const World = global.World;
|
||||||
if (!World) throw new Error("World is not available for mixin: world_view.js");
|
if (!World) throw new Error("World is not available for mixin: world_view.js");
|
||||||
const BASE_ZOOM_MIN = 0.42;
|
const BASE_ZOOM_MIN = 0.42;
|
||||||
const BASE_ZOOM_MAX = 2.45;
|
const BASE_ZOOM_MAX = 4.5;
|
||||||
|
|
||||||
function terrainSignature(worldRef) {
|
function terrainSignature(worldRef) {
|
||||||
const counts = worldRef.itemCounts || {};
|
// Terrain item additions/removals already invalidate only their affected chunks.
|
||||||
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${worldRef.weather || "sunny"}`;
|
// Keep the global signature limited to changes that truly require every chunk.
|
||||||
|
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maxCameraX(worldRef) {
|
function maxCameraX(worldRef) {
|
||||||
|
|
@ -62,10 +63,14 @@
|
||||||
markSpatialDirty(reason = "manual") {
|
markSpatialDirty(reason = "manual") {
|
||||||
this.spatialDirty = true;
|
this.spatialDirty = true;
|
||||||
this.spatialDirtyReason = reason;
|
this.spatialDirtyReason = reason;
|
||||||
if (!this.spatialDirtyReasonCountsThisFrame) this.spatialDirtyReasonCountsThisFrame = {};
|
if (this.spatialDirtyReasonCountsThisFrame) {
|
||||||
this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] = (this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
|
const key = String(reason || "manual");
|
||||||
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
|
this.spatialDirtyReasonCountsThisFrame[key] = (this.spatialDirtyReasonCountsThisFrame[key] || 0) + 1;
|
||||||
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
|
}
|
||||||
|
if (this.spatialDirtyReasonCountsThisFrame) {
|
||||||
|
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
|
||||||
|
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
|
||||||
|
}
|
||||||
this.lastSpatialDirtyReason = reason;
|
this.lastSpatialDirtyReason = reason;
|
||||||
const r = String(reason || "manual");
|
const r = String(reason || "manual");
|
||||||
if (/tarinai-moved|post-tarinai|creature/i.test(r)) {
|
if (/tarinai-moved|post-tarinai|creature/i.test(r)) {
|
||||||
|
|
@ -74,22 +79,28 @@
|
||||||
this.spatialAntDirty = true;
|
this.spatialAntDirty = true;
|
||||||
} else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi|mechanical|rotator|reciprocator|poison-block|constraint|rod-/i.test(r)) {
|
} else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi|mechanical|rotator|reciprocator|poison-block|constraint|rod-/i.test(r)) {
|
||||||
this.spatialDynamicItemsDirty = true;
|
this.spatialDynamicItemsDirty = true;
|
||||||
|
// Ordinary moving items are not routing obstacles. Only explicit fence,
|
||||||
|
// gate, or nest geometry changes invalidate obstacle-route caches.
|
||||||
|
if (/fence|gate|nest-box|pipe/i.test(r)) this.routingObstacleVersion = (this.routingObstacleVersion || 0) + 1;
|
||||||
} else {
|
} else {
|
||||||
this.spatialStaticItemsDirty = true;
|
this.spatialStaticItemsDirty = true;
|
||||||
this.spatialDynamicItemsDirty = true;
|
this.spatialDynamicItemsDirty = true;
|
||||||
this.spatialTarinaiDirty = true;
|
this.spatialTarinaiDirty = true;
|
||||||
this.spatialAntDirty = true;
|
this.spatialAntDirty = true;
|
||||||
|
this.routingObstacleVersion = (this.routingObstacleVersion || 0) + 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
markTerrainDirty(reason = "manual") {
|
markTerrainDirty(reason = "manual") {
|
||||||
const reasonKey = String(reason || "manual");
|
const reasonKey = String(reason || "manual");
|
||||||
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
|
const terrainStats = this.terrainDirtyStatsThisFrame;
|
||||||
this.terrainDirtyStatsThisFrame.raw += 1;
|
if (terrainStats) {
|
||||||
this.terrainDirtyStatsThisFrame.global += 1;
|
terrainStats.raw += 1;
|
||||||
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
|
terrainStats.global += 1;
|
||||||
|
terrainStats.reasons[reasonKey] = (terrainStats.reasons[reasonKey] || 0) + 1;
|
||||||
|
}
|
||||||
if (this.terrainDirtyGlobal && this.terrainDirtyReason === reasonKey) {
|
if (this.terrainDirtyGlobal && this.terrainDirtyReason === reasonKey) {
|
||||||
this.terrainDirtyStatsThisFrame.coalesced += 1;
|
terrainStats && (terrainStats.coalesced += 1);
|
||||||
this.terrainDirty = true;
|
this.terrainDirty = true;
|
||||||
this.terrainLastDirtyAt = this.time || 0;
|
this.terrainLastDirtyAt = this.time || 0;
|
||||||
return;
|
return;
|
||||||
|
|
@ -106,10 +117,12 @@
|
||||||
const cx = Math.floor((Number(x) || 0) / 256);
|
const cx = Math.floor((Number(x) || 0) / 256);
|
||||||
const cy = Math.floor((Number(y) || 0) / 256);
|
const cy = Math.floor((Number(y) || 0) / 256);
|
||||||
const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256));
|
const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256));
|
||||||
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
|
const terrainStats = this.terrainDirtyStatsThisFrame;
|
||||||
this.terrainDirtyStatsThisFrame.raw += 1;
|
if (terrainStats) {
|
||||||
this.terrainDirtyStatsThisFrame.chunk += 1;
|
terrainStats.raw += 1;
|
||||||
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
|
terrainStats.chunk += 1;
|
||||||
|
terrainStats.reasons[reasonKey] = (terrainStats.reasons[reasonKey] || 0) + 1;
|
||||||
|
}
|
||||||
if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set();
|
if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set();
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (let yy = cy - cr; yy <= cy + cr; yy++) {
|
for (let yy = cy - cr; yy <= cy + cr; yy++) {
|
||||||
|
|
@ -123,7 +136,7 @@
|
||||||
this.terrainDirtyReason = reasonKey;
|
this.terrainDirtyReason = reasonKey;
|
||||||
this.terrainLastDirtyAt = this.time || 0;
|
this.terrainLastDirtyAt = this.time || 0;
|
||||||
if (added <= 0) {
|
if (added <= 0) {
|
||||||
this.terrainDirtyStatsThisFrame.coalesced += 1;
|
terrainStats && (terrainStats.coalesced += 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.terrainVersion = (this.terrainVersion || 0) + 1;
|
this.terrainVersion = (this.terrainVersion || 0) + 1;
|
||||||
|
|
@ -136,10 +149,7 @@
|
||||||
if (signature !== this.lastTerrainSignature) {
|
if (signature !== this.lastTerrainSignature) {
|
||||||
this.lastTerrainSignature = signature;
|
this.lastTerrainSignature = signature;
|
||||||
this.markTerrainDirty("terrain-signature");
|
this.markTerrainDirty("terrain-signature");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const interval = 3.0;
|
|
||||||
if ((this.time || 0) - (this.terrainLastDirtyAt || 0) >= interval) this.markTerrainDirty("terrain-periodic");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
phaseName() {
|
phaseName() {
|
||||||
|
|
|
||||||
|
|
@ -113,17 +113,35 @@ const expectedIds = [
|
||||||
"park_ground_changed", "ground_change_4_in_1_second",
|
"park_ground_changed", "ground_change_4_in_1_second",
|
||||||
"ant_nest_without_tarinai", "sticky_bomb_15_passes",
|
"ant_nest_without_tarinai", "sticky_bomb_15_passes",
|
||||||
"daily_play_7_days", "wire_shock_7_tarinai", "information_industry",
|
"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",
|
||||||
];
|
];
|
||||||
|
|
||||||
(async function run() {
|
(async function run() {
|
||||||
let h = createHarness();
|
let h = createHarness();
|
||||||
let storage;
|
let storage;
|
||||||
assert(h.api.definitions.length === 64, "definition count must be 64");
|
assert(h.api.definitions.length === 75, "definition count must be 75");
|
||||||
|
{
|
||||||
|
const spellHarness = createHarness();
|
||||||
|
spellHarness.api.unlock("statistician");
|
||||||
|
const spell = spellHarness.api.exportSpellState();
|
||||||
|
assert(spell[0] === 8, "current achievement spell version must be 8");
|
||||||
|
assert(spell[3] === 128, "achievement 72 is not stored in the extended mask");
|
||||||
|
spellHarness.api.unlock("memento_mori");
|
||||||
|
const extendedSpell = spellHarness.api.exportSpellState();
|
||||||
|
assert((extendedSpell[3] & 0x0400) !== 0, "achievement 75 is not stored in the second extended mask byte");
|
||||||
|
const oldSpellHarness = createHarness();
|
||||||
|
const imported = oldSpellHarness.api.importSpellState([7, 1, 0, 0, 100, [0], []]);
|
||||||
|
assert(!imported.included && !oldSpellHarness.api.isUnlocked("first_birth"), "obsolete achievement spell version was accepted");
|
||||||
|
}
|
||||||
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
|
assert(JSON.stringify(h.api.definitions.map(d => d.id)) === JSON.stringify(expectedIds), "definition IDs mismatch");
|
||||||
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
const categorizedIds = h.api.categories.flatMap(category => category.ids);
|
||||||
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
assert(categorizedIds.length === expectedIds.length, "achievement category count mismatch");
|
||||||
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
|
assert(new Set(categorizedIds).size === expectedIds.length, "achievement category contains duplicates");
|
||||||
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
|
assert(expectedIds.every(id => categorizedIds.includes(id)), "achievement category misses an ID");
|
||||||
|
assert(h.api.categories.length === 6, "achievement categories must be reorganized into six groups");
|
||||||
|
assert(JSON.stringify(h.api.categories.map(category => category.title)) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles/order mismatch");
|
||||||
h.api.open();
|
h.api.open();
|
||||||
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
|
const titles = Object.fromEntries(h.api.definitions.map(def => [def.id, def.title]));
|
||||||
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
|
assert(titles.natural_zunchi_slave === "ずんちどれい", "slave title mismatch");
|
||||||
|
|
@ -179,7 +197,7 @@ const expectedIds = [
|
||||||
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
|
assert(descriptions.sniper_333_shots.includes("333発"), "sniper description mismatch");
|
||||||
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
|
assert(descriptions.megalopolis.includes("10個") && descriptions.megalopolis.includes("15個") && descriptions.megalopolis.includes("100体"), "megalopolis description mismatch");
|
||||||
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
|
assert(descriptions.fertility_seeker_721_love_births.includes("へこ餅") && descriptions.fertility_seeker_721_love_births.includes("721回"), "fertility description mismatch");
|
||||||
assert(descriptions.idle_observer_5_minutes === "5分間画面を見るだけ", "observer description mismatch");
|
assert(descriptions.idle_observer_5_minutes === "3ゲーム日、操作せず画面を見るだけ", "observer description mismatch");
|
||||||
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
|
assert(descriptions.safe_colony_25_5_minutes.includes("寿命以外で"), "safe-colony lifespan exclusion description mismatch");
|
||||||
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
|
assert(descriptions.pause_spam_4_in_1_second.includes("何もない部分"), "empty-click description mismatch");
|
||||||
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
|
assert(descriptions.low_fps_single_digit === "fpsを1桁にする。", "low-fps description mismatch");
|
||||||
|
|
@ -200,20 +218,26 @@ const expectedIds = [
|
||||||
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
const eternalHistoryCard = cards.find(node => node.dataset.achievementId === "eternal_history_generation_10");
|
||||||
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
|
const lowFpsCard = cards.find(node => node.dataset.achievementId === "low_fps_single_digit");
|
||||||
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
|
const sleepCard = cards.find(node => node.dataset.achievementId === "continuous_play_24_hours");
|
||||||
|
const livelyCard = cards.find(node => node.dataset.achievementId === "lively_making");
|
||||||
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
|
const hiddenCard = cards.find(node => node.dataset.achievementId === "self_zunchi_death");
|
||||||
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
|
assert(firstBirthCard.children[1].children[0].children[1].textContent === descriptions.first_birth, "disclosed locked condition is not shown in the UI");
|
||||||
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard]) {
|
for (const card of [disclosedSlaveCard, disclosedKingCard, disclosedRevolutionCard, disclosedLaxativeCard, eternalHistoryCard, lowFpsCard, sleepCard, livelyCard]) {
|
||||||
assert(card.children[1].children[0].children[1].textContent !== "???", "requested pre-unlock condition is hidden");
|
assert(card.children[1].children[0].children[1].textContent !== "???", "requested pre-unlock condition is hidden");
|
||||||
}
|
}
|
||||||
assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks");
|
assert(hiddenCard.children[1].children[0].children[1].textContent === "???", "undisclosed locked condition is not hidden as question marks");
|
||||||
const groups = h.elements.get("achievementList").children;
|
const groups = h.elements.get("achievementList").children;
|
||||||
assert(groups.length === 4, "achievement UI must contain exactly four category groups");
|
assert(groups.length === 6, "achievement UI must contain exactly six category groups");
|
||||||
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "experiment", "construction", "operation"]), "achievement category order mismatch");
|
assert(JSON.stringify(groups.map(group => group.dataset.achievementGroup)) === JSON.stringify(["ecology", "population", "experiment", "construction", "operation", "other"]), "achievement category order mismatch");
|
||||||
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
assert(groups.every(group => group.tagName === "DETAILS"), "achievement categories are not collapsible details");
|
||||||
const categoryTitles = h.api.categories.map(category => category.title);
|
const categoryTitles = h.api.categories.map(category => category.title);
|
||||||
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "実験", "建築", "その他"]), "achievement category titles mismatch");
|
assert(JSON.stringify(categoryTitles) === JSON.stringify(["生態", "繁殖・人口", "実験", "建築", "操作", "その他"]), "achievement category titles mismatch");
|
||||||
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
|
const operationIds = h.api.categories.find(category => category.id === "operation").ids;
|
||||||
assert(JSON.stringify(operationIds.slice(-2)) === JSON.stringify(["continuous_play_1_hour", "true_tarinai_observer"]), "observation achievements are not at the bottom of その他");
|
const otherIds = h.api.categories.find(category => category.id === "other").ids;
|
||||||
|
const populationIds = h.api.categories.find(category => category.id === "population").ids;
|
||||||
|
assert(otherIds.at(-1) === "true_tarinai_observer", "completionist is not at the bottom of その他");
|
||||||
|
assert(otherIds.includes("across_seasons"), "across_seasons is not in その他");
|
||||||
|
assert(operationIds.includes("statistician") && operationIds.includes("well_informed"), "requested 操作 achievements are missing");
|
||||||
|
assert(populationIds.includes("lively_making") && populationIds.includes("memento_mori"), "population achievements are missing from 繁殖・人口");
|
||||||
h.api.unlock("natural_zunchi_slave");
|
h.api.unlock("natural_zunchi_slave");
|
||||||
const rerenderedGroups = h.elements.get("achievementList").children;
|
const rerenderedGroups = h.elements.get("achievementList").children;
|
||||||
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
const ecologyCards = walk(rerenderedGroups[0]).filter(node => node.dataset?.achievementId);
|
||||||
|
|
@ -262,16 +286,21 @@ const expectedIds = [
|
||||||
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
|
assert(unlocked(h, "laxative_starvation"), "laxative starvation did not unlock");
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
const slaveWorld = {};
|
const slaveWorld = { tarinai: [] };
|
||||||
[1, 2, 4, 5, 6].forEach(generation => h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation } }));
|
let parent = { id: "s1", parents: [], generation: 1 }; slaveWorld.tarinai.push(parent); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: parent });
|
||||||
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "slave run ignored generation gap");
|
for (let i = 2; i <= 5; i += 1) { const child = { id: `s${i}`, parents: [parent.id], generation: i }; slaveWorld.tarinai.push(child); h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: child }); parent = child; }
|
||||||
h.api.recordNaturalZunchiSlave({ world: slaveWorld, tarinai: { generation: 3 } });
|
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "direct slave lineage did not unlock");
|
||||||
assert(unlocked(h, "natural_zunchi_slave_5_generations"), "slave five-generation run did not unlock");
|
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
const kingWorld = {};
|
const kingWorld = { tarinai: [] };
|
||||||
[4, 5, 6].forEach(generation => h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: { generation } }));
|
let kingParent = { id: "k1", parents: [], generation: 4 }; kingWorld.tarinai.push(kingParent); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: kingParent });
|
||||||
assert(unlocked(h, "natural_tarinai_king_3_generations"), "king three-generation run did not unlock");
|
for (let i = 2; i <= 3; i += 1) { const child = { id: `k${i}`, parents: [kingParent.id], generation: 3 + i }; kingWorld.tarinai.push(child); h.api.recordNaturalTarinaiKing({ world: kingWorld, tarinai: child }); kingParent = child; }
|
||||||
|
assert(unlocked(h, "natural_tarinai_king_3_generations"), "direct king lineage did not unlock");
|
||||||
|
|
||||||
|
h = createHarness();
|
||||||
|
const unrelatedWorld = { tarinai: [] };
|
||||||
|
for (let i = 1; i <= 5; i += 1) { const t = { id: `u${i}`, parents: [], generation: i }; unrelatedWorld.tarinai.push(t); h.api.recordNaturalZunchiSlave({ world: unrelatedWorld, tarinai: t }); }
|
||||||
|
assert(!unlocked(h, "natural_zunchi_slave_5_generations"), "unrelated generations incorrectly counted as a lineage");
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
|
const feedWorldA = { time: 0, achievementDirectFeedCount: 0 };
|
||||||
|
|
@ -390,7 +419,7 @@ const expectedIds = [
|
||||||
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
|
assert(unlocked(h, "true_tarinai_observer"), "completionist did not unlock with every other achievement");
|
||||||
|
|
||||||
h = createHarness();
|
h = createHarness();
|
||||||
const idleWorld = { time: 300, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
const idleWorld = { time: 360, tarinai: [], ants: [], achievementLastInterventionAt: 0, achievementNoDeathStartAt: -1 };
|
||||||
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
|
h.api.evaluateWorld(idleWorld, { id: "relaxed" });
|
||||||
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
|
assert(unlocked(h, "idle_observer_5_minutes"), "idle observer did not unlock");
|
||||||
|
|
||||||
|
|
@ -444,12 +473,12 @@ const expectedIds = [
|
||||||
|
|
||||||
storage = new Map();
|
storage = new Map();
|
||||||
h = createHarness(storage);
|
h = createHarness(storage);
|
||||||
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {} });
|
for (let i = 0; i < 65; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
|
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked before 100");
|
||||||
h = createHarness(storage);
|
h = createHarness(storage);
|
||||||
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {} });
|
for (let i = 0; i < 34; i += 1) h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||||
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
|
assert(!unlocked(h, "ants_killed_100"), "ant extermination unlocked at 99");
|
||||||
h.api.recordAntKilled({ world: {} });
|
h.api.recordAntKilled({ world: {}, playerCaused: true });
|
||||||
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
assert(unlocked(h, "ants_killed_100"), "ant extermination did not persist to 100");
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -668,7 +697,17 @@ const expectedIds = [
|
||||||
h.api.evaluateCleanFreak(cleanWorld);
|
h.api.evaluateCleanFreak(cleanWorld);
|
||||||
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
assert(unlocked(h, "clean_freak_robot_only"), "clean freak did not unlock with all settings and robot-only field");
|
||||||
|
|
||||||
console.log("[OK] all 64 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
|
||||||
|
h = createHarness();
|
||||||
|
const livelyWorldA = { time: 0, tarinai: [] };
|
||||||
|
for (let i = 0; i < 60; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldA });
|
||||||
|
h.api.resetWorldProgress(livelyWorldA);
|
||||||
|
const livelyWorldB = { time: 0, tarinai: [] };
|
||||||
|
for (let i = 0; i < 39; i += 1) h.api.recordManualTarinaiAdded({ world: livelyWorldB });
|
||||||
|
assert(!unlocked(h, "lively_making"), "lively making unlocked before 100 additions across resets");
|
||||||
|
h.api.recordManualTarinaiAdded({ world: livelyWorldB });
|
||||||
|
assert(unlocked(h, "lively_making"), "lively making did not persist across field reset");
|
||||||
|
console.log("[OK] all 75 achievement definitions, categorized UI, inline disclosures, and recovery synchronization passed");
|
||||||
})().catch(error => {
|
})().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ css = read('css/components.css')
|
||||||
|
|
||||||
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
|
ids_js = re.findall(r'id: "([a-z0-9_]+)"', ach.split('const DEFINITIONS',1)[1].split(']);',1)[0])
|
||||||
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
|
ids_php = re.findall(r"'([a-z0-9_]+)'", api.split('const ACHIEVEMENT_IDS',1)[1].split('];',1)[0])
|
||||||
require(len(ids_js) == 64, 'JS achievement count is not 64')
|
require(len(ids_js) == 75, 'JS achievement count is not 75')
|
||||||
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
|
require(ids_js == ids_php, 'JS/PHP achievement IDs are not synchronized')
|
||||||
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
|
require('signal_automation_first' not in ids_js and 'link_craftsman' not in ids_js, 'retired achievements remain defined')
|
||||||
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
|
require('mechanized_industry' in ids_js, 'mechanized industry is missing')
|
||||||
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
|
require('placed_objects_100' in ids_js and 'ants_killed_100' in ids_js, '100-count achievements are missing')
|
||||||
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
|
require('great_mother_1000_births' in ids_js and 'fight_pair_danger_kill' in ids_js, 'previous new achievements are missing')
|
||||||
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
|
require(all(item in ids_js for item in ['secret_collection_9_slots','pause_spam_4_in_1_second','continuous_play_1_hour','below_absolute_zero_item']), 'previous achievement IDs are missing')
|
||||||
require(all(item in ids_js for item in ['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']), 'latest achievement IDs are missing')
|
require(all(item in ids_js for item in ['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','well_informed','lively_making','memento_mori']), 'latest achievement IDs are missing')
|
||||||
require(all(item in ids_js for item in ['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']), 'playstyle achievement IDs are missing')
|
require(all(item in ids_js for item in ['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']), 'playstyle achievement IDs are missing')
|
||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
|
|
@ -72,7 +72,7 @@ checks = {
|
||||||
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
|
'low fps heartbeat': 'recordLowFps' in ach and '__tarinaiFps' in ach,
|
||||||
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
|
'toilet-sand field count': 'evaluateSandbox' in ach and 'item.type === "toilet"' in ach,
|
||||||
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
|
'ground-change hooks': 'recordGroundChange' in ach and 'recordGroundChange?.' in read('js/world_view.js'),
|
||||||
'idle observer timer': 'now - lastInterventionAt >= 300' in ach,
|
'idle observer timer': 'observerTarget' in ach and 'OBSERVER_GAME_DAYS = 3' in ach,
|
||||||
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
|
'server legacy AND migration': "unset($unlocks['signal_automation_first'], $unlocks['link_craftsman'])" in api and "mechanized_industry" in api,
|
||||||
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
|
'automatic aggregate recovery sync': 'localUnlockSnapshot' in ach and 'action: "sync"' in ach and "['session', 'unlock', 'sync', 'reset', 'summary']" in api and "$action === 'sync'" in api,
|
||||||
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
|
'save-slot collection hook': 'recordSaveSlotsFilled' in ach and 'recordSaveSlotsFilled?.' in read('js/save_storage.js'),
|
||||||
|
|
@ -81,7 +81,7 @@ checks = {
|
||||||
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
|
'continuous play survives tab switches': 'if (document.hidden) continuousPlayStartedAt' not in ach and 'continuousPlayStartedAt = Date.now();\n continuousPlayLastHeartbeatAt' not in ach.split('document.addEventListener("visibilitychange"',1)[-1],
|
||||||
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
|
'ant observation evaluator': 'evaluateAntNestWithoutTarinai' in ach and 'item.type === "ant_nest"' in ach,
|
||||||
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
|
'sticky bomb relay hook and persistence': 'recordStickyBombPass' in ach and 'recordStickyBombPass' in read('js/item_dynamic_tool_system.js') and 'stickyBombPassCount' in read('js/snapshot_system.js') and '[0, -1, 0], 3' in read('js/save_codec.js'),
|
||||||
'current-only achievement spell': 'const SPELL_STATE_VERSION = 5;' in ach and 'Number(payload[0]) !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js'),
|
'current-only achievement and save format': 'const SPELL_STATE_VERSION = 8;' in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach and 'version !== SPELL_STATE_VERSION' in ach and 'schema !== BINARY_SCHEMA_VERSION' in read('js/save_codec.js') and 'schema !== 52' not in read('js/save_codec.js'),
|
||||||
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
|
'progress text weight is stable': 'font-weight: 500;' in read('css/components.css').split('.achievement-entry-progress',1)[1].split('}',1)[0],
|
||||||
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
|
'sauna description matches evaluator': r'15\u79d2\u4ee5\u5185\u306b\u5bd2\u3059\u304e\u308b' in ach,
|
||||||
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' in ach,
|
'enemy streak resets on dangerous damage': 'world.achievementEnemyAntKills = 0;' in ach,
|
||||||
|
|
@ -107,7 +107,7 @@ checks = {
|
||||||
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
|
'managed paradise starts day six': 'day >= 6' in ach and 'now - happyStart >= dayLength' in ach,
|
||||||
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
|
'compact server state v3': 'function compact_state' in api and 'return [STATE_SCHEMA_VERSION, $baseTime, $versions, $players, $unlocks];' in api and 'compact_player_id' in api,
|
||||||
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
|
'spell omits completed counters': 'state.unlocked.sniper_333_shots ? 0' in ach and 'state.unlocked.fertility_seeker_721_love_births ? 0' in ach and 'state.unlocked.chaos_seeker_666_fights ? 0' in ach,
|
||||||
'old generic fight progress is not migrated': 'progressSource.fightCount' not in ach and 'progressVersion === SPELL_STATE_VERSION' in ach,
|
'obsolete spell versions are rejected': 'const SPELL_STATE_VERSION = 8;' in ach and 'LEGACY_SPELL_STATE_VERSION' not in ach and 'PREVIOUS_SPELL_STATE_VERSION' not in ach,
|
||||||
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
|
'stale completionist server record is revocable': 'completionistEligible' in ach and "unset($state['unlocks']['true_tarinai_observer'][$playerId])" in api,
|
||||||
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
|
'server corruption is non-destructive': 'JSON_THROW_ON_ERROR' in api and "throw new RuntimeException('storage_corrupt')" in api and 'atomic_write_state' in api,
|
||||||
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
|
'server writes use a separate lock': "LOCK_FILE_NAME = 'state.lock'" in api and 'flock($lockHandle, LOCK_EX)' in api and '@rename($tempPath, $dataPath)' in api,
|
||||||
|
|
@ -122,7 +122,6 @@ social = read('js/world_family_social.js') + read('js/tarinai_social_action_runt
|
||||||
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
|
require('!!a.isZunchiSlave !== !!b.isZunchiSlave' in social or '!!t.isZunchiSlave === !!other.isZunchiSlave' in social, 'slave mating rule not found')
|
||||||
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
|
require('recordNaturalGeneration(world, tarinai, "achievementNaturalSlaveGenerations", 5)' in ach, 'slave achievement still uses impossible direct-line logic')
|
||||||
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
|
require('\\u52dd\\u738730%\\u4ee5\\u4e0b' in read('js/world_combat_effects.js'), 'slave fight-record text does not state the 30% threshold')
|
||||||
require('legacyLinkTypes' in ach, 'legacy link-craftsman partial migration is missing')
|
|
||||||
|
|
||||||
print('[OK] achievement integration audit passed')
|
print('[OK] achievement integration audit passed')
|
||||||
for name in checks:
|
for name in checks:
|
||||||
|
|
|
||||||
47
scripts/achievement_save_v6_audit.js
Normal file
47
scripts/achievement_save_v6_audit.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
global.window = global;
|
||||||
|
global.TarinaiSnapshot = { version: 40 };
|
||||||
|
global.TarinaiSaveSchema = {
|
||||||
|
BINARY_SCHEMA_VERSION: 53,
|
||||||
|
FIELD_IDS: ["garden"],
|
||||||
|
itemTypeValue(_index, fallback = "") { return fallback; },
|
||||||
|
};
|
||||||
|
|
||||||
|
require("../js/save_codec.js");
|
||||||
|
|
||||||
|
// first_birth + statistician + memento_mori. v8 also stores persistent lively-making progress.
|
||||||
|
// Achievements 65-75 remain in the extended mask value at spell[3].
|
||||||
|
const progress = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 9999, 99];
|
||||||
|
const spell = [8, 1, 0, 1152, 100, [0, 1, 2], progress];
|
||||||
|
const snapshot = {
|
||||||
|
v: 40,
|
||||||
|
a: "tj1",
|
||||||
|
m: [1, 0, "garden", 0],
|
||||||
|
w: [0, 0, 0, 0, 0, -9990, 1, 0, "w0000000000000000", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
t: [],
|
||||||
|
i: [],
|
||||||
|
g: {
|
||||||
|
w: [[], [], 0, 0, 0, null, -1, null, 0, -1, -1],
|
||||||
|
t: [],
|
||||||
|
i: [],
|
||||||
|
s: spell,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytes = global.TarinaiSaveCodec.encodeBinarySnapshot(snapshot);
|
||||||
|
const decoded = global.TarinaiSaveCodec.decodeBinarySnapshot(bytes);
|
||||||
|
const restored = decoded?.g?.s;
|
||||||
|
if (!Array.isArray(restored) || restored[0] !== 8) throw new Error("achievement spell v8 was not restored");
|
||||||
|
if (restored[3] !== 1152) throw new Error("extended achievement mask bytes were lost");
|
||||||
|
if (restored[6]?.[14] !== 29) throw new Error("statistician progress was lost");
|
||||||
|
if (restored[6]?.[15] !== 9999) throw new Error("memento mori progress was lost");
|
||||||
|
if (restored[6]?.[16] !== 99) throw new Error("lively making progress was lost");
|
||||||
|
|
||||||
|
const oldSchemaBytes = Uint8Array.from(bytes);
|
||||||
|
oldSchemaBytes[0] = (oldSchemaBytes[0] & 0x80) | 52;
|
||||||
|
let rejectedOldSchema = false;
|
||||||
|
try { global.TarinaiSaveCodec.decodeBinarySnapshot(oldSchemaBytes); } catch (_) { rejectedOldSchema = true; }
|
||||||
|
if (!rejectedOldSchema) throw new Error("obsolete binary schema was accepted");
|
||||||
|
|
||||||
|
console.log("[OK] current-only schema 53, achievement v8 mask, and persistent progress binary round-trip passed");
|
||||||
|
|
@ -28,8 +28,8 @@ def achievement_ids() -> list[str]:
|
||||||
if not match:
|
if not match:
|
||||||
raise AssertionError("ACHIEVEMENT_IDS was not found")
|
raise AssertionError("ACHIEVEMENT_IDS was not found")
|
||||||
values = re.findall(r"'([^']+)'", match.group(1))
|
values = re.findall(r"'([^']+)'", match.group(1))
|
||||||
if len(values) != 64 or len(values) != len(set(values)):
|
if len(values) != 75 or len(values) != len(set(values)):
|
||||||
raise AssertionError(f"expected 64 unique achievement IDs, got {len(values)}")
|
raise AssertionError(f"expected 75 unique achievement IDs, got {len(values)}")
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -282,7 +282,7 @@ def run_concurrency_case(server: PhpServer, state_path: Path) -> None:
|
||||||
rng = random.Random(52_001)
|
rng = random.Random(52_001)
|
||||||
requests: list[tuple[str, dict[str, int]]] = []
|
requests: list[tuple[str, dict[str, int]]] = []
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
for index in range(72):
|
for index in range(75):
|
||||||
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
|
player_id = str(uuid.UUID(int=rng.getrandbits(128), version=4))
|
||||||
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
|
selected = rng.sample(ACHIEVEMENTS, rng.randint(1, 18))
|
||||||
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
|
unlocks = {achievement_id: now_ms - rng.randint(0, 1_000_000) for achievement_id in selected}
|
||||||
|
|
@ -332,7 +332,7 @@ def main() -> None:
|
||||||
server.close()
|
server.close()
|
||||||
print(
|
print(
|
||||||
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
|
"[OK] 32 randomized v2/verbose migrations, exact semantic round-trips, corruption preservation, "
|
||||||
f"completionist filtering, GET mutation rejection, and 72 concurrent syncs passed; "
|
f"completionist filtering, GET mutation rejection, and 75 concurrent syncs passed; "
|
||||||
f"aggregate JSON shrank {original}→{compact} bytes ({compact / original:.1%})"
|
f"aggregate JSON shrank {original}→{compact} bytes ({compact / original:.1%})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
44
scripts/colony_crisis_regression_audit.js
Normal file
44
scripts/colony_crisis_regression_audit.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
global.window = global;
|
||||||
|
global.CONFIG = { dayLength: 120 };
|
||||||
|
global.TarinaiAchievements = { evaluateWorld() {} };
|
||||||
|
require("../js/colony_situation_system.js");
|
||||||
|
|
||||||
|
function actor({ energy = 80, hunger = 20, health = 0 } = {}) {
|
||||||
|
return { dead: false, energy, hunger, stress: 10, needs: { health, safety: 0 }, state: "idle" };
|
||||||
|
}
|
||||||
|
function world(actors, deaths = 0, previousPopulation = null) {
|
||||||
|
return {
|
||||||
|
time: 100,
|
||||||
|
tarinai: actors,
|
||||||
|
recentDeathTimes: Array.from({ length: deaths }, (_, i) => 99 - i * 0.1),
|
||||||
|
lastColonyMoodPopulation: previousPopulation == null ? actors.length : previousPopulation,
|
||||||
|
colonyMoodDefinition(id) { return { id, label: id, effects: { personality: {} } }; },
|
||||||
|
config: { dayLength: 120 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function evaluate(w) { return global.TarinaiColonySituationSystem.evaluate(w, true).id; }
|
||||||
|
function assert(ok, message) { if (!ok) throw new Error(message); }
|
||||||
|
|
||||||
|
// Ten recent deaths alone at 100 population must no longer trigger crisis without systemic weakness.
|
||||||
|
let w = world(Array.from({ length: 100 }, () => actor()), 10);
|
||||||
|
assert(evaluate(w) !== "crisis", "recent deaths alone still trigger crisis too easily");
|
||||||
|
|
||||||
|
// Systemic weakness alone below collapse severity must not trigger crisis.
|
||||||
|
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 0);
|
||||||
|
assert(evaluate(w) !== "crisis", "moderate systemic weakness alone still triggers crisis");
|
||||||
|
|
||||||
|
// Mortality plus systemic weakness should trigger crisis.
|
||||||
|
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 50 ? { energy: 24, hunger: 90, health: 70 } : {})), 10);
|
||||||
|
assert(evaluate(w) === "crisis", "compound mortality/systemic stress does not trigger crisis");
|
||||||
|
|
||||||
|
// Extreme collapse can trigger even without recent deaths.
|
||||||
|
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 0);
|
||||||
|
assert(evaluate(w) === "crisis", "extreme colony collapse does not trigger crisis");
|
||||||
|
|
||||||
|
// Population growth still suppresses crisis.
|
||||||
|
w = world(Array.from({ length: 100 }, (_, i) => actor(i < 70 ? { energy: 10, hunger: 98, health: 80 } : {})), 20, 90);
|
||||||
|
assert(evaluate(w) !== "crisis", "crisis ignored population-growth exclusion");
|
||||||
|
|
||||||
|
console.log("[OK] stricter compound crisis classification passed");
|
||||||
15
scripts/effect_runtime_regression_audit.js
Normal file
15
scripts/effect_runtime_regression_audit.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
|
||||||
|
const sim = fs.readFileSync("js/simulation_effects_system.js", "utf8");
|
||||||
|
const combat = fs.readFileSync("js/world_combat_effects.js", "utf8");
|
||||||
|
const all = fs.readdirSync("js").filter(x => x.endsWith(".js")).map(x => fs.readFileSync(`js/${x}`, "utf8")).join("\n");
|
||||||
|
ok(!/\.map\([\s\S]{0,300}\.sort\(/.test(sim), "effect pressure compaction no longer map/sorts all effects");
|
||||||
|
ok(sim.includes("for (let priority = 1; priority <= 3"), "effect pressure drops by priority tiers");
|
||||||
|
ok(combat.includes('new Set(["ring", "fight", "flame", "fall"])'), "effect pool is limited to simple high-frequency types");
|
||||||
|
ok(combat.includes("offscreenCulled"), "low-importance offscreen spawns are tracked and culled");
|
||||||
|
ok(sim.includes('if (effect.type === "ring")') && sim.includes("lightweight"), "rings bypass the budgeted physics update path");
|
||||||
|
ok(sim.includes("spawnRequested") && sim.includes("poolEligible") && sim.includes("pooled"), "effect runtime diagnostics expose spawn and pool counters");
|
||||||
|
const direct = [...all.matchAll(/effects\.push\(new Effect\(/g)].length;
|
||||||
|
ok(direct === 0, "normal Effect construction is routed through spawnEffect");
|
||||||
|
console.log("[OK] effect runtime regression audit passed");
|
||||||
46
scripts/history_binary_regression_audit.js
Normal file
46
scripts/history_binary_regression_audit.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
global.window = global;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
global.TarinaiSaveCodec = {
|
||||||
|
encodeBinarySnapshot(snapshot) {
|
||||||
|
return encoder.encode(JSON.stringify(snapshot));
|
||||||
|
},
|
||||||
|
decodeBinarySnapshot(bytes) {
|
||||||
|
return JSON.parse(decoder.decode(bytes));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
global.TarinaiSnapshot = {
|
||||||
|
createSnapshot(world) {
|
||||||
|
return { v: 1, a: "tj1", value: world.value, time: world.time };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
global.TarinaiRestoreCoordinator = {
|
||||||
|
restoreSnapshot(snapshot, world) {
|
||||||
|
world.value = snapshot.value;
|
||||||
|
world.time = snapshot.time;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
require(path.join(__dirname, "..", "js", "history_system.js"));
|
||||||
|
|
||||||
|
const world = { value: 1, time: 10 };
|
||||||
|
if (!global.TarinaiHistory.capture(world, "first")) throw new Error("initial history capture failed");
|
||||||
|
if (!(world._undoStack[0].bytes instanceof Uint8Array)) throw new Error("history entry is not binary");
|
||||||
|
if ("snapshot" in world._undoStack[0] || "patch" in world._undoStack[0]) throw new Error("legacy object history payload remains");
|
||||||
|
if (global.TarinaiHistory.capture(world, "duplicate")) throw new Error("duplicate snapshot was not suppressed");
|
||||||
|
|
||||||
|
world.value = 2;
|
||||||
|
world.time = 20;
|
||||||
|
if (!global.TarinaiHistory.capture(world, "second")) throw new Error("second history capture failed");
|
||||||
|
world.value = 3;
|
||||||
|
world.time = 30;
|
||||||
|
let result = global.TarinaiHistory.undo(world);
|
||||||
|
if (!result.ok || world.value !== 2 || world.time !== 20) throw new Error("undo did not restore the binary snapshot");
|
||||||
|
result = global.TarinaiHistory.redo(world);
|
||||||
|
if (!result.ok || world.value !== 3 || world.time !== 30) throw new Error("redo did not restore the captured current state");
|
||||||
|
|
||||||
|
console.log("[OK] binary undo/redo history, duplicate suppression, and synchronous restore passed");
|
||||||
21
scripts/non_tarinai_performance_regression_audit.js
Normal file
21
scripts/non_tarinai_performance_regression_audit.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const viewSrc = fs.readFileSync("js/world_view.js", "utf8");
|
||||||
|
const maintenanceSrc = fs.readFileSync("js/simulation_maintenance_system.js", "utf8");
|
||||||
|
const spatialSrc = fs.readFileSync("js/world_spatial_budget.js", "utf8");
|
||||||
|
const mainSrc = fs.readFileSync("js/main.js", "utf8");
|
||||||
|
const statsSrc = fs.readFileSync("js/ui_charts.js", "utf8");
|
||||||
|
const decaySrc = fs.readFileSync("js/item_lifecycle_decay_system.js", "utf8");
|
||||||
|
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
|
||||||
|
ok(!viewSrc.includes('terrain-periodic'), "terrain cache is not globally invalidated on a timer");
|
||||||
|
ok(viewSrc.includes('return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}`'), "terrain signature excludes object-count churn");
|
||||||
|
ok(maintenanceSrc.includes('_nextStructureDependencyCheckAt'), "structure dependency safety sweeps are throttled");
|
||||||
|
ok(maintenanceSrc.includes('_nextItemCompactAt') && maintenanceSrc.includes('_nextAntCompactAt'), "item and ant compactions are staggered");
|
||||||
|
ok(!maintenanceSrc.includes('countsInterval = 2.75'), "item bucket rebuilds are no longer forced by a periodic timer");
|
||||||
|
ok(spatialSrc.includes('if (terrainChanged) this.markTerrainDirty?.("compact-terrain-items")'), "item compaction only invalidates terrain when cached terrain items were removed");
|
||||||
|
ok(!spatialSrc.includes('else this.markTerrainDirty?.(reason);'), "adding ordinary objects no longer invalidates the whole terrain cache");
|
||||||
|
ok(mainSrc.includes('requestIdleCallback') && mainSrc.includes('scheduleStatsRefresh'), "periodic stats refresh is deferred outside the simulation frame");
|
||||||
|
ok(statsSrc.includes('world.tarinaiCounts?.()') && statsSrc.includes('collectDynamicColonyStats'), "stats use differential population counters and isolate dynamic aggregation");
|
||||||
|
ok(statsSrc.includes('colonyStatsUiVisible') && mainSrc.includes('!colonyStatsUiVisible()'), "hidden colony UI skips scheduled stats work");
|
||||||
|
ok(decaySrc.includes('"splat-fade"'), "splat fading invalidates only its local terrain chunk");
|
||||||
|
if (process.exitCode) process.exit(process.exitCode);
|
||||||
58
scripts/pathfinding_regression_audit.js
Normal file
58
scripts/pathfinding_regression_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
global.window = global;
|
||||||
|
global.CONFIG = { worldPadding: 28 };
|
||||||
|
global.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||||
|
class World {}
|
||||||
|
global.World = World;
|
||||||
|
require(path.join(__dirname, "..", "js", "world_pathfinding_system.js"));
|
||||||
|
|
||||||
|
function makeWorld({ directBlocked = false } = {}) {
|
||||||
|
const w = new World();
|
||||||
|
w.w = 1400;
|
||||||
|
w.h = 1000;
|
||||||
|
w.time = 10;
|
||||||
|
w.routingObstacleVersion = 3;
|
||||||
|
w.pointChecks = 0;
|
||||||
|
w.edgeChecks = 0;
|
||||||
|
w.pointBlockedByObstacle = () => { w.pointChecks++; return false; };
|
||||||
|
w.pathBlockedByFence = () => { w.edgeChecks++; return directBlocked; };
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waypointFor(id) {
|
||||||
|
const w = makeWorld();
|
||||||
|
const actor = { id, x: 120, y: 120, radius: 20 };
|
||||||
|
const target = { id: "goal", x: 1120, y: 780 };
|
||||||
|
const first = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||||
|
if (!first) throw new Error(`no path for ${id}`);
|
||||||
|
const pointAfterFirst = w.pointChecks;
|
||||||
|
const edgeAfterFirst = w.edgeChecks;
|
||||||
|
const again = w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||||
|
if (!again || first.x !== again.x || first.y !== again.y) throw new Error("same actor route cache is not stable");
|
||||||
|
if (w.pointChecks !== pointAfterFirst || w.edgeChecks !== edgeAfterFirst) throw new Error("throttled actor cache still performs geometry checks every call");
|
||||||
|
if (actor._routeCache?.spatialVersion !== 3 || actor._routeCache?.mode !== "grid") throw new Error("unified route cache was not stored");
|
||||||
|
if (actor._gridPathWaypoint || actor._pathWaypoint) throw new Error("legacy route caches still exist");
|
||||||
|
w.routingObstacleVersion = 4;
|
||||||
|
w.findGridPathWaypoint(actor, target, { gridStep: 58, state: "seek_food" });
|
||||||
|
if (actor._routeCache?.spatialVersion !== 4) throw new Error("cache did not invalidate after obstacle version change");
|
||||||
|
if (w.pointChecks > 18 * 18 * 3) throw new Error(`excessive point checks: ${w.pointChecks}`);
|
||||||
|
if (w.edgeChecks > 18 * 18 * 8 * 3) throw new Error(`excessive edge checks: ${w.edgeChecks}`);
|
||||||
|
return `${first.x.toFixed(3)},${first.y.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = new Set(Array.from({ length: 64 }, (_, i) => waypointFor(`tarinai-${i}`)));
|
||||||
|
if (routes.size < 2) throw new Error("ID-fixed bias did not diversify independently computed routes");
|
||||||
|
|
||||||
|
const directWorld = makeWorld();
|
||||||
|
const directTarget = { id: "direct-goal", x: 600, y: 400 };
|
||||||
|
const directActor = { id: "direct-a", x: 100, y: 100, radius: 20 };
|
||||||
|
directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
|
||||||
|
const directChecks = directWorld.edgeChecks;
|
||||||
|
for (let i = 0; i < 100; i++) directWorld.findTarinaiPathWaypoint(directActor, directTarget, { state: "seek_food" });
|
||||||
|
if (directWorld.edgeChecks !== directChecks) throw new Error("direct-route throttle did not suppress repeated fence checks");
|
||||||
|
|
||||||
|
const w = makeWorld();
|
||||||
|
w.markSpatialDirty = World.prototype.markSpatialDirty;
|
||||||
|
console.log(`[OK] throttled actor-local route cache, obstacle invalidation, stable ID bias, and route diversity passed (${routes.size} first-waypoint variants)`);
|
||||||
58
scripts/performance_5to7_regression_audit.js
Normal file
58
scripts/performance_5to7_regression_audit.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||||
|
|
||||||
|
const creatureSource = read("js/simulation_creature_system.js");
|
||||||
|
assert(creatureSource.includes("_creatureScheduledWorkCursor"), "rotating scheduled-work cursor is missing");
|
||||||
|
assert(creatureSource.includes("creatureCount >= 80"), "high-population guard for rotating AI work is missing");
|
||||||
|
assert(creatureSource.includes("perfProfile.aiBudget"), "AI budget is not used as the low-overhead rotation step");
|
||||||
|
assert(!creatureSource.includes("sortScheduledAi"), "AI spreading introduced an unnecessary sort queue");
|
||||||
|
console.log("[OK] high-cost creature work uses a rotating, queue-free budget order");
|
||||||
|
|
||||||
|
const renderSource = read("js/render.js");
|
||||||
|
assert(renderSource.includes("creatureSortRect = visibleWorldRect(world, 72)"), "narrow creature depth-sort rectangle is missing");
|
||||||
|
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.tarinaiCells, creatureSortRect"), "Tarinai sort candidates are not limited before sorting");
|
||||||
|
assert(renderSource.includes("nearbyRectInto(worldRef.spatial.antCells, creatureSortRect"), "ant sort candidates are not limited before sorting");
|
||||||
|
assert(renderSource.includes("collectVisibleRenderStack(world, visibleRect, creatureSortRect)"), "render stack does not receive the narrowed creature sort rectangle");
|
||||||
|
console.log("[OK] creature depth sorting is limited to near-viewport candidates before list insertion");
|
||||||
|
|
||||||
|
const relationSource = read("js/tarinai_identity_social.js");
|
||||||
|
assert(relationSource.includes("lazyDropDeadRelationPeer"), "lazy dead relationship deletion helper is missing");
|
||||||
|
assert(relationSource.includes("pruneDeadRelationshipRefs(deadIds)"), "dead-ID batch relationship cleanup is missing");
|
||||||
|
assert(!/entry\.ownCount\s*=\s*Math\.max\(entry\.ownCount,\s*Object\.keys\(this\.relationships\)\.length\)/.test(relationSource), "relation insertion still enumerates all relationship keys");
|
||||||
|
console.log("[OK] dead relationship references are lazily removed without per-insert full key enumeration");
|
||||||
|
|
||||||
|
class World {}
|
||||||
|
global.World = World;
|
||||||
|
global.TarinaiHistory = { trimMemory() { return 0; } };
|
||||||
|
require(path.join(ROOT, "js/world_spatial_budget.js"));
|
||||||
|
|
||||||
|
const world = new World();
|
||||||
|
world.time = 100;
|
||||||
|
const aliveA = { id: "A", dead: false, relationships: { D1: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
|
||||||
|
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 2 }, X: { affinity: 9 } }, relationCache: { stale: true } };
|
||||||
|
world.tarinai = [aliveA, aliveC];
|
||||||
|
world.liveTarinai = new Map([["A", { target: aliveA }], ["C", { target: aliveC }]]);
|
||||||
|
world._deadTarinaiIdsPendingCleanup = new Set(["D1"]);
|
||||||
|
world.tarinaiCollisionMemo = new Map();
|
||||||
|
world.relationNotices = {};
|
||||||
|
world.fightPairCooldowns = {};
|
||||||
|
world.resolvedFightIds = {};
|
||||||
|
world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true });
|
||||||
|
assert(world._tarinaiRuntimePruneJob, "threshold cleanup job was not queued");
|
||||||
|
world.processTarinaiRuntimeCachePruneStep(1);
|
||||||
|
assert(world._tarinaiRuntimePruneJob, "threshold cleanup was not split across frames");
|
||||||
|
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(1);
|
||||||
|
assert(!Object.prototype.hasOwnProperty.call(aliveA.relationships, "D1"), "known dead relationship was not removed");
|
||||||
|
assert(Object.prototype.hasOwnProperty.call(aliveA.relationships, "C"), "live relationship was incorrectly removed");
|
||||||
|
assert(Object.prototype.hasOwnProperty.call(aliveC.relationships, "X"), "unrelated stale reference was scanned/removed by targeted cleanup");
|
||||||
|
assert.strictEqual(world._deadTarinaiIdsPendingCleanup.size, 0, "processed dead-ID set was not released after chunked cleanup");
|
||||||
|
console.log("[OK] threshold cleanup is split across frames and removes only queued dead IDs");
|
||||||
|
|
||||||
|
const deathSource = read("js/tarinai_social_move_life.js");
|
||||||
|
assert(deathSource.includes("noteTarinaiDeathForRuntimeCleanup?.(this.id)"), "death hook does not queue the dead Tarinai ID");
|
||||||
|
console.log("Performance 5-7 regression audit passed.");
|
||||||
28
scripts/performance_architecture_regression_audit.js
Normal file
28
scripts/performance_architecture_regression_audit.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const read = rel => fs.readFileSync(path.join(root, rel), "utf8");
|
||||||
|
const ok = (value, message) => { if (!value) throw new Error(message); console.log(`[OK] ${message}`); };
|
||||||
|
|
||||||
|
const budget = read("js/world_spatial_budget.js");
|
||||||
|
const family = read("js/world_family_social.js");
|
||||||
|
const life = read("js/tarinai_social_move_life.js");
|
||||||
|
const achievements = read("js/achievements.js");
|
||||||
|
const charts = read("js/ui_charts.js");
|
||||||
|
const main = read("js/main.js");
|
||||||
|
const render = read("js/render.js");
|
||||||
|
|
||||||
|
ok(budget.includes("rebuildTarinaiCountCache") && budget.includes("syncTarinaiCountEntry") && budget.includes("tarinaiCounts()"), "population counters use mutation-maintained differential cache");
|
||||||
|
ok(family.includes("syncTarinaiCountEntry?.(t)") && life.includes("syncTarinaiCountEntry?.(this)"), "birth/add and death paths update differential population counters");
|
||||||
|
ok(budget.includes('noteItemInactive(item, reason = "item-inactive", notifyAchievements = true)') && budget.includes('noteItemInactive?.(it, "compact-items", false)'), "bulk item compaction suppresses per-item achievement checks");
|
||||||
|
ok(budget.includes('evaluateEvent?.(this, "items", { delta: 0, reason: "compact-items" })'), "bulk item compaction emits one aggregate achievement event");
|
||||||
|
ok(achievements.includes("function evaluateEvent(worldRef, trigger") && achievements.includes('evaluateEvent(worldRef, "timer"'), "achievement evaluation has event-driven trigger entry point");
|
||||||
|
ok(!achievements.includes("const needsHabitatItemScan"), "playstyle habitat achievements no longer rescan every item on periodic evaluation");
|
||||||
|
ok(achievements.includes("worldRef?.itemCounts") && achievements.includes("playstyleItemCount"), "item-dependent achievements use differential item counts in normal runtime");
|
||||||
|
ok(charts.includes("function colonyStatsUiVisible()") && main.includes("!colonyStatsUiVisible()"), "hidden colony UI prevents scheduled stats aggregation");
|
||||||
|
ok(charts.includes("world.tarinaiCounts?.()") && charts.includes("collectDynamicColonyStats"), "visible stats separate hot counters from dynamic full-population aggregation");
|
||||||
|
ok(render.includes("nearbyRectInto(worldRef.spatial.tarinaiCells") && render.includes("isPointVisibleInRect") && render.includes("!isEntityVisibleInRect(entity, visibleRect)"), "render stack pre-culls entities before list insertion");
|
||||||
|
ok(!render.includes("isEntityVisibleInRect({ ...it, x: pose.x, y: pose.y }"), "carried-item visibility culling avoids temporary object allocation");
|
||||||
|
|
||||||
|
console.log("[OK] performance architecture: differential counters, event triggers, hidden UI gating, and pre-render culling verified");
|
||||||
77
scripts/performance_cleanup_regression_audit.js
Normal file
77
scripts/performance_cleanup_regression_audit.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const vm = require("vm");
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||||
|
|
||||||
|
const render = read("js/render.js");
|
||||||
|
assert(!render.includes("insertionSortNearPrevious"), "custom insertion sort still exists");
|
||||||
|
assert(!render.includes("_renderPrevDynamicLayeredIds") && !render.includes("_renderBackSortMap"), "previous-order render bookkeeping still exists");
|
||||||
|
assert(render.includes("dynamicBack.sort(compareBackItems)") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "native render sort is not active");
|
||||||
|
console.log("[OK] previous-order Map and custom insertion sort are removed");
|
||||||
|
|
||||||
|
const pathfinding = read("js/world_pathfinding_system.js");
|
||||||
|
assert(!pathfinding.includes("_sharedRouteCache") && !pathfinding.includes("trySharedRoute") && !pathfinding.includes("storeSharedRoute"), "shared route cache still exists");
|
||||||
|
assert(pathfinding.includes("actor._routeCache"), "actor-local route cache was removed accidentally");
|
||||||
|
console.log("[OK] shared route cache is removed while actor-local throttling remains");
|
||||||
|
|
||||||
|
const profiler = read("js/perf_profiler.js");
|
||||||
|
const budget = read("js/world_spatial_budget.js");
|
||||||
|
assert(profiler.includes("diagnosticsEnabled"), "diagnostic-mode gate is missing");
|
||||||
|
assert(budget.includes("diagnostics ?") && budget.includes("this.workStats = diagnostics ?"), "production detailed diagnostic allocation is still unconditional");
|
||||||
|
assert(profiler.includes("transitionHoldSeconds") && profiler.includes("pendingProfileSeconds"), "performance-profile hysteresis hold timers are missing");
|
||||||
|
console.log("[OK] production diagnostics are gated and auto profile switching uses hysteresis");
|
||||||
|
|
||||||
|
const environment = read("js/world_environment.js");
|
||||||
|
assert(environment.includes("`${this.routingObstacleVersion || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}`"), "obstacle cache is not keyed by routingObstacleVersion only");
|
||||||
|
assert(!environment.includes("`${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx}"), "obstacle cache still invalidates on generic spatial movement");
|
||||||
|
console.log("[OK] obstacle cache invalidation is isolated from generic spatial movement");
|
||||||
|
|
||||||
|
const simCore = read("js/sim_core.js");
|
||||||
|
const start = simCore.indexOf("class SpatialGrid {");
|
||||||
|
const end = simCore.indexOf("\nfunction drawHeartShape", start);
|
||||||
|
assert(start >= 0 && end > start, "SpatialGrid source could not be isolated");
|
||||||
|
const context = { console, Map, Set, Math, Number, Array, globalThis: {} };
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(`${simCore.slice(start, end)}\nglobalThis.SpatialGrid = SpatialGrid;`, context);
|
||||||
|
const SpatialGrid = context.globalThis.SpatialGrid;
|
||||||
|
const grid = new SpatialGrid(100);
|
||||||
|
const a = { id: "a", x: 10, y: 10, dead: false };
|
||||||
|
const b = { id: "b", x: 40, y: 40, dead: false };
|
||||||
|
grid.rebuildTarinai([a, b]);
|
||||||
|
const firstKey = a._spatialTarinaiCellKey;
|
||||||
|
a.x = 50;
|
||||||
|
assert.strictEqual(grid.syncTarinai([a, b]), 0, "same-cell movement should not rewrite the grid");
|
||||||
|
a.x = 150;
|
||||||
|
assert.strictEqual(grid.syncTarinai([a, b]), 1, "cell crossing should update exactly one entity");
|
||||||
|
assert.notStrictEqual(a._spatialTarinaiCellKey, firstKey, "cell key did not change after crossing");
|
||||||
|
b.dead = true;
|
||||||
|
assert(grid.syncTarinai([a, b]) >= 1, "dead entity was not removed incrementally");
|
||||||
|
assert(!grid._indexedTarinai.has(b), "dead entity remains in the Tarinai spatial index");
|
||||||
|
console.log("[OK] Tarinai spatial grid updates only cell crossings/removals instead of full rebuilds");
|
||||||
|
|
||||||
|
class World {}
|
||||||
|
global.World = World;
|
||||||
|
global.TarinaiHistory = { trimMemory() { return 0; } };
|
||||||
|
require(path.join(ROOT, "js", "world_spatial_budget.js"));
|
||||||
|
const world = new World();
|
||||||
|
world.time = 100;
|
||||||
|
world.tarinai = Array.from({ length: 120 }, (_, i) => ({ id: `T${i}`, dead: false, relationships: { D: { affinity: 1 } } }));
|
||||||
|
world.liveTarinai = new Map(world.tarinai.map(t => [t.id, { target: t }]));
|
||||||
|
world._deadTarinaiIdsPendingCleanup = new Set(["D"]);
|
||||||
|
world.tarinaiCollisionMemo = new Map();
|
||||||
|
world.relationNotices = {};
|
||||||
|
world.fightPairCooldowns = {};
|
||||||
|
world.resolvedFightIds = {};
|
||||||
|
assert(world.queueTarinaiRuntimeCachePrune("audit", { resetRelationPeerCaches: true }), "cleanup job was not queued");
|
||||||
|
world.processTarinaiRuntimeCachePruneStep(24);
|
||||||
|
assert(world._tarinaiRuntimePruneJob, "cleanup finished in one frame instead of being chunked");
|
||||||
|
assert.strictEqual(world._tarinaiRuntimePruneJob.cursor, 24, "cleanup chunk size was not respected");
|
||||||
|
while (world._tarinaiRuntimePruneJob) world.processTarinaiRuntimeCachePruneStep(24);
|
||||||
|
assert(world.tarinai.every(t => !Object.prototype.hasOwnProperty.call(t.relationships, "D")), "chunked cleanup left dead references behind");
|
||||||
|
console.log("[OK] death-reference cleanup runs in bounded per-frame chunks");
|
||||||
|
|
||||||
|
console.log("Performance cleanup regression audit passed.");
|
||||||
|
|
@ -35,7 +35,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
|
|
||||||
(function run(){
|
(function run(){
|
||||||
let h=harness();
|
let h=harness();
|
||||||
assert(h.api.definitions.length===64,"must have 64 achievements");
|
assert(h.api.definitions.length===75,"must have 75 achievements");
|
||||||
h.api.open();
|
h.api.open();
|
||||||
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
const title=Object.fromEntries(h.api.definitions.map(d=>[d.id,d.title]));
|
||||||
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
assert(title.medicine_ledger_all==="おくすり手帳全埋め","medicine title");
|
||||||
|
|
@ -45,6 +45,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
assert(description("mercury_lifespan").includes("水銀を使用したたりないが天寿を全うする。"),"mercury condition disclosed in UI");
|
||||||
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
assert(findCard(h,"enemy_enemy_friend").children[1].children.some(node=>node.textContent.includes("現在の達成率 0.0%")),"enemy progress shown in UI");
|
||||||
assert(description("overprotective")==="???","other new condition hidden");
|
assert(description("overprotective")==="???","other new condition hidden");
|
||||||
|
assert(description("elite_few").includes("25体以下の状態を5日間維持"),"elite few condition must be visible before unlock");
|
||||||
|
|
||||||
// One Undo only: multiple smaller Undo operations never aggregate.
|
// One Undo only: multiple smaller Undo operations never aggregate.
|
||||||
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
h.api.recordHistoryAction("undo",{deadRestored:9}); h.api.recordHistoryAction("undo",{deadRestored:1});
|
||||||
|
|
@ -83,7 +84,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
// Overprotective requires both thresholds on the same individual.
|
// Overprotective requires both thresholds on the same individual.
|
||||||
h=harness(); const cared={dead:false}; const cw={time:0};
|
h=harness(); const cared={dead:false}; const cw={time:0};
|
||||||
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
for(let i=0;i<9;i++)h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"});
|
||||||
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",treatment:true});
|
for(let i=0;i<3;i++)h.api.recordDirectCare({world:cw,tarinai:cared,type:"first_aid",beneficialRecovery:true});
|
||||||
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
assert(off(h,"overprotective"),"overprotective before 10 feeds"); h.api.recordDirectFeed({world:cw,tarinai:cared,type:"food"}); assert(on(h,"overprotective"),"overprotective thresholds");
|
||||||
|
|
||||||
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
// Self-sufficient: 20+, no direct feed, no duplicator, 10 game minutes.
|
||||||
|
|
@ -99,6 +100,7 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
assert(off(h,"unplanned_city_30"),"unplanned at 29");let item={};h.api.recordPlayerPlacement({world:qw,item});qw.time=30.001;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"late deletion counted");
|
||||||
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
item={_achievementPlayerPlaced:true,_achievementPlacedAt:null};qw.time=0;h.api.recordPlayerDeletion({world:qw,item});assert(off(h,"unplanned_city_30"),"missing placement timestamp counted");
|
||||||
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
item={};qw.time=0;h.api.recordPlayerPlacement({world:qw,item});qw.time=30;h.api.recordPlayerDeletion({world:qw,item});assert(on(h,"unplanned_city_30"),"unplanned at 30");
|
||||||
|
h=harness(); qw={time:0}; for(let i=0;i<30;i++){const x={};h.api.recordPlayerPlacement({world:qw,item:x});h.api.recordPlayerDeletion({world:qw,item:x,operationId:"same-area-op"});} assert(off(h,"unplanned_city_30"),"area deletion counted each removed item instead of one operation");
|
||||||
|
|
||||||
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
// Sauna -> cold within 15 seconds; cold alone must never unlock.
|
||||||
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
h=harness(); const st={dead:false,x:0,y:0}; let temp=0; const tw={time:0,tarinai:[st],ants:[],items:[],feltTemperatureFor(){return temp;},temperatureStatusFor(v){return {direction:v>25?"hot":v<10?"cold":"comfort",comfortable:v>=10&&v<=25};}};
|
||||||
|
|
@ -174,9 +176,29 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
|
h=harness();const antWorld={tarinai:[],ants:[],items:[{type:"ant_nest",dead:false}]};h.api.evaluateWorld(antWorld,{});assert(on(h,"ant_nest_without_tarinai"),"ant observation condition");
|
||||||
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
|
h=harness();const bomb={type:"sticky_bomb",dead:false};for(let i=0;i<14;i++)h.api.recordStickyBombPass(bomb,{});assert(off(h,"sticky_bomb_15_passes"),"sticky relay early");h.api.recordStickyBombPass(bomb,{});assert(on(h,"sticky_bomb_15_passes"),"sticky relay at 15");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// New playstyle-survey achievements.
|
||||||
|
h=harness(); let popWorld={time:0,day:1,tarinai:alive(99),ants:[],items:[]}; h.api.evaluateWorld(popWorld,{}); assert(off(h,"strength_in_numbers"),"strength in numbers unlocked before 100"); popWorld.tarinai.push({id:"t99",dead:false,x:99,y:0}); h.api.evaluateWorld(popWorld,{}); assert(on(h,"strength_in_numbers"),"strength in numbers did not unlock at 100");
|
||||||
|
|
||||||
|
h=harness(); let eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=599.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few unlocked before five days"); eliteWorld.time=600; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not unlock at five days");
|
||||||
|
h=harness(); eliteWorld={time:0,day:1,tarinai:alive(25),ants:[],items:[],config:{dayLength:120}}; h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=300; eliteWorld.tarinai.push({dead:false}); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.tarinai.pop(); h.api.evaluateWorld(eliteWorld,{}); eliteWorld.time=899.99; h.api.evaluateWorld(eliteWorld,{}); assert(off(h,"elite_few"),"elite few timer survived population overflow"); eliteWorld.time=900; h.api.evaluateWorld(eliteWorld,{}); assert(on(h,"elite_few"),"elite few did not restart after overflow");
|
||||||
|
|
||||||
|
h=harness(); let zWorld={time:0,tarinai:alive(20),ants:[],items:Array.from({length:20},()=>({type:"zunchi",dead:false}))}; h.api.evaluateWorld(zWorld,{}); assert(off(h,"zunchi_overflow"),"zunchi overflow unlocked at equal count"); zWorld.items.push({type:"zunchi",dead:false}); h.api.evaluateWorld(zWorld,{}); assert(on(h,"zunchi_overflow"),"zunchi overflow did not unlock above population");
|
||||||
|
|
||||||
|
h=harness(); let bedWorld={time:0,tarinai:alive(5),ants:[],items:[{type:"nest_box",dead:false}],nestBoxCapacity(){return 5;}}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"comfortable_beds"),"comfortable beds did not count nest capacity");
|
||||||
|
h=harness(); bedWorld={time:0,tarinai:alive(30),ants:[],items:[]}; h.api.evaluateWorld(bedWorld,{}); assert(on(h,"stone_pillow"),"stone pillow did not unlock at 30 with zero bed capacity");
|
||||||
|
|
||||||
|
h=harness(); const seasonWorld={time:2400,elapsedDays:20,tarinai:alive(1),ants:[],items:[]}; h.api.evaluateWorld(seasonWorld,{}); assert(on(h,"across_seasons"),"across seasons did not unlock after full cycle");
|
||||||
|
h=harness(); const fav={dead:false,favorite:true}; h.api.recordFavoriteTarinai({tarinai:fav}); assert(on(h,"favorite_one"),"favorite achievement did not unlock");
|
||||||
|
|
||||||
|
storage=new Map(); h=harness(storage); for(let i=0;i<15;i++) h.api.recordStatsButtonPress({control:"chart:population"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<14;i++) h.api.recordStatsButtonPress({control:"series:pop"}); assert(off(h,"statistician"),"statistician unlocked at 29 across reset"); h.api.recordStatsButtonPress({control:"chart:life"}); assert(on(h,"statistician"),"statistician did not persist across reset to 30");
|
||||||
|
|
||||||
|
storage=new Map(); h=harness(storage); for(let i=0;i<60;i++) h.api.recordManualTarinaiAdded({tool:"new"}); h.api.resetWorldProgress({}); h=harness(storage); for(let i=0;i<39;i++) h.api.recordManualTarinaiAdded({tool:"new"}); assert(off(h,"lively_making"),"lively making unlocked at 99 across reset"); h.api.recordManualTarinaiAdded({tool:"new"}); assert(on(h,"lively_making"),"lively making did not persist across reset to 100");
|
||||||
|
|
||||||
// Static integration guards for all production hook points.
|
// Static integration guards for all production hook points.
|
||||||
const checks={
|
const checks={
|
||||||
"js/command_dispatcher.js":["deadRestored","recordHistoryAction"],
|
"js/command_dispatcher.js":["deadRestored","recordHistoryAction","recordFavoriteTarinai"],
|
||||||
"js/robot_cleaner_system.js":["recordRobotClean"],
|
"js/robot_cleaner_system.js":["recordRobotClean"],
|
||||||
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
"js/world_tool_actions.js":["recordPlayerDeletion","recordAntDamageSource"],
|
||||||
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
"js/world_family_social.js":["resetWorldProgress","recordFightStarted"],
|
||||||
|
|
@ -195,7 +217,16 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
|
||||||
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
|
"js/item_dynamic_tool_system.js":["recordStickyBombPass", "reason === \"transfer\""],
|
||||||
"js/snapshot_system.js":["stickyBombPassCount"],
|
"js/snapshot_system.js":["stickyBombPassCount"],
|
||||||
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
|
"js/save_codec.js":["sticky_bomb", "[0, -1, 0], 3"],
|
||||||
|
"js/ui_bind.js":["recordStatsButtonPress","chart:","series:"],
|
||||||
};
|
};
|
||||||
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
for(const [file,needles] of Object.entries(checks)){const text=fs.readFileSync(path.join(root,file),"utf8");for(const needle of needles)assert(text.includes(needle),`${file} missing ${needle}`);}
|
||||||
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
console.log("[OK] playstyle achievements: boundaries, reset scope, persistence, inline disclosures, and hook coverage passed");
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
{
|
||||||
|
const achSrc = fs.readFileSync("js/achievements.js", "utf8");
|
||||||
|
const ecologyBlock = achSrc.match(/id: "ecology"[\s\S]*?id: "population"/)?.[0] || "";
|
||||||
|
const otherBlock = achSrc.match(/id: "other"[\s\S]*?const DEFINITIONS/)?.[0] || "";
|
||||||
|
assert(!ecologyBlock.includes('"across_seasons"'), "across_seasons category remained ecology");
|
||||||
|
assert(otherBlock.includes('"across_seasons"'), "across_seasons category is not その他");
|
||||||
|
}
|
||||||
|
|
|
||||||
194
scripts/population_recovery_regression_audit.js
Normal file
194
scripts/population_recovery_regression_audit.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const read = rel => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||||
|
|
||||||
|
// The removed load-settle feature may only be explicitly cleared for stale
|
||||||
|
// runtime state; it must never be scheduled or checked as active behavior.
|
||||||
|
const snapshotSource = read("js/snapshot_system.js");
|
||||||
|
const settleSources = [
|
||||||
|
"js/collision_response_system.js",
|
||||||
|
"js/tarinai_social_move_life.js",
|
||||||
|
"js/tarinai_update_step_movement.js",
|
||||||
|
"js/world_environment.js",
|
||||||
|
].map(read).join("\n");
|
||||||
|
assert(!/_restoreSettleUntil\s*=/.test(snapshotSource), "load settle timer is still assigned");
|
||||||
|
assert(!/_restoreSettleUntil/.test(settleSources), "load settle behavior is still checked outside snapshot cleanup");
|
||||||
|
assert(snapshotSource.includes("delete worldRef._restoreSettleUntil"), "stale settle state is not cleared on restore");
|
||||||
|
|
||||||
|
// Colony population statistics must expose both special populations.
|
||||||
|
const chartsSource = read("js/ui_charts.js");
|
||||||
|
assert(chartsSource.includes('key: "zunchiSlaves"') && chartsSource.includes('label: "\\u305a\\u3093\\u3061\\u3069\\u308c\\u3044"'), "zunchi slave population series is missing");
|
||||||
|
assert(chartsSource.includes('key: "tarinaiKings"') && chartsSource.includes('label: "\\u305f\\u308a\\u306a\\u3044\\u738b"'), "tarinai king population series is missing");
|
||||||
|
assert(chartsSource.includes("world.tarinaiCounts?.()") && chartsSource.includes("counts.zunchiSlaves") && chartsSource.includes("counts.tarinaiKings"), "special population counters are not sourced from differential live counters");
|
||||||
|
assert(chartsSource.indexOf('key: "deaths"') < chartsSource.indexOf('key: "zunchiSlaves"'), "zunchi slave button must appear after deaths");
|
||||||
|
assert(chartsSource.indexOf('key: "zunchiSlaves"') < chartsSource.indexOf('key: "tarinaiKings"'), "tarinai king button must follow zunchi slave");
|
||||||
|
|
||||||
|
const maintenanceSource = read("js/simulation_maintenance_system.js");
|
||||||
|
assert(!maintenanceSource.includes("periodic-runtime-cache-prune"), "periodic full runtime-cache prune is still enabled");
|
||||||
|
|
||||||
|
class World {}
|
||||||
|
global.World = World;
|
||||||
|
let historyTrimCalls = 0;
|
||||||
|
global.TarinaiHistory = {
|
||||||
|
trimMemory(world, options) {
|
||||||
|
historyTrimCalls += 1;
|
||||||
|
world._lastTrimOptions = options;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
require(path.join(ROOT, "js/family_graph.js"));
|
||||||
|
require(path.join(ROOT, "js/world_spatial_budget.js"));
|
||||||
|
require(path.join(ROOT, "js/world_family_social.js"));
|
||||||
|
|
||||||
|
function makeWorld() {
|
||||||
|
const world = new World();
|
||||||
|
world.time = 100;
|
||||||
|
world.tarinai = [];
|
||||||
|
world.spatial = { tarinaiCells: new Map([["old", [1, 2, 3]]]) };
|
||||||
|
world.markSpatialDirty = () => { world._spatialDirtyMarked = true; };
|
||||||
|
world.markFamilyTreeDirty = () => {};
|
||||||
|
world.normalizeFamily = () => false;
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime cache pruning is death-triggered: small numbers of deaths must not
|
||||||
|
// schedule a full survivor relationship scan, while the adaptive threshold does.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
world.liveTarinai = new Map(Array.from({ length: 100 }, (_, i) => [`T${i}`, { target: { id: `T${i}`, dead: false } }]));
|
||||||
|
for (let i = 0; i < 19; i++) world.noteTarinaiDeathForRuntimeCleanup();
|
||||||
|
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "cleanup scheduled before the minimum 20-death threshold");
|
||||||
|
world.noteTarinaiDeathForRuntimeCleanup();
|
||||||
|
assert.strictEqual(world.tarinaiRuntimePrunePending, true, "cleanup was not scheduled at the death threshold");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending death-threshold cleanup executes once at compaction and then resets.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
const alive = Array.from({ length: 100 }, (_, i) => ({ id: `A${i}`, dead: false, relationships: {} }));
|
||||||
|
const dead = Array.from({ length: 20 }, (_, i) => ({ id: `D${i}`, dead: true, relationships: {} }));
|
||||||
|
world.tarinai = [...alive, ...dead];
|
||||||
|
world.liveTarinai = new Map(alive.map(t => [t.id, { target: t }]));
|
||||||
|
world.deadTarinaiSinceRuntimePrune = 20;
|
||||||
|
world.tarinaiRuntimePrunePending = true;
|
||||||
|
world._deadTarinaiIdsPendingCleanup = new Set(dead.map(t => t.id));
|
||||||
|
world.compactTarinai();
|
||||||
|
assert(world._tarinaiRuntimePruneJob, "pending runtime cleanup was not queued during compaction");
|
||||||
|
assert.strictEqual(world.deadTarinaiSinceRuntimePrune, 0, "death cleanup counter was not reset when the job was queued");
|
||||||
|
assert.strictEqual(world.tarinaiRuntimePrunePending, false, "death cleanup pending flag was not cleared when queued");
|
||||||
|
let steps = 0;
|
||||||
|
while (world._tarinaiRuntimePruneJob && steps++ < 10) world.processTarinaiRuntimeCachePruneStep(24);
|
||||||
|
assert(!world._tarinaiRuntimePruneJob, "queued runtime cleanup did not finish in bounded chunks");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime caches must stop retaining dead peers and expired peak-population keys.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
const aliveA = { id: "A", dead: false, relationships: { B: { affinity: 1 }, C: { affinity: 2 } }, relationCache: { stale: true } };
|
||||||
|
const deadB = { id: "B", dead: true, relationships: {} };
|
||||||
|
const aliveC = { id: "C", dead: false, relationships: { A: { affinity: 3 } } };
|
||||||
|
world.tarinai = [aliveA, deadB, aliveC];
|
||||||
|
world.liveTarinai = new Map([["A", { target: aliveA }], ["B", { target: deadB }], ["ghost", { target: null }]]);
|
||||||
|
world.tarinaiCollisionMemo = new Map([["old", 90], ["recent", 99.5]]);
|
||||||
|
world.relationNotices = { old: -50, recent: 50 };
|
||||||
|
world.fightPairCooldowns = { expired: 99, future: 110 };
|
||||||
|
world.resolvedFightIds = { stale: true };
|
||||||
|
world._lastResolvedFightIdsResetAt = 0;
|
||||||
|
world._deadTarinaiIdsPendingCleanup = new Set(["B"]);
|
||||||
|
|
||||||
|
world.pruneTarinaiRuntimeCaches("audit", { resetRelationPeerCaches: true });
|
||||||
|
assert.deepStrictEqual(Object.keys(aliveA.relationships), ["C"], "dead relationship peer was retained");
|
||||||
|
assert.strictEqual(aliveA.relationCache, null, "relationship hot cache was not invalidated");
|
||||||
|
assert.deepStrictEqual([...world.liveTarinai.keys()], ["A"], "dead live-id cache entries were retained");
|
||||||
|
assert.deepStrictEqual([...world.tarinaiCollisionMemo.keys()], ["recent"], "stale collision memo was retained");
|
||||||
|
assert.deepStrictEqual(Object.keys(world.relationNotices), ["recent"], "stale relation notice was retained");
|
||||||
|
assert.deepStrictEqual(Object.keys(world.fightPairCooldowns), ["future"], "expired fight cooldown was retained");
|
||||||
|
assert.deepStrictEqual(world.resolvedFightIds, {}, "old resolved-fight keys were retained");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large population collapse must recreate high-water scratch containers and
|
||||||
|
// release large undo snapshots rather than keeping the peak footprint forever.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
const oldRenderScratch = new Array(400).fill(null);
|
||||||
|
const oldRenderSeen = new Set(Array.from({ length: 400 }, (_, i) => `id-${i}`));
|
||||||
|
world._renderVisibleTarinaiScratch = oldRenderScratch;
|
||||||
|
world._renderVisibleSeen = oldRenderSeen;
|
||||||
|
world.tarinaiCollisionMemo = new Map([["peak", 100]]);
|
||||||
|
world.relationNotices = { peak: 100 };
|
||||||
|
world.resolvedFightIds = { peak: true };
|
||||||
|
world.fightPairCooldowns = { peak: 999 };
|
||||||
|
world._solidObstacleRectQueryCache = new Map([["peak", []]]);
|
||||||
|
world.spatialTarinaiScratch = new Array(400).fill(null);
|
||||||
|
world.drawList = new Array(400).fill(null);
|
||||||
|
world._tarinaiRuntimeHighWater = 400;
|
||||||
|
world.tarinai = Array.from({ length: 400 }, (_, i) => ({ id: `T${i}`, dead: i >= 200, relationships: {} }));
|
||||||
|
|
||||||
|
const changed = world.compactTarinai();
|
||||||
|
assert.strictEqual(changed, true, "population compaction did not run");
|
||||||
|
assert(world._tarinaiRuntimePruneJob, "population collapse did not queue an aggressive cleanup job");
|
||||||
|
let shrinkSteps = 0;
|
||||||
|
while (world._tarinaiRuntimePruneJob && shrinkSteps++ < 20) world.processTarinaiRuntimeCachePruneStep(32);
|
||||||
|
assert(!world._tarinaiRuntimePruneJob, "aggressive population cleanup did not complete in chunks");
|
||||||
|
assert.strictEqual(world.tarinai.length, 200, "dead tarinai were not compacted");
|
||||||
|
assert.strictEqual(world._tarinaiRuntimeHighWater, 200, "population high-water mark did not collapse");
|
||||||
|
assert.notStrictEqual(world._renderVisibleTarinaiScratch, oldRenderScratch, "render scratch retained peak backing store");
|
||||||
|
assert.notStrictEqual(world._renderVisibleSeen, oldRenderSeen, "render seen-set retained peak entries");
|
||||||
|
assert.strictEqual(world.tarinaiCollisionMemo.size, 0, "collision peak cache survived aggressive shrink");
|
||||||
|
assert(historyTrimCalls > 0, "history snapshots were not memory-trimmed after population collapse");
|
||||||
|
assert.strictEqual(world._lastTrimOptions.targetBytes, 8 * 1024 * 1024, "population-collapse history target is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id, generation, alive, parents = [], children = []) {
|
||||||
|
return { id, generation, alive, parents: [...parents], children: [...children], hasPaired: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead generation is protected while an older generation above it is alive.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
world.family = {
|
||||||
|
A: node("A", 1, true, [], ["B"]),
|
||||||
|
B: node("B", 2, false, ["A"], ["C"]),
|
||||||
|
C: node("C", 3, true, ["B"], []),
|
||||||
|
};
|
||||||
|
world.tarinai = [{ id: "A", dead: false, parents: [], children: ["B"] }, { id: "C", dead: false, parents: ["B"], children: [] }];
|
||||||
|
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), false, "dead generation was pruned despite a living older generation");
|
||||||
|
assert.deepStrictEqual(Object.keys(world.family).sort(), ["A", "B", "C"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once all older generations are extinct, extinct leading generations are removed.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
world.family = {
|
||||||
|
A: node("A", 1, false, [], ["B"]),
|
||||||
|
B: node("B", 2, false, ["A"], ["C"]),
|
||||||
|
C: node("C", 3, true, ["B"], []),
|
||||||
|
};
|
||||||
|
world.tarinai = [{ id: "C", dead: false, parents: ["B"], children: [] }];
|
||||||
|
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true, "extinct leading generations were not pruned");
|
||||||
|
assert.deepStrictEqual(Object.keys(world.family), ["C"], "unexpected family generations remained after pruning");
|
||||||
|
assert.deepStrictEqual(world.family.C.parents, [], "survivor retained a deleted parent link");
|
||||||
|
assert.deepStrictEqual(world.tarinai[0].parents, [], "live tarinai retained a deleted parent link");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully extinct disconnected family component may be dropped completely.
|
||||||
|
{
|
||||||
|
const world = makeWorld();
|
||||||
|
world.family = {
|
||||||
|
A: node("A", 1, false, [], ["B"]),
|
||||||
|
B: node("B", 2, false, ["A"], []),
|
||||||
|
X: node("X", 1, true, [], ["Y"]),
|
||||||
|
Y: node("Y", 2, true, ["X"], []),
|
||||||
|
};
|
||||||
|
world.tarinai = [{ id: "X", dead: false, parents: [], children: ["Y"] }, { id: "Y", dead: false, parents: ["X"], children: [] }];
|
||||||
|
assert.strictEqual(world.pruneExtinctFamilies({ normalize: false }), true);
|
||||||
|
assert.deepStrictEqual(Object.keys(world.family).sort(), ["X", "Y"], "fully extinct component was retained or living component was removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Population recovery regression audit passed.");
|
||||||
|
|
@ -370,8 +370,8 @@ if (!placementResult || placementResult.removed || placementItem.dead) throw new
|
||||||
click_section = placement.find("const itemType = toolItemType(tool)")
|
click_section = placement.find("const itemType = toolItemType(tool)")
|
||||||
click_direct = placement.find("const directTarget =", click_section)
|
click_direct = placement.find("const directTarget =", click_section)
|
||||||
duplicator_set = placement.find("this.directSetDuplicatorAt?.", click_section)
|
duplicator_set = placement.find("this.directSetDuplicatorAt?.", click_section)
|
||||||
if min(click_section, click_direct, duplicator_set) < 0 or click_direct > duplicator_set:
|
if min(click_section, click_direct, duplicator_set) < 0 or duplicator_set > click_direct:
|
||||||
fail("Tarinai direct feeding must take precedence over duplicator loading at the same point")
|
fail("Duplicator loading must take precedence over Tarinai direct feeding at the same point")
|
||||||
required_ui = ["directFeedTargetAt", 'source: "pinch"', "setGiveHover", "hoverGiveTarget"]
|
required_ui = ["directFeedTargetAt", 'source: "pinch"', "setGiveHover", "hoverGiveTarget"]
|
||||||
if any(token not in ui for token in required_ui):
|
if any(token not in ui for token in required_ui):
|
||||||
fail("pinch direct-feeding input bridge is incomplete")
|
fail("pinch direct-feeding input bridge is incomplete")
|
||||||
|
|
@ -1389,6 +1389,7 @@ context.window = context;
|
||||||
context.isServingFoodType = (type) => type === 'food';
|
context.isServingFoodType = (type) => type === 'food';
|
||||||
context.passiveFoodDecayInterval = () => 0.5;
|
context.passiveFoodDecayInterval = () => 0.5;
|
||||||
context.passiveFoodDecayRate = () => 0.1;
|
context.passiveFoodDecayRate = () => 0.1;
|
||||||
|
context.updateServingFoodVisualSize = (item) => {{ item.r = (item.r || 12) - 0.1; return true; }};
|
||||||
context.TarinaiItemRegistry = {{ food: {{ }} }};
|
context.TarinaiItemRegistry = {{ food: {{ }} }};
|
||||||
context.isPinType = (type) => type === 'pushpin';
|
context.isPinType = (type) => type === 'pushpin';
|
||||||
context.normalizeGrassStage = (item) => {{ item.normalized = true; }};
|
context.normalizeGrassStage = (item) => {{ item.normalized = true; }};
|
||||||
|
|
@ -1401,11 +1402,13 @@ const events = [];
|
||||||
const world = {{
|
const world = {{
|
||||||
itemDropImpact(item) {{ events.push(['drop', item.type]); }},
|
itemDropImpact(item) {{ events.push(['drop', item.type]); }},
|
||||||
markTerrainDirty(reason) {{ events.push(['terrain', reason]); }},
|
markTerrainDirty(reason) {{ events.push(['terrain', reason]); }},
|
||||||
|
markItemBucketsDirty(reason) {{ events.push(['buckets', reason]); }},
|
||||||
|
markSpatialDirty(reason) {{ events.push(['spatial', reason]); }},
|
||||||
emit(type, payload) {{ events.push(['emit', type, payload.type]); }},
|
emit(type, payload) {{ events.push(['emit', type, payload.type]); }},
|
||||||
}};
|
}};
|
||||||
const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }};
|
const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }};
|
||||||
if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed');
|
if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed');
|
||||||
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind, value]) => kind === 'terrain' && value === 'food-passive-decay')) throw new Error('frame/decay step did not run');
|
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind, value]) => kind === 'buckets' && value === 'food-passive-resize') || !events.some(([kind, value]) => kind === 'spatial' && value === 'food-passive-resize')) throw new Error('frame/decay step did not run');
|
||||||
context.CONFIG = {{ worldPadding: 30 }};
|
context.CONFIG = {{ worldPadding: 30 }};
|
||||||
context.clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
context.clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||||
context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
||||||
|
|
@ -2755,7 +2758,7 @@ vm.createContext(collisionContext);
|
||||||
vm.runInContext({collisions!r}, collisionContext, {{ filename: 'collision_response_system.js' }});
|
vm.runInContext({collisions!r}, collisionContext, {{ filename: 'collision_response_system.js' }});
|
||||||
const ball = {{ id: 'ball', type: 'ball', x: 9, y: 0, prevX: -9, prevY: 0, r: 10, vx: 100, vy: 0, spinVelocity: 0, dead: false }};
|
const ball = {{ id: 'ball', type: 'ball', x: 9, y: 0, prevX: -9, prevY: 0, r: 10, vx: 100, vy: 0, spinVelocity: 0, dead: false }};
|
||||||
const sleeper = {{ id: 'sleeper', name: 'sleeper', x: 20, y: 0, radius: 20, vx: 0, vy: 0, state: 'sleep', sleeping: true, dead: false, energy: 100 }};
|
const sleeper = {{ id: 'sleeper', name: 'sleeper', x: 20, y: 0, radius: 20, vx: 0, vy: 0, state: 'sleep', sleeping: true, dead: false, energy: 100 }};
|
||||||
const collisionWorld = {{ time: 1, w: 500, h: 500, itemCounts: {{ ball: 1, balloon: 0 }}, items: [ball], effects: [], itemsOfType(type) {{ return type === 'ball' ? [ball] : []; }}, nearbyTarinai() {{ return [sleeper]; }}, isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}}, relationNotice() {{ return false; }}, applyImpulse() {{}}, applyImpactDamage() {{}} }};
|
const collisionWorld = {{ time: 1, w: 500, h: 500, itemCounts: {{ ball: 1, balloon: 0 }}, items: [ball], effects: [], spawnEffect() {{ return null; }}, itemsOfType(type) {{ return type === 'ball' ? [ball] : []; }}, nearbyTarinai() {{ return [sleeper]; }}, isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}}, relationNotice() {{ return false; }}, applyImpulse() {{}}, applyImpactDamage() {{}} }};
|
||||||
collisionContext.TarinaiCollisionResponseSystem.resolveBallInteractions(collisionWorld, 0.016);
|
collisionContext.TarinaiCollisionResponseSystem.resolveBallInteractions(collisionWorld, 0.016);
|
||||||
if (!(ball.vx < 0) || !(sleeper.vx > 0) || sleepMotionMarks < 1) throw new Error('sleeping Tarinai did not physically collide with ball');
|
if (!(ball.vx < 0) || !(sleeper.vx > 0) || sleepMotionMarks < 1) throw new Error('sleeping Tarinai did not physically collide with ball');
|
||||||
|
|
||||||
|
|
|
||||||
16
scripts/render_stack_regression_audit.js
Normal file
16
scripts/render_stack_regression_audit.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const render = fs.readFileSync(path.join(root, "js", "render.js"), "utf8");
|
||||||
|
const spatial = fs.readFileSync(path.join(root, "js", "sim_core.js"), "utf8");
|
||||||
|
const budget = fs.readFileSync(path.join(root, "js", "world_spatial_budget.js"), "utf8");
|
||||||
|
function ok(cond, msg) { if (!cond) throw new Error(msg); console.log(`[OK] ${msg}`); }
|
||||||
|
ok(render.includes("_renderStaticVisibleCache") && render.includes("renderStaticVersion"), "static visible render stacks are version-cached");
|
||||||
|
ok(!render.includes("insertionSortNearPrevious") && render.includes("dynamicLayered.sort(compareRenderEntries)"), "dynamic sorting uses native sort without previous-order bookkeeping");
|
||||||
|
ok(render.includes("mergeSortedRenderEntries") && render.includes("mergeSortedBackItems"), "static and dynamic sorted stacks are merged linearly");
|
||||||
|
ok(spatial.includes("linkRenderCells") && spatial.includes("addLinkRenderItem") && spatial.includes("rebuildLinkRenderItems"), "links use dedicated AABB render spatial cells");
|
||||||
|
ok(!render.includes("for (const type of LINK_RENDER_TYPES)"), "per-frame full link type fallback scan is removed");
|
||||||
|
ok(render.includes("drawCarriedPlushieAtOwner") && !render.includes("syncCarriedPlushieToOwner"), "carried plushie rendering no longer synchronizes simulation coordinates");
|
||||||
|
ok(render.includes("entity._renderCullRadius = value"), "eligible fixed cull radii are cached");
|
||||||
|
ok(render.includes("it._renderSortY = renderLayerSortY(it)"), "back-layer sort keys are computed before sorting");
|
||||||
|
ok(budget.includes("this.renderStaticVersion = (this.renderStaticVersion || 0) + 1"), "static spatial rebuilds invalidate the render cache");
|
||||||
106
scripts/secondary_achievement_regression_audit.js
Normal file
106
scripts/secondary_achievement_regression_audit.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
const path = require("path");
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
const achievementSource = fs.readFileSync(path.join(root, "js", "achievements.js"), "utf8");
|
||||||
|
|
||||||
|
class ClassList { add() {} remove() {} contains() { return false; } toggle() { return false; } }
|
||||||
|
class Element {
|
||||||
|
constructor() { this.classList = new ClassList(); this.children = []; this.dataset = {}; this.style = { setProperty() {}, removeProperty() {} }; this.hidden = true; }
|
||||||
|
append(...children) { this.children.push(...children); }
|
||||||
|
replaceChildren(...children) { this.children = children; }
|
||||||
|
addEventListener() {} focus() {} setAttribute() {}
|
||||||
|
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 30 }; }
|
||||||
|
get offsetWidth() { return 100; }
|
||||||
|
}
|
||||||
|
function harness() {
|
||||||
|
const storage = new Map();
|
||||||
|
const ids = ["achievementsBtn","achievementButtonCount","achievementsDialog","achievementDialogCount","achievementsCloseBtn","achievementsCloseIconBtn","achievementRefreshBtn","achievementResetBtn","achievementUnlockAllBtn","achievementList","achievementSharedStatus","achievementToast","achievementToastTitle","pauseBtn"];
|
||||||
|
const elements = new Map(ids.map(id => [id, new Element()]));
|
||||||
|
let timer = 0;
|
||||||
|
const c = {
|
||||||
|
console, Date, Intl, Math, JSON, Object, Array, Map, Set, WeakMap, Promise, Number, String, Boolean, Error, AbortController, encodeURIComponent, URLSearchParams,
|
||||||
|
location: { protocol: "file:", search: "" }, crypto: { randomUUID: () => "11111111-1111-4111-8111-111111111111" },
|
||||||
|
localStorage: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, String(v)), removeItem: k => storage.delete(k) },
|
||||||
|
document: { hidden: false, getElementById: id => elements.get(id) || null, createElement: () => new Element(), addEventListener() {} },
|
||||||
|
TarinaiEvents: { emit() {} }, TarinaiGameDialogs: { confirm: async () => true }, showToast() {}, audio: { uiClick() {} },
|
||||||
|
setTimeout() { return ++timer; }, clearTimeout() {}, setInterval() { return ++timer; }, clearInterval() {}, fetch: async () => { throw new Error("offline"); },
|
||||||
|
TARINAI_VERSION: "39.16.77",
|
||||||
|
};
|
||||||
|
c.window = c; c.globalThis = c;
|
||||||
|
vm.createContext(c); vm.runInContext(achievementSource, c, { filename: "achievements.js" });
|
||||||
|
return { c, api: c.TarinaiAchievements };
|
||||||
|
}
|
||||||
|
function assert(value, message) { if (!value) throw new Error(message); }
|
||||||
|
|
||||||
|
(async function run() {
|
||||||
|
// Sauna timing must start on entering the hot state, not refresh every hot frame.
|
||||||
|
let h = harness();
|
||||||
|
let temperature = 40;
|
||||||
|
const t = { dead: false, x: 0, y: 0 };
|
||||||
|
const world = {
|
||||||
|
time: 0, tarinai: [t], ants: [], items: [],
|
||||||
|
feltTemperatureFor() { return temperature; },
|
||||||
|
temperatureStatusFor(v) { return { direction: v > 25 ? "hot" : (v < 10 ? "cold" : "comfort"), comfortable: v >= 10 && v <= 25 }; },
|
||||||
|
};
|
||||||
|
h.api.evaluateWorld(world, {});
|
||||||
|
world.time = 20; h.api.evaluateWorld(world, {}); // Still hot: must not restart the timer.
|
||||||
|
world.time = 20.1; temperature = 0; h.api.evaluateWorld(world, {});
|
||||||
|
assert(!h.api.isUnlocked("sauna_cold_plunge"), "sauna timer refreshed while continuously hot");
|
||||||
|
world.time = 30; temperature = 40; h.api.evaluateWorld(world, {}); // New hot entry.
|
||||||
|
world.time = 40; temperature = 0; h.api.evaluateWorld(world, {});
|
||||||
|
assert(h.api.isUnlocked("sauna_cold_plunge"), "sauna timer did not restart on a genuine new hot entry");
|
||||||
|
|
||||||
|
// Achievement reset must clear per-world/per-object hidden progress.
|
||||||
|
h = harness();
|
||||||
|
const tarinai = {
|
||||||
|
dead: false,
|
||||||
|
_achievementDirectFeedCount: 9,
|
||||||
|
_achievementDirectTreatmentCount: 3,
|
||||||
|
_achievementSaunaHotAt: 12,
|
||||||
|
_achievementSaunaWasHot: true,
|
||||||
|
_achievementHeldStartedAt: 1234,
|
||||||
|
};
|
||||||
|
const bomb = { type: "sticky_bomb", stickyBombPassCount: 14, _achievementPlayerPlaced: true, _achievementPlacedAt: 9 };
|
||||||
|
const resetWorld = { time: 100, tarinai: [tarinai], items: [bomb] };
|
||||||
|
h.c.world = resetWorld;
|
||||||
|
await h.api.reset();
|
||||||
|
assert(tarinai._achievementDirectFeedCount === 0 && tarinai._achievementDirectTreatmentCount === 0, "care progress survived achievement reset");
|
||||||
|
assert(tarinai._achievementSaunaHotAt == null && tarinai._achievementHeldStartedAt == null, "timed per-tarinai progress survived achievement reset");
|
||||||
|
assert(bomb.stickyBombPassCount === 0, "sticky-bomb relay progress survived achievement reset");
|
||||||
|
assert(bomb._achievementPlayerPlaced === true && bomb._achievementPlacedAt == null, "reset should preserve placement identity but clear quick-delete timing");
|
||||||
|
|
||||||
|
// World-changing history/ground operations are interventions, so passive observation must restart.
|
||||||
|
h = harness();
|
||||||
|
const observerWorld = { time: 0, tarinai: [], ants: [], items: [], fieldType: "garden", groundType: "soil" };
|
||||||
|
h.api.evaluateWorld(observerWorld, {});
|
||||||
|
observerWorld.time = 359.9; h.api.evaluateWorld(observerWorld, {});
|
||||||
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "observer unlocked before three game days");
|
||||||
|
h.api.recordHistoryAction("undo", { world: observerWorld, deadRestored: 0 });
|
||||||
|
observerWorld.time = 360; h.api.evaluateWorld(observerWorld, {});
|
||||||
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "undo did not reset passive-observation progress");
|
||||||
|
observerWorld.time = 500;
|
||||||
|
h.api.recordGroundChange({ world: observerWorld, previous: "soil", next: "ice", now: 1000 });
|
||||||
|
observerWorld.time = 859.9; h.api.evaluateWorld(observerWorld, {});
|
||||||
|
assert(!h.api.isUnlocked("idle_observer_5_minutes"), "ground change did not reset passive-observation progress");
|
||||||
|
observerWorld.time = 860; h.api.evaluateWorld(observerWorld, {});
|
||||||
|
assert(h.api.isUnlocked("idle_observer_5_minutes"), "observer did not unlock after three uninterrupted game days");
|
||||||
|
|
||||||
|
// Minimalist needs player-placement identity even after Unplanned City is already unlocked.
|
||||||
|
const snapshotSource = fs.readFileSync(path.join(root, "js", "snapshot_system.js"), "utf8");
|
||||||
|
assert(snapshotSource.includes('const needPlayerPlacedMetadata = needQuickDelete || needMinimalist;'), "minimalist placement metadata is not retained independently of quick-delete progress");
|
||||||
|
assert(snapshotSource.includes('needPlayerPlacedMetadata && rec?.item?._achievementPlayerPlaced ? 1 : 0'), "snapshot does not serialize minimalist placement identity");
|
||||||
|
|
||||||
|
// Lethal shocks must count before damage can mark the target dead.
|
||||||
|
const signalSource = fs.readFileSync(path.join(root, "js", "signal_system.js"), "utf8");
|
||||||
|
const shockStart = signalSource.indexOf("function shockTarget");
|
||||||
|
const shockEnd = signalSource.indexOf("function damageFromWires", shockStart);
|
||||||
|
const shockBody = signalSource.slice(shockStart, shockEnd);
|
||||||
|
const recordAt = shockBody.indexOf("recordWireShock");
|
||||||
|
const damageAt = shockBody.indexOf("target.damage?.");
|
||||||
|
assert(recordAt >= 0 && damageAt >= 0 && recordAt < damageAt, "wire-shock achievement is still recorded after potentially lethal damage");
|
||||||
|
|
||||||
|
console.log("[OK] secondary achievement regressions: sauna onset, reset cleanup, observer interventions, minimalist save metadata, and lethal wire shocks passed");
|
||||||
|
})();
|
||||||
22
scripts/social_interaction_regression_audit.js
Normal file
22
scripts/social_interaction_regression_audit.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"use strict";
|
||||||
|
const fs = require("fs");
|
||||||
|
const moveSrc = fs.readFileSync("js/tarinai_social_move_life.js", "utf8");
|
||||||
|
const needSrc = fs.readFileSync("js/tarinai_needs_items.js", "utf8");
|
||||||
|
const actionSrc = fs.readFileSync("js/tarinai_action_definitions.js", "utf8");
|
||||||
|
const colonySrc = fs.readFileSync("js/colony_situation_system.js", "utf8");
|
||||||
|
function ok(cond, msg) { if (!cond) { console.error(`[FAIL] ${msg}`); process.exitCode = 1; } else console.log(`[OK] ${msg}`); }
|
||||||
|
ok(!moveSrc.includes('this.socialCrowdSample = {'), "contact scan no longer records crowd density");
|
||||||
|
ok(!needSrc.includes('const crowdSample = tarinai.socialCrowdSample'), "relation need no longer consumes crowd pressure");
|
||||||
|
ok(!actionSrc.includes('createCrowdEscapeActionSpec(),'), "crowd retreat action is not registered");
|
||||||
|
ok(!colonySrc.includes('nearestAverage('), "colony status no longer performs O(N^2) crowd distance scans");
|
||||||
|
ok(!colonySrc.includes('add("overcrowded"'), "overcrowded colony status is no longer selected");
|
||||||
|
ok(!moveSrc.includes('const candidateBudget = Math.max(20, scanLimit + 8)'), "contact candidates are no longer truncated by spatial bucket order");
|
||||||
|
ok(!moveSrc.includes('if (inspected >= candidateBudget) break'), "contact scans do not stop before considering nearer later candidates");
|
||||||
|
ok(moveSrc.includes('if (pos < scanLimit)'), "contact scan keeps a bounded nearest-candidate list");
|
||||||
|
ok(moveSrc.includes('if (detailed.length > scanLimit) detailed.pop()'), "nearest-candidate list remains bounded under dense populations");
|
||||||
|
ok(moveSrc.includes('const pairLeader = String(this.id) < String(o.id)'), "symmetric pair work has one deterministic leader");
|
||||||
|
ok(moveSrc.includes('const d2 = dx * dx + dy * dy'), "distance-squared prefilter is used");
|
||||||
|
ok(moveSrc.includes('const selfProfile = this.personalityProfile()'), "self personality profile is cached per scan");
|
||||||
|
ok(!moveSrc.includes('open.sort('), "social interaction does not introduce full sorting");
|
||||||
|
ok(moveSrc.includes('const combinedStartChance = 1 - (1 - oneDirectionChance) * (1 - oneDirectionChance)'), "single-pass pair processing preserves old start probability");
|
||||||
|
if (process.exitCode) process.exit(process.exitCode);
|
||||||
85
scripts/ui_grass_limits_regression_audit.py
Normal file
85
scripts/ui_grass_limits_regression_audit.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
def text(rel):
|
||||||
|
return (ROOT / rel).read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
def must(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
raise AssertionError(msg)
|
||||||
|
|
||||||
|
index = text('index.html')
|
||||||
|
ui_bind = text('js/ui_bind.js')
|
||||||
|
ui_mouse = text('js/ui_input_mouse.js')
|
||||||
|
ui_shared = text('js/ui_input_shared.js')
|
||||||
|
view = text('js/world_view.js')
|
||||||
|
data = text('js/data.js')
|
||||||
|
temp = text('js/world_temperature_system.js')
|
||||||
|
disease = text('js/tarinai_item_effects.js')
|
||||||
|
spatial = text('js/sim_core.js')
|
||||||
|
spatial_budget = text('js/world_spatial_budget.js')
|
||||||
|
targeting = text('js/tarinai_item_targeting.js')
|
||||||
|
ach = text('js/achievements.js')
|
||||||
|
|
||||||
|
# Tool mode survives ordinary controls, but blank UI space explicitly clears it.
|
||||||
|
must('isInteractiveUiTarget' in ui_bind and 'clearSelectedToolSilently' in ui_bind,
|
||||||
|
'blank-UI tool cancellation helpers are missing')
|
||||||
|
must('!canvas?.contains?.(target) && !isInteractiveUiTarget(target)' in ui_bind,
|
||||||
|
'blank UI space is not the exclusive document-click cancellation path')
|
||||||
|
must('target.closest("button, a, input, select, textarea, label, summary' in ui_bind,
|
||||||
|
'ordinary UI controls are not protected from tool cancellation')
|
||||||
|
|
||||||
|
# Limits: 300, next slot 301 = infinity, default infinity.
|
||||||
|
must('COLONY_LIMIT_MAX = 300' in ui_bind, 'colony limit max is not 300')
|
||||||
|
must('COLONY_LIMIT_INFINITY_SLOT = 301' in ui_bind, 'infinity slot is not 301')
|
||||||
|
must(re.search(r'max="301"[^>]*value="301"', index) is not None, 'limit slider does not default to infinity slot')
|
||||||
|
|
||||||
|
# Right-drag context-menu suppression.
|
||||||
|
must('suppressContextMenuUntil' in ui_mouse and 'contextmenu' in ui_mouse, 'right-pan context-menu suppression missing')
|
||||||
|
must('suppressContextMenuUntil' in ui_shared, 'right-pan suppression timestamp is not set on pan end')
|
||||||
|
|
||||||
|
# Zoom.
|
||||||
|
must(re.search(r'BASE_ZOOM_MAX\s*=\s*4\.5', view) is not None, 'zoom maximum is not 4.5')
|
||||||
|
|
||||||
|
# Disease probability reduced to one-third at the shared helper.
|
||||||
|
must(re.search(r'Number\(base\)[^\n]*/\s*3', disease) is not None or '/ 3' in disease[disease.find('diseaseChance'):disease.find('diseaseChance')+240], 'diseaseChance is not reduced to one-third')
|
||||||
|
|
||||||
|
# Summer correction removed completely.
|
||||||
|
must(re.search(r'summerTemperatureBoost\s*:\s*0\b', data) is not None, 'summerTemperatureBoost is not zero')
|
||||||
|
body = re.search(r'(?:function\s+)?seasonTemperatureBias\s*\([^)]*\)\s*\{([\s\S]*?)\n\s*\}', temp)
|
||||||
|
must(body is not None and re.search(r'\breturn\s+0\s*;', body.group(1)), 'seasonTemperatureBias does not return zero')
|
||||||
|
|
||||||
|
# Grass: dedicated spatial index and no global grass fallback re-add in food search.
|
||||||
|
must('grassCells = new Map()' in spatial, 'dedicated grassCells index missing')
|
||||||
|
must('nearbyGrass(' in spatial_budget and 'spatial.grassCells' in spatial_budget, 'nearbyGrass spatial query missing')
|
||||||
|
must('type === "grass"' in targeting and 'nearbyFood' in targeting, 'grass global-bucket skip guard missing')
|
||||||
|
# Ensure the hot-path global type bucket explicitly skips grass when nearbyFood exists.
|
||||||
|
must(re.search(r'if\s*\(\s*type\s*===\s*["\']grass["\'][^)]*nearbyFood', targeting) is not None,
|
||||||
|
'food targeting does not skip the global grass bucket')
|
||||||
|
|
||||||
|
# Achievements 73-75 and persistent Memento Mori progress.
|
||||||
|
for aid in ('well_informed', 'lively_making', 'memento_mori'):
|
||||||
|
must(aid in ach, f'achievement {aid} missing')
|
||||||
|
must('memento_mori' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach, 'Memento Mori pre-unlock condition visibility missing')
|
||||||
|
must('totalDeathCount' in ach, 'persistent total death counter missing')
|
||||||
|
|
||||||
|
|
||||||
|
# Current UI/achievement/colony changes.
|
||||||
|
placement_preview = text('js/placement_preview_system.js')
|
||||||
|
placement_log = text('js/world_placement_log.js')
|
||||||
|
colony = text('js/colony_situation_system.js')
|
||||||
|
components = text('css/components.css')
|
||||||
|
|
||||||
|
must('achievementToastCondition' in index and 'achievement-toast-condition' in components, 'achievement unlock toast condition line missing')
|
||||||
|
must('if (dom.toastCondition) dom.toastCondition.textContent = definition.description || "";' in ach, 'achievement toast does not render the completion condition')
|
||||||
|
must('lively_making' in ach and 'PRE_UNLOCK_DESCRIPTION_IDS' in ach and 'manualTarinaiAddedCount' in ach, 'Lively Making persistent/pre-unlock progress is missing')
|
||||||
|
must(r'title: "\u7e41\u6b96\u30fb\u4eba\u53e3"' in ach and r'title: "\u64cd\u4f5c"' in ach and 'id: "other"' in ach, 'six-category achievement reorganization missing')
|
||||||
|
must('num(rect.bottom) > (worldRef.h || 0)' in placement_preview, 'placement preview still reserves an artificial bottom padding')
|
||||||
|
must('rect.bottom > this.h' in placement_log, 'confirmed placement still reserves an artificial bottom padding')
|
||||||
|
must('Math.max(5, Math.ceil(m.n * 0.10))' in colony, 'crisis mortality threshold is still too loose')
|
||||||
|
must('criticalRatio' in colony and 'crisisMortalitySignal && crisisSystemicStress' in colony and 'crisisCollapseSignal' in colony, 'crisis no longer requires compound severe conditions')
|
||||||
|
must('!m.populationIncreasing' in colony, 'crisis population-growth exclusion was lost')
|
||||||
|
|
||||||
|
print('[OK] UI/limits/input/zoom/disease/grass/summer/achievement/placement/crisis regression audit passed')
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const APP_VERSION = "39.16.75";
|
const APP_VERSION = "39.16.98";
|
||||||
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||||
const v = `v=${APP_VERSION}`;
|
const v = `v=${APP_VERSION}`;
|
||||||
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue