diff --git a/.htaccess b/.htaccess index 36b3f0a..fc13343 100644 --- a/.htaccess +++ b/.htaccess @@ -15,8 +15,13 @@ Header set Pragma "no-cache" Header set Expires "0" + + Header set Cache-Control "no-cache, no-store, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + - Header set Cache-Control "public, max-age=2592000" + Header set Cache-Control "public, max-age=2592000, immutable" diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index 938feeb..3bbdf03 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,69 +1,248 @@ -## 15.9.2 修正 -- 巣箱アイコンとゲーム内描画から、枠内の明色・透過っぽく見える装飾を除去。 -- アリのHPバーを、たりないと同様にダメージ直後だけ表示する方式へ変更。 -- 道具UIに「げんこつ」を追加。添付画像から正義げんこつ部分を切り抜き、上から落下してたりない・アリにダメージと吹っ飛びを与える効果を追加。 -- 初期ロード画面と読み込み進捗バーを追加。JS/CSSをdefer化し、CSS/JSのサーバーキャッシュを有効化して再訪問時の通信量を削減。 +# Patch notes + +## 15.20.16 + +- Added canonical item visual, food, and disease registries; wired food decay/visual helpers and mystery-drug disease sampling to registry data. +- Generated the tool palette from `TOOL_CATEGORIES` at runtime and removed duplicated category markup from `index.html`. +- Added compact family-tree personality tags, daily personality-change details, and selected-Tarinai rolling recent-change causes for personality, damage, item-effect, disease, and ant-carry changes. +- Added `InputModeManager` for touch-first mobile classification and `WeatherSystem` for weather transitions/rain-water spawning. +- Added central domain events for placement, eating, decay, effects, diseases, damage, death, birth, fights, ants, weather, and rain item drops. +- Moved item-effect modifiers, active summaries, apply/tick behavior callbacks, and food interest class queries into registries. +- Moved explosion-disease base infection chance reads into `DiseaseRegistry` while preserving protein/niterop disease multipliers. +- Moved serving-food nutrition and hunger-relief reads into `FoodRegistry`, including laxative's zero hunger relief. +- Routed direct love/fight/sleep item eating through `EffectRegistry` while preserving the previous bite-specific stress, fear, and sleep-disease chance behavior. +- Routed sleep drug, rain-water item, and water-bowl field visuals through `item_visual_registry.js`; added water-bowl icon-generation support. +- Moved canonical sound gains, sample paths, categories, cooldowns, and ID category mapping out of `audio.js` into `sound_pack.js`. +- Added named ant ecology extension hooks/comments without changing ant balance. +- Bumped cache/version references to `15.20.16`; documented the new source-of-truth rules. + +## 15.20.12 + +- Updated documentation after inspecting the current game surface: field choices, startup flow, tool groups, chart/family/debug surfaces, localStorage settings, and validation fallback when Node is unavailable. +- Removed unused `css/styles.css` shim and no-op `js/ui_family.js` loader entry; refactored duplicate world initialization and allocation-heavy live-count paths. + +- Removed the unwanted item-effect speech bubbles listed in `docs/prompt.txt`. +- Reorganized the tool palette into color-coded collapsible groups: operation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles. +- Changed the tool UI ball icon to a soccer-ball emoji preview. +- Changed time phases: morning 05:00, day 10:00, evening 17:00, night 20:00. +- Increased genkotsu knockback for Tarinai and ants; genkotsu now also blasts zunchi away. +- Genkotsu damage now adds fear/stress and decreases Tarinai openness. +- Rain weather overlay is now diagonal streaks. Fallen rain-water items, zunda juice, and mercury remain horizontal oval item sprites. +- Tightened mobile operation UI visibility to smartphone-class compact touch devices. +- Sleep-disease Tarinai can be moved by ant hauling without snapping back to their sleep anchor. + +## 15.20.12 and earlier summary + +- Consolidated documentation into dense English files; left `docs/prompt.txt` untouched. +- Restored rain, revised water/zunda/mercury item sprites, added zunchi-slave head zunchi, fight win rate, fight defeat chance, and post-wake lock. +- Earlier patches added parameter items, persistent item effects, WebP assets, sound categories, push notifications, ant systems, family tree UI, performance manager, event bus, manifest generation, CSS split, and large-file JS split. + +## Debug patch: panic chain and action reasons + +- Removed the friend-only panic chain that made only a best friend follow a panic target. +- Added general nearby panic contagion: any nearby live tarinai in panic can cause another tarinai to panic. Neuroticism, distance, and current stress affect the probability. +- Added / completed visible action reasons for: + - eating all food and effect items + - eating grass, ant corpses, and zunchi + - drinking / touching water + - resting near stone, bed, and nest box + - sunbathing + - zunchi-slave avoidance + - being pinched / dragged + - firecracker blast panic + - explosion-disease blast panic + - falling object / stone / genkotsu impact + - high-speed ball collision + - high-speed tarinai collision + - intimidation, intimidation escape, fights, birth ritual, and thaw surprise + - fence-blocked path give-up +- Added state labels for `sunbath`, `ant_attack`, `ant_intimidate`, and `frozen`. +- Expanded target labels for all current tool/item target types, including medicine/effect items, ant corpses, ant nests, firecrackers, pushpins, signboards, and fences. +- Verification performed: + - `node --check` over every `js/*.js` + - state-label coverage scan: no uncovered tarinai states + - old friend-panic chain scan: zero matches -## 15.9.1 修正 -- 草を地面レイヤーとして扱い、既存の草の上にもオブジェクトを配置できるようにしました。草同士・草と障害物の植え付け制限は維持しています。 -- けんか餅のゲーム内描画から星印を削除し、×印のみ表示するようにしました。 -- ダメージ後に出る「こわい…」吹き出しを削除しました。 -- 家系図に「更新」トグルを追加しました。デフォルトはOFFで、ONの間だけ家系図を描画・更新します。 -- 死亡した個体のライブIDを再利用しないようにし、友達・敵対関係が別個体へ引き継がれて見える問題を防止しました。死亡時の一時ターゲット参照も掃除します。 -- たりないデータUIをカテゴリ別の折り畳み表示に変更しました。 +## おしり鋲・冷凍操作変更パッチ +- ずんちどれい化条件の喧嘩勝率を30%以下に変更。 +- 衰弱死因判定を厳格化。 +- 冷凍手段をクリック式から「つまむで冷凍UIへ運ぶ」方式へ変更。 +- 新アイテム「おしり鋲」を追加。刺さるとずんちを溜め、日光浴不可。抜くと溜まったずんちが高速で飛散。 +- 高速ずんち衝突時の吹き飛び・パニック理由文を追加。 -# Patch Notes +## 15.22.0 統合リファクタ -## Encoding cleanup -- Normalized `index.html` back to UTF-8. -- Replaced mojibake strings with the intended labels. -- Escaped Japanese text in source files to avoid encoding-dependent corruption. +- `snapshot_system.js` を追加し、セーブ/ロードと冷凍で重複していた `clonePlain` / `copyOwnData` / `applyOwnData` / 参照復元 / スナップショット作成・復元を共通化。 +- `tarinai_action_state.js` を追加し、`setActionState()` / `enterPanic()` / `beginEating()` により、状態変更・パニック化・食事開始の理由文設定を統合。 +- 直接 `state = "panic"` していた処理を `enterPanic()` 経由へ変更。爆竹、爆発病、高速ボール、高速衝突、アリ、画鋲、近傍パニック連鎖、関係者死亡、解凍時の訃報などが対象。 +- 直接 `state = "eat"` していた食事処理を `beginEating()` 経由へ変更。 +- `physics_helpers.js` を追加し、壁反射・摩擦・回転を `TarinaiPhysics.applyKinematicItemMotion()` に共通化。おしり鋲/画鋲/高速ずんちの移動処理に適用。 +- `ITEM_TRAITS` と `PIN_BEHAVIORS` を追加し、アイテム分類と画鋲/おしり鋲の差分挙動をテーブル化。 +- `text_catalog.js` に状態ラベル、対象ラベル、理由文補完を集約。`ui_selected.js` はカタログ参照へ変更。 +- `family_graph.js` を追加し、家系グラフの連結成分探索を共通化。同期家系データと描画側に適用。 +- `relation_event_system.js` を追加し、関係者死亡の即時反応と冷凍個体への遅延通知を共通化。 +- `ui_helpers.js` を追加し、HTMLエスケープ等のUI共通処理を切り出し。 +- 構文チェック: 全 `js/*.js` OK。 +- 重複確認: `clonePlain` / `copyOwnData` / `applyOwnData` は `snapshot_system.js` に集約、直接 `state = "panic"` と直接 `state = "eat"` は除去済み。 -## Personality specification fixes -- Personality tags and data UI now use Japanese labels from the four-axis personality model. -- Threshold-crossing personality logs are now generated in Japanese and only when visible trait bands change. -- Daily personality caps now track increase and decrease separately per parameter. -- Current personality effects are neutral inside the -0.5 to +0.5 band. -- Direct conversion to the legacy timid personality after damage was removed; cowardly behavior is derived from high neuroticism plus low aggression. -- Corpse/splat contact now increases stress and slowly reduces neuroticism through habituation. +## 15.23.0 safe duplicate integration pass -## Follow-up fixes -- Fixed CSS emoji escapes for tool UI icons. CSS now uses code-point escapes such as `\\1F448` instead of invalid surrogate escapes. -- Hid the detailed four-axis personality rows from the compact tarinai data UI. -- Restored `bestBedFor()` fallback so tarinai can choose hay beds when no usable nest box is available. -- Damage now wakes sleeping tarinai and forces tarinai hidden inside a nest box to leave the box. -- Added a short minimum stay window after entering a nest box to avoid immediate exit loops. -- Renamed the friend relation label from `\\u4ef2\\u826f\\u3057` to `\\u53cb\\u9054` in UI text and related logs. +- `registry_base.js` を追加し、`food_registry.js` / `effect_registry.js` / `disease_registry.js` の `get` / `has` / `label` / `color` / `debugSnapshot` 系の共通基盤を統合。 +- `patch_helpers.js` を追加し、後付けの `World.prototype` パッチを `TarinaiPatcher.patchWorld()` 経由に統合。冷凍・関係者死亡通知で利用。 +- `data.js` に `sleepFurniture` / `draggable` trait と、`isPinType()` / `isSleepFurnitureType()` / `lodgedPinFor()` / `hasLodgedPinEffect()` / `normalizedToolSizeFor()` / `toolSizeScaleFor()` を追加。 +- `ui_tools.js` と `world_view.js` のツールサイズ算出を `normalizedToolSizeFor()` / `toolSizeScaleFor()` に統合。 +- `tarinai_action_state.js` に `currentLodgedPin()` / `currentLodgedPinBehavior()` / `hasLodgedPinEffect()` / `goIdle()` / `startSleeping()` / `seekTarget()` を追加。 +- `blockedByOshibyo` 系の重複判定を `hasLodgedPinEffect("blocksZunchi")` に統合。 +- 画鋲/おしり鋲の型判定を `isPinType()` に寄せ、描画・配置・つまみ・削除判定の分岐を整理。 +- 睡眠家具判定を `isSleepFurnitureType()` に寄せ、ベッド/巣箱の直書き判定を削減。 +- 手動アリ近傍探索の一部を `world.nearbyAnts()` に寄せた。 +- `pruneExtinctFamilies()` を `TarinaiFamilyGraph.connectedComponents()` に寄せた。`family_graph.js` 側のデフォルト edge も親子参照を使うようにした。 +- `save_system.js` / `freeze_system.js` / `ui_selected.js` のHTMLエスケープを `TarinaiUIHelpers.htmlEscape()` に統合。 +- `assets.js` のスプライト/汚れオーバーレイ用キャンバス生成を `TarinaiPhysics.createCachedCanvas()` に寄せた。 +- 危険度の高い `render.js` 全体再設計、`Tarinai.update()` の状態機械化、`ants.js` の状態機械化、`items.js` 描画全体のレジストリ化は未実施。 +- 確認: 全 `js/*.js` の `node --check` 通過。 + +### 15.23.0 追確認 + +- `service-worker.js` / `app_manifest.json` のスクリプト一覧を `index.html` の実ロード順に同期。新規共通化ファイルもキャッシュ対象に追加。 +- アリ運搬中に寝ているたりないの状態変更も `startSleeping()` 経由へ寄せた。 +- 最終確認: 全 `js/*.js` と `service-worker.js` の `node --check` 通過。 +- 最終確認: `blockedByOshibyo`、`type === "oshibyo"`、`type === "pushpin"` の直接判定は 0件。`clonePlain` / `copyOwnData` / `applyOwnData` / `isPlainObject` は `snapshot_system.js` のみ。 + +## 15.24.0 safe integration / UI escape recursion fix + +- Fixed `Maximum call stack size exceeded` caused by `ui_helpers.htmlEscape()` calling the global `escapeHtml()` while `ui_selected.escapeHtml()` called back into `TarinaiUIHelpers.htmlEscape()`. +- Made `TarinaiUIHelpers.htmlEscape()` a pure escaping function and loaded `ui_helpers.js` before `ui_selected.js`. +- Added a guard in `ui_selected.escapeHtml()` so it never delegates to itself. +- Added a data URL favicon link to stop the browser's default `/favicon.ico` 404 request. +- Bumped static asset version to `15.24.0` in `app_manifest.json`, `index.html`, `service-worker.js`, and `js/version.js` to avoid stale cached scripts. +- Reduced target-label duplication: item labels now come from `TOOL_DEFINITIONS` / `toolLabel()`, with `text_catalog.js` holding only non-tool overrides. +- Removed the remaining synchronous family-tree DFS fallback blocks in `ui_family_data.js` and `ui_family_render.js`; both now use `TarinaiFamilyGraph.connectedComponents()`. +- Simplified the remaining manual ant proximity fallback in `tarinai_needs_items.js` to `world.nearbyAnts()`. +- Verification: all `js/*.js` and `service-worker.js` pass `node --check`. + +## 15.25.0 save/load UI and pin UI fix +- Fixed `Item.draw()` crash caused by an undefined `isOshibyo` local in the pin drawing branch. +- Reworked the save dialog into a 3x3 slot grid and added per-slot canvas thumbnails. +- Added compressed `TARINAI_SAVE_V2:` hash export/import using browser gzip streams when available; legacy `TARINAI_SAVE_V1:` imports remain supported. +- Replaced browser confirm prompts in save/load/import/delete flows with in-game save dialog confirmation UI. +- Added missing tool UI icons for `pushpin` and `oshibyo` and updated their tooltips. +- Renamed tool categories: 操作, 飲食, 薬, 生活, 危険物. Moved freeze to 操作 and fences to 生活. +- Made the frozen individual panel initially collapsed. +- Made colony mood span the full colony UI width with a compact height. +- Moved push notification cards inside the top of the Event UI. +- Bumped static cache version to `15.25.0`. + +## v15.26.0 save/sunbath/signboard/freeze UI patch +- ずんちどれいの食対象を、ずんだ餅・草・水系を除く食べ物全般へ拡張。 +- 看板を大型化し、観察/看板ツールでクリック編集、配置直後編集に対応。 +- 日光浴の開始処理が呼ばれていなかった問題を修正し、発生確率と継続時間を大幅に増加。 +- フィールド内の非地形オブジェクト・たりない・アリをY座標で統合ソートし、下側が前に出る描画順へ調整。 +- 冷凍ボタンを操作カテゴリから廃止し、冷凍個体一覧を操作カテゴリ内部へ移動。 +- セーブスロットを小型化し、サムネイルをスロット全面背景として表示。 +- ハッシュ書き出しをV3コンパクト形式へ変更。イベントログ、エフェクト、吹き出し等の非本体情報は保存対象外。 +- α版のため、セーブ/ハッシュ互換性は当面保証しない旨をコードコメントに明記。 + +## v15.27.0 hash V4 / layer / warning patch +- ハッシュ書き出しを `TARINAI_SAVE_V4:` に更新。V3の密な配列形式から、短い疎配列形式へ変更。 +- V4ではゲームプレイ本体に必要な world / tarinai / item / ant / frozen 状態のみを保存し、ログ、イベント表示、エフェクト、吹き出し、サムネイル等は保存対象外。 +- アイテム種別・スプライト種別は固定辞書の数値コードで保存し、初期状態で全アイテムを1つずつ置いたケースでも短くなるようにした。 +- アイテムは `type,x,y` をヘッダ化し、その他はデフォルト値から変化した値だけを保存する方式へ変更。 +- 干草寝床の描画レイヤーを同じY座標の他オブジェクトより少し後ろへ調整。 +- ロード/インポート/削除の上書き警告を中央の大きなゲーム内UIとして表示。 +- キャッシュ対策としてバージョンを `15.27.0` へ更新。 + +## v15.28.0 machine / pin placement patch +- おしり鋲を約半分の表示サイズに縮小し、刺さり位置をたりないスプライト右側へ移動。 +- 画鋲/おしり鋲の刺さり表示は、たりないのスプライト反転に合わせて左右反転するよう調整。 +- 新アイテム「複製機」を追加。消費アイテムツールで複製機をクリックするとスロットにセットし、下側の皿へ無限補充する。 +- 新アイテム「大砲」を追加。消費アイテムツールで大砲をクリックするとスロットにセットし、クリック位置で360度向きを変更し、セット品を撃ち出す。 +- 大砲弾として飛んだ消費アイテムは、高速時にたりないへ当たるとボール同様に吹き飛ばす。 +- 複製機/大砲の道具UIアイコンを追加。 +- セーブV4のアイテム種別表・追加キーへ複製機/大砲および機械スロット情報を追加。 +- キャッシュ対策としてバージョンを `15.28.0` へ更新。 + +## v15.29.0 asset / save compression patch +- 全たりないスプライトを 512x468 以下に収まるよう縮小し、WebP を再圧縮。 +- `ALPHA_BOUNDS` を現行画像寸法と透過領域から再生成。 +- 音声サンプルを軽量 MP3 に置き換え、WAV を配布対象から除外。 +- セーブハッシュ内の座標系数値は小数点第一位までに丸めるよう変更。 +- コメントは削除せず、JS minify は行っていない。 +- キャッシュ対策としてバージョンを `15.29.0` へ更新。 + +## v15.30.0 save hash v5 compact patch +- ハッシュ書き出しを `TARINAI_SAVE_V5:` に更新。 +- V5はトップレベルを短い配列形式にし、world / たりない / item / ant / 冷凍個体をゲームプレイに必要な値だけで再パック。 +- たりないの性格・遺伝・関係性・効果タイマー・状態/欲求/アイテム種別を短い配列または数値コード化。 +- 初期値・決定論的に再生成できる値・UI/演出専用値はハッシュ保存対象から除外。 +- 座標は引き続き小数点第一位まで保存。 +- V4/V3/V2/V1の読み込み互換は一応残すが、α版のため今後の互換性保証はしない。 +- キャッシュ対策としてバージョンを `15.30.0` へ更新。 + +## 15.31.0 - Hash V6 compaction + +- Added `TARINAI_SAVE_V6:` export format. +- Further shortened hash text while preserving gameplay state. +- Removed individual observation history/name fields from hash exports: + - names + - former names + - parent display names + - personality daily history + - mystery drug history + - recent change records + - per-individual observation records +- Replaced long family keys in hash payload with compact numeric family references. +- Encoded live IDs like `t12` as numeric IDs in hash payload and restored them on load. +- Packed tarinai core gameplay stats into fixed compact rows. +- Kept gameplay-critical data: + - needs/status + - position and movement-relevant values + - personality and genetics + - parent/child links + - relationships + - diseases + - zunchi slave state + - item effects + - oshibyo/zunchi stock + - sunbath/fear/fall gameplay timers + - items, machines, ants, frozen tarinai +- Flattened item base records remains active from V6 export. +- Hash remains ASCII base64url + gzip for copy/paste and editor durability. + +## 15.32.0 - Hash V7 base91/name preservation + +- Added `TARINAI_SAVE_V7:` hash export. +- V7 uses gzip plus a 91-character printable ASCII transport alphabet instead of base64url. +- V7 keeps individual names in compact hash exports. +- V7 excludes dead tarinai rows from compact hash exports so family graph history does not revive/save dead individuals. +- V7 filters parent/child and relationship links to living or frozen individuals; pending frozen death notices remain gameplay data. +- V7 stores selected numeric tarinai stats as relative deltas from common defaults while preserving the existing compact numeric precision. +- V1-V6 import support remains. + +## 15.33.0 - Hash V8 current-only compact save +- Hash exports now use `TARINAI_SAVE_V8:` only. Old hash import compatibility was intentionally removed for the alpha save format. +- Added an untouched-initial-colony sentinel. Initial colonies now export as a compact reset marker plus names instead of full entity/item rows, targeting roughly 100 characters for the initial state depending on generated names. +- Frozen individual save data no longer preserves pending death notices or relationship/memory records. +- Dead family nodes remain omitted from compact hash saves. +- Individual names are preserved in V8 compact saves. +- V8 keeps printable ASCII transport using the 91-character alphabet, with an explicit compressed/uncompressed mode marker. -## Personality cleanup v3 -- Current live friend count now changes sociability drift. Family/friend proximity raises sociability more when the tarinai has more current friends, while a lonely tarinai with no current friends can slowly lose sociability. -- Removed the legacy named personality route from constructor, serialization, child inheritance, selected data UI, selected-card overlay, and family-tree titles. The game now uses only birthPersonality/currentPersonality four-axis data. -- Kept empty legacy stubs only for old-save compatibility so older saves cannot revive the pre-four-axis personality model. +## 15.34.0 - Hash V9 binary compact format -## Follow-up fixes v4 -- Restored compact personality information in the tarinai data panel: visible tags, birth personality, and current personality. -- Moved the focused-tarinai highlight outside the sprite rotation transform so the ring and label stay upright. -- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress. -- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights. -- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies. +- Removed the V8 initial-state-only save sentinel. All states now use the same compact format. +- Export hash format is now `TARINAI_SAVE_V9:` and legacy hash imports are rejected during alpha. +- Replaced JSON-based compact hashes with a dedicated binary packet plus printable ASCII Base94 transport. +- Stopped saving elapsed day count in compact hash; loaded compact hashes restore day as 1. +- Kept custom individual names, while default four-character names made only from `は / う / ぅ / っ` are packed as 2-bit character codes. +- Replaced saved tarinai/item IDs and family keys with array-index references; IDs are regenerated on load. +- Uses default-relative numeric deltas, optional timer tags, boolean bit fields, and 0.1-unit integer coordinates. +- Keeps gameplay-critical tarinai needs, personality, genetics, family links, relationships, item states, machines, ants, and frozen individuals; frozen pending-loss memory remains omitted. -## Ant nest update -- Added a new placeable `ant_nest` object and tool button. -- Added ant sprites cropped from the provided source image: queen, worker variants, and upside-down worker corpse. -- Each ant nest starts with 6 workers, caps at 10 workers, and sends at most 3 workers outside at once. -- Worker ants spawn above their home nest, random-walk, search for tarinai, and drag found tarinai back to the nest. Multiple workers dragging the same tarinai increase carrying speed. -- Dragged tarinai struggle automatically and can kill workers. Dead workers become edible `ant_corpse` items and slightly reduce hygiene. -- If a nest below capacity brings back a tarinai, its worker count increases by 1. -- If a full nest brings back another tarinai, a queen ant appears and creates a new ant nest in an open spot. -- Ant health does not recover. -- Added the requested Irasutoya credit entry. - -## Ant nest follow-up v5 -- Increased ant movement and hauling constants by 1.5x from the previous slow tuning. -- Kept queen spawning to at most one queen per nest per in-game day, while making spawned queens keep retrying until a new ant nest is built somewhere. -- Reduced the ant nest object radius from 34 to 17. -- Removed the separate ant corpse sprite asset. Ant corpses now render by flipping the worker ant sprite vertically and remain stationary while decaying. -- Added shared ant damage handling for firecracker explosions and dropped-object impacts. -- Kept ant worker HP in the nest worker pool so damaged workers remain damaged after returning to and leaving the nest. +## 15.35.0 - Hash V10 compact packet +- Changed exported hash prefix to `TARINAI-SAVE-V10:` so the full hash text no longer uses `_` in its version marker. +- Excluded `\\`, `*`, `'`, and `_` from the printable ASCII transport alphabet. +- Replaced the V9 generic array/value packet with a V10 field-specific binary packet for common gameplay data. +- Kept gameplay-critical state while reducing overhead for names, world data, tarinai core rows, item rows, ants, and frozen individuals. +- Kept elapsed day count out of the hash; loads restore to day 1. diff --git a/README.md b/README.md index 69725d1..7a87766 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,51 @@ # Tarinai Colony Observation Game -Single-page, dependency-free browser simulation. Open `index.html`; no build step, package manager, server API, transpiler, or module loader is required. Runtime is plain HTML/CSS/JS loaded by ordered ` - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index afbc83f..fbe5b36 100644 --- a/js/ants.js +++ b/js/ants.js @@ -3,15 +3,24 @@ const ANT_NEST_START_COUNT = 6; const ANT_NEST_MAX_COUNT = 10; const ANT_NEST_MAX_OUTSIDE = 3; -const ANT_WORKER_HP = 32; -const ANT_QUEEN_HP = 180; +const ANT_WORKER_HP = 48; +const ANT_QUEEN_HP = 270; const ANT_WORKER_RADIUS = 4.2; const ANT_QUEEN_RADIUS = 9.5; -const ANT_WORKER_SEARCH_SPEED = 8.625; -const ANT_WORKER_RETURN_SPEED = 12.0; -const ANT_WORKER_RETURN_WEAK_SPEED = 6.0; -const ANT_WORKER_DRAG_SPEED = 4.125; -const ANT_QUEEN_SPEED = 9.0; +const ANT_WORKER_SEARCH_SPEED = 12.9375; +const ANT_WORKER_RETURN_SPEED = 18.0; +const ANT_WORKER_RETURN_WEAK_SPEED = 9.0; +const ANT_WORKER_DRAG_SPEED = 6.1875; +const ANT_QUEEN_SPEED = 13.5; + +const ANT_EXTENSION_HOOKS = Object.freeze({ + nestHunger: "reserved", + nestGrowth: "reserved", + queenReproduction: "existing-queen-founding", + workerLifecycle: "worker-pool-hp", + carryingSuccessRate: "current-deterministic-haul", + territorialBehavior: "reserved", +}); function clampAntHp(v) { return clamp(Number(v) || 0, 0, ANT_WORKER_HP); @@ -30,6 +39,8 @@ function sanitizeAntWorkerPool(list, fallbackCount = 0) { function ensureAntNestWorkerPool(nest) { if (!nest || nest.type !== "ant_nest") return []; + // Extension hook: future nest hunger/growth state should attach to the nest + // item here without changing current worker count balance. if (!Array.isArray(nest.antWorkers)) { nest.antWorkers = buildAntWorkerPool(nest.antCount ?? ANT_NEST_START_COUNT); } else { @@ -43,6 +54,11 @@ function syncAntNestCount(worldRef, nest) { ensureAntNestWorkerPool(nest); const outside = (worldRef?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; nest.antCount = clamp((nest.antWorkers?.length || 0) + outside, 0, ANT_NEST_MAX_COUNT); + if (nest.antCount <= 0) { + nest.antWorkers = []; + nest.amount = 0; + if (worldRef) worldRef.drawListDirty = true; + } return nest.antCount; } @@ -81,6 +97,50 @@ class AntActor { return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null; } + adoptableNest() { + let best = null; + let bestScore = Infinity; + for (const nest of this.world?.items || []) { + if (!nest || nest.dead || nest.type !== "ant_nest") continue; + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); + const outside = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; + const inside = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); + const count = inside + outside; + if (count >= (ANT_NEST_MAX_COUNT || 10)) continue; + const d = distXY(this.x, this.y, nest.x, nest.y); + const score = d + count * 26; + if (score < bestScore) { best = nest; bestScore = score; } + } + return best; + } + + updateWanderNoNest(dt) { + const adopt = this.adoptableNest(); + if (adopt) { + this.homeId = adopt.id; + this.state = "return"; + this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; + this.world.drawListDirty = true; + return; + } + this.targetId = ""; + this.targetToken = 0; + this.targetRef = null; + this.state = "wander"; + this.nextTurn -= dt; + if (this.nextTurn <= 0) { + this.wanderAngle += rand(-1.35, 1.35); + this.nextTurn = rand(0.55, 1.45); + } + const speed = (ANT_WORKER_SEARCH_SPEED || 10) * clamp((this.hp || this.maxHp || ANT_WORKER_HP) / Math.max(1, this.maxHp || ANT_WORKER_HP), 0.45, 1.0) * 0.86; + this.vx = Math.cos(this.wanderAngle) * speed; + this.vy = Math.sin(this.wanderAngle) * speed * 0.78; + this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + } + targetTarinai() { if (this.targetRef && !this.targetRef.dead && this.targetRef.id === this.targetId && this.targetRef.liveToken === this.targetToken) return this.targetRef; const live = this.world?.liveTarinaiById?.(this.targetId, this.targetToken) || null; @@ -101,6 +161,8 @@ class AntActor { } chooseTarget(maxDist = 56) { + // Extension hook: future carrying success and territorial behavior should + // filter or rank candidates here while preserving sleep-disease hauling. let best = null; let bestScore = Infinity; for (const t of this.world?.tarinai || []) { @@ -134,7 +196,7 @@ class AntActor { updateWorker(dt) { const home = this.homeNest(); if (!home) { - this.dead = true; + this.updateWanderNoNest(dt); return; } if (this.hp <= 0) { @@ -158,6 +220,9 @@ class AntActor { target.antDraggedBy = this.id; target.fearTimer = Math.max(target.fearTimer || 0, 0.55); target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"; + target.recordChangeCause?.("\u30a2\u30ea\u306b\u904b\u3070\u308c\u305f", "\u72b6\u614b"); + this.world?.emit?.("ant:grabbed", { ant: this, target }); + audio.antDrag?.(); this.world.drawListDirty = true; return; } @@ -180,7 +245,9 @@ class AntActor { returnToNest(home) { if (!home || home.dead || home.type !== "ant_nest") { - this.dead = true; + const adopt = this.adoptableNest(); + if (adopt) { this.homeId = adopt.id; this.state = "return"; return; } + this.updateWanderNoNest(0.016); return; } ensureAntNestWorkerPool(home).push(clampAntHp(this.hp)); @@ -213,13 +280,21 @@ class AntActor { } const haulers = Math.max(1, this.activeHaulersForTarget().length); const leadHauler = this.isLeadHauler(); + const targetWasSleeping = Boolean(target.sleeping || target.state === "sleep"); target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.35); target.antDraggedBy = this.id; - target.state = "panic"; - target.sleeping = false; + if (leadHauler && (this.world?.time || 0) - (this.lastCarryEventAt || -999) > 0.75) { + this.lastCarryEventAt = this.world?.time || 0; + this.world?.emit?.("ant:carried", { ant: this, target, lead: true, haulers }); + } target.target = null; - target.fearTimer = Math.max(target.fearTimer || 0, 0.36); - target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b"; + if (targetWasSleeping) { + target.startSleeping?.(null, "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"); + target.sleepTimer = Math.max(target.sleepTimer || 0, 0.8); + } else { + if (target.enterPanic) target.enterPanic({ threat: this, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", fear: 0.36, wake: true, cause: "ant_drag" }); + else { target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(this) : null, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", wake: true }); target.fearTimer = Math.max(target.fearTimer || 0, 0.36); } + } const dHome = distXY(target.x, target.y, home.x, home.y) || 1; if (dHome < Math.max(18, home.r * 0.66)) { this.world.completeAntHaul?.(home, target); @@ -230,7 +305,8 @@ class AntActor { return; } const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0); - const speed = ANT_WORKER_DRAG_SPEED * hpFactor; + const haulerBoost = 1 + Math.min(0.18, Math.max(0, haulers - 1) * 0.045); + const speed = ANT_WORKER_DRAG_SPEED * hpFactor * haulerBoost; const ux = (home.x - target.x) / dHome; const uy = (home.y - target.y) / dHome; if (leadHauler) { @@ -246,10 +322,11 @@ class AntActor { this.vy = uy * speed; const aggression = Math.max(0, target.currentPersonality?.aggression || 0); - const aggressiveAttack = target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"; - const struggle = aggressiveAttack + const stillSleeping = Boolean(target.sleeping || targetWasSleeping || target.state === "sleep"); + const aggressiveAttack = !stillSleeping && (target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack"); + const struggle = stillSleeping ? 0 : (aggressiveAttack ? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9) - : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers); + : (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers)); this.hp -= dt * struggle; if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) { @@ -262,6 +339,8 @@ class AntActor { dieAsCorpse(home = null) { if (this.dead) return; this.dead = true; + audio.antDie?.(); + this.world?.emit?.("ant:died", { ant: this, home, reason: "hp" }); const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; corpse.workerSprite = this.workerSpriteId(); @@ -274,6 +353,8 @@ class AntActor { buildNestAtFoundingSpot(force = false) { if (!this.world) return null; + // Extension hook: queen reproduction currently means founding a new nest. + // Future growth rules should branch here without changing current behavior. const base = new Item("ant_nest", this.foundingX, this.foundingY); let spot = null; if (this.world.findPlacementSpot) { @@ -288,9 +369,12 @@ class AntActor { base.antCount = ANT_NEST_START_COUNT; base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT); if (!force && this.world.placementBlocked?.(base)) return null; + audio.antNest?.(); this.world.items.push(base); this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1; - this.world.rebuildSpatial?.(); + this.world.markTerrainDirty?.("ant-nest-built"); + this.world.emit?.("ant:nest-built", { nest: base, queen: this }); + this.world.rebuildSpatial?.(true); this.world.drawListDirty = true; this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event"); return base; @@ -347,7 +431,7 @@ class AntActor { const img = typeof getRenderableImage === "function" ? getRenderableImage(id, id) : images.get(id); const lightState = lighting || getLightingState(this.world); ctx.save(); - const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.58 : 0.26); + const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.78 : 0.26); drawProjectedShadow(ctx, this.x, this.y + this.r * 0.62, this.r * 1.5, this.r * 0.30, { ...shadow, alpha: shadow.alpha * 0.72 }); ctx.translate(this.x, this.y); const angle = this.kind === "queen" @@ -355,7 +439,7 @@ class AntActor { : Math.atan2(this.vy || 0, this.vx || 1); ctx.rotate(angle); if (img) { - const w = this.kind === "queen" ? this.r * 4.1 : this.r * 3.8; + const w = this.kind === "queen" ? this.r * 5.25 : this.r * 3.8; const metrics = getImageMetrics(id); const h = w * (metrics?.ratio || 0.52); ctx.globalAlpha = 0.96; @@ -401,6 +485,7 @@ window.ANT_NEST_MAX_COUNT = ANT_NEST_MAX_COUNT; window.ANT_NEST_MAX_OUTSIDE = ANT_NEST_MAX_OUTSIDE; window.ANT_WORKER_HP = ANT_WORKER_HP; window.ANT_QUEEN_HP = ANT_QUEEN_HP; +window.ANT_EXTENSION_HOOKS = ANT_EXTENSION_HOOKS; window.buildAntWorkerPool = buildAntWorkerPool; window.ensureAntNestWorkerPool = ensureAntNestWorkerPool; window.syncAntNestCount = syncAntNestCount; diff --git a/js/assets.js b/js/assets.js index efd8b2b..50b7510 100644 --- a/js/assets.js +++ b/js/assets.js @@ -7,11 +7,13 @@ const imageAssetIndex = new Map(); const scaledSpriteCache = new Map(); const stainOverlayCache = new Map(); +const SPRITE_CACHE_LIMIT = 128; +const STAIN_CACHE_LIMIT = 64; -const INITIAL_IMAGE_IDS = new Set(["smile", "zunchi", "zunchi_02", "genkotsu"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "zunchi", "zunchi_02", "genkotsu"]); +const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); -function trimCanvasCache(cache, limit = 420) { +function trimCanvasCache(cache, limit = 128) { while (cache.size > limit) { const first = cache.keys().next().value; if (first === undefined) break; @@ -19,17 +21,24 @@ function trimCanvasCache(cache, limit = 420) { } } -function quantizedCanvasSize(v, step = 16) { +function quantizedCanvasSize(v, step = 24) { return Math.max(1, Math.round(v / step) * step); } -const SPRITE_CACHE_SCALE = 2.0; +const SPRITE_CACHE_SCALE = 1.55; function getCachedSpriteCanvas(id, img, w, h) { if (!img || !w || !h) return img; - const qw = quantizedCanvasSize(w, 16); - const qh = quantizedCanvasSize(h, 16); + const qw = quantizedCanvasSize(w); + const qh = quantizedCanvasSize(h); const key = `${id}:${qw}x${qh}`; + if (window.TarinaiPhysics?.createCachedCanvas) { + return window.TarinaiPhysics.createCachedCanvas(scaledSpriteCache, key, qw, qh, (c) => { + if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; + c.clearRect(0, 0, qw, qh); + c.drawImage(img, 0, 0, qw, qh); + }, { limit: SPRITE_CACHE_LIMIT }); + } let canvas = scaledSpriteCache.get(key); if (canvas) return canvas; canvas = document.createElement("canvas"); @@ -41,27 +50,21 @@ function getCachedSpriteCanvas(id, img, w, h) { c.clearRect(0, 0, qw, qh); c.drawImage(img, 0, 0, qw, qh); scaledSpriteCache.set(key, canvas); - trimCanvasCache(scaledSpriteCache, 520); + trimCanvasCache(scaledSpriteCache, SPRITE_CACHE_LIMIT); return canvas; } function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { if (!img || stain <= 0.01 || !w || !h) return null; - const qw = quantizedCanvasSize(w, 16); - const qh = quantizedCanvasSize(h, 16); + const qw = quantizedCanvasSize(w); + const qh = quantizedCanvasSize(h); const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8))); const seedBucket = Math.round((seed || 0) * 16); const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`; - let canvas = stainOverlayCache.get(key); - if (canvas) return canvas; - canvas = document.createElement("canvas"); - canvas.width = qw; - canvas.height = qh; - const c = canvas.getContext("2d"); - c.imageSmoothingEnabled = true; - if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; - c.clearRect(0, 0, qw, qh); - c.drawImage(img, 0, 0, qw, qh); + const buildOverlay = (c) => { + if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; + c.clearRect(0, 0, qw, qh); + c.drawImage(img, 0, 0, qw, qh); c.globalCompositeOperation = "source-in"; c.globalAlpha = 0.34 + bucket * 0.075; c.fillStyle = "rgba(28, 92, 22, 0.96)"; @@ -79,8 +82,18 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { } c.globalAlpha = 1; c.globalCompositeOperation = "source-over"; + }; + if (window.TarinaiPhysics?.createCachedCanvas) return window.TarinaiPhysics.createCachedCanvas(stainOverlayCache, key, qw, qh, buildOverlay, { limit: STAIN_CACHE_LIMIT }); + let canvas = stainOverlayCache.get(key); + if (canvas) return canvas; + canvas = document.createElement("canvas"); + canvas.width = qw; + canvas.height = qh; + const c = canvas.getContext("2d"); + c.imageSmoothingEnabled = true; + buildOverlay(c); stainOverlayCache.set(key, canvas); - trimCanvasCache(stainOverlayCache, 360); + trimCanvasCache(stainOverlayCache, STAIN_CACHE_LIMIT); return canvas; } @@ -138,7 +151,7 @@ function loadImageAsset(asset, priority = "auto") { img.onerror = () => { resolve(null); }; }); imagePromises.set(asset.id, promise); - if (!img.src) img.src = asset.path; + if (!img.src) img.src = (window.TarinaiVersion?.withStaticVersion ? window.TarinaiVersion.withStaticVersion(asset.path) : asset.path); return promise; } @@ -175,9 +188,23 @@ function loadImages(ids = null, onProgress = null) { return Promise.all(tasks).then(() => undefined); } +function trimImageMemory(aggressive = false) { + trimCanvasCache(scaledSpriteCache, aggressive ? 48 : SPRITE_CACHE_LIMIT); + trimCanvasCache(stainOverlayCache, aggressive ? 16 : STAIN_CACHE_LIMIT); +} + +if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.hidden) trimImageMemory(true); + }); +} + +if (typeof window !== "undefined") { + window.setInterval(() => trimImageMemory(false), 45000); +} + function startBackgroundImageLoading() { setupAssetIndex(); - const allAssets = [...SPRITES, ...DECOR_ASSETS]; const loadGroup = (ids, priority = "low") => { for (const id of ids) { const asset = imageAssetIndex.get(id); @@ -189,7 +216,8 @@ function startBackgroundImageLoading() { : (fn) => setTimeout(fn, 60); schedule(() => loadGroup(EARLY_IMAGE_IDS, "auto"), 450); schedule(() => { - for (const asset of allAssets) loadImageAsset(asset, "low"); + for (const asset of [...SPRITES, ...DECOR_ASSETS]) loadImageAsset(asset, "low"); + trimImageMemory(false); }, 2200); } @@ -224,4 +252,4 @@ function getImageMetrics(idOrImg) { setupAssetIndex(); -window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, imagePromises, scaledSpriteCache, stainOverlayCache, loadImages, startBackgroundImageLoading, ensureImage, isImageReady, getRenderableImage, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas }; +window.TarinaiAssets = { images, imageBoundsCache, imageMetrics, imagePromises, scaledSpriteCache, stainOverlayCache, loadImages, startBackgroundImageLoading, trimImageMemory, ensureImage, isImageReady, getRenderableImage, imageAlphaBounds, getImageMetrics, getCachedSpriteCanvas, getCachedStainOverlayCanvas }; diff --git a/js/audio.js b/js/audio.js index 24b20cc..6abcfbc 100644 --- a/js/audio.js +++ b/js/audio.js @@ -1,88 +1,342 @@ "use strict"; + +// Sound IDs are intentionally stable. To replace a synthesized placeholder with a file, +// add a path to samplePaths using the same ID, for example: +// sfx_genkotsu_impact: "assets/sounds/sfx_genkotsu_impact.mp3" const audio = { - enabled: false, + enabled: true, ctx: null, samples: {}, sampleLast: {}, - samplePaths: { - hau: "assets/sounds/voice_001_hau.wav", - flee: "assets/sounds/voice_002_flee.wav", - poop: "assets/sounds/voice_003_po.wav", - eat: "assets/sounds/voice_004_eat.wav", - stress: "assets/sounds/voice_005_stress.wav", - sleep: "assets/sounds/voice_006_sleep.wav", - explosion: "assets/sounds/firecracker_explosion.mp3", + activeVoices: 0, + maxConcurrent: 96, + failedSamples: {}, + lastPlayedId: "", + lastPlayedAt: 0, + soundPackVersion: "inline", + masterGainNode: null, + compressorNode: null, + _synthIdGain: 1, + idGain: {}, + idMinGap: {}, + categoryEnabled: { voice: false, notify: true, ops: true }, + categoryGain: {}, + samplePaths: {}, + soundCategories: {}, + soundMap: {}, + applySoundPack(pack = window.TARINAI_SOUND_PACK) { + if (!pack || typeof pack !== "object") return false; + this.soundPackVersion = String(pack.version || "custom"); + if (pack.categoryGain && typeof pack.categoryGain === "object") this.categoryGain = { ...this.categoryGain, ...pack.categoryGain }; + if (pack.idGain && typeof pack.idGain === "object") this.idGain = { ...this.idGain, ...pack.idGain }; + if (pack.idMinGap && typeof pack.idMinGap === "object") this.idMinGap = { ...this.idMinGap, ...pack.idMinGap }; + if (pack.samplePaths && typeof pack.samplePaths === "object") this.samplePaths = { ...this.samplePaths, ...pack.samplePaths }; + if (pack.categories && typeof pack.categories === "object") this.soundCategories = { ...this.soundCategories, ...pack.categories }; + if (pack.soundMap && typeof pack.soundMap === "object") this.soundMap = { ...this.soundMap, ...pack.soundMap }; + this._samplesReady = false; + this.samples = {}; + return true; }, ensure() { const AudioClass = window.AudioContext || window.webkitAudioContext; if (!AudioClass) return null; if (!this.ctx) this.ctx = new AudioClass(); - if (this.ctx.state === "suspended") this.ctx.resume(); + if (!this.masterGainNode) { + const ctx = this.ctx; + this.masterGainNode = ctx.createGain(); + this.masterGainNode.gain.setValueAtTime(1.85, ctx.currentTime); + if (typeof ctx.createDynamicsCompressor === "function") { + this.compressorNode = ctx.createDynamicsCompressor(); + this.compressorNode.threshold.value = -18; + this.compressorNode.knee.value = 24; + this.compressorNode.ratio.value = 8; + this.compressorNode.attack.value = 0.002; + this.compressorNode.release.value = 0.18; + this.masterGainNode.connect(this.compressorNode).connect(ctx.destination); + } else { + this.masterGainNode.connect(ctx.destination); + } + } + if (this.ctx.state === "suspended") this.ctx.resume().catch?.(() => {}); this.initSamples(); return this.ctx; }, + outputNode() { + this.ensure(); + return this.masterGainNode || this.ctx?.destination; + }, + loadSettings() { + try { + const raw = localStorage.getItem("tarinai_audio_settings_v2"); + if (!raw) return; + const parsed = JSON.parse(raw); + if (typeof parsed.enabled === "boolean") this.enabled = parsed.enabled; + if (parsed.categories && typeof parsed.categories === "object") { + for (const key of Object.keys(this.categoryEnabled)) { + if (typeof parsed.categories[key] === "boolean") this.categoryEnabled[key] = parsed.categories[key]; + } + } + } catch (_) {} + }, + saveSettings() { + try { + localStorage.setItem("tarinai_audio_settings_v2", JSON.stringify({ + enabled: Boolean(this.enabled), + categories: { ...this.categoryEnabled }, + })); + } catch (_) {} + }, initSamples() { if (this._samplesReady) return; this._samplesReady = true; for (const [key, path] of Object.entries(this.samplePaths)) { + if (!path) continue; const a = new Audio(path); a.preload = "auto"; - a.volume = 0.72; + a.volume = this.sampleVolumeFor(key); + a.addEventListener("error", () => { + this.failedSamples[key] = path; + window.TarinaiEvents?.emit("audio:sample-error", { id: key, path }); + }, { once: true }); this.samples[key] = a; } }, - toggle() { - this.enabled = !this.enabled; + categoryFor(id, fallback = "ops") { + return this.soundMap[id] || fallback; + }, + categoryOn(category = "ops") { + return Boolean(this.categoryEnabled[category] ?? true); + }, + gainFor(category = "ops", base = 1) { + return Math.max(0.1, Number(base) || 1) * (this.categoryGain?.[category] ?? 1); + }, + sampleVolumeFor(key = "") { + const category = this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops"); + const base = category === "voice" ? 0.95 : 1.0; + return Math.min(1, base * Math.max(1, (this.categoryGain?.[category] ?? 1) * 0.35) * (this.idGain?.[key] ?? 1)); + }, + distanceGain(opts = {}) { + if (!opts || opts.category === "notify" || !Number.isFinite(opts.x) || !Number.isFinite(opts.y)) return 1; + const w = window.world; + if (!w || !w.worldToScreen) return 1; + const p = w.worldToScreen(opts.x, opts.y); + const margin = 140; + const vw = w.viewportW || w.w || 1000; + const vh = w.viewportH || w.h || 720; + if (p.x < -margin || p.y < -margin || p.x > vw + margin || p.y > vh + margin) return 0; + const cx = vw / 2, cy = vh / 2; + const d = Math.hypot(p.x - cx, p.y - cy); + return clamp(1 - d / Math.max(vw, vh) * 0.65, 0.35, 1); + }, + beginVoice(dur = 0.1) { + this.activeVoices = (this.activeVoices || 0) + 1; + setTimeout(() => { this.activeVoices = Math.max(0, (this.activeVoices || 0) - 1); }, Math.max(60, dur * 1000 + 80)); + }, + setCategory(category, on) { + if (!(category in this.categoryEnabled)) return; + this.categoryEnabled[category] = Boolean(on); + this.saveSettings(); + }, + toggleCategory(category) { + if (!(category in this.categoryEnabled)) return false; + this.setCategory(category, !this.categoryEnabled[category]); + return this.categoryEnabled[category]; + }, + setEnabled(on) { + this.enabled = Boolean(on); if (this.enabled) this.ensure(); + this.saveSettings(); return this.enabled; }, - playSample(key, minGap = 0.16) { + toggle() { + return this.setEnabled(!this.enabled); + }, + canPlay(id, category = null, minGap = 0.04) { if (!this.enabled) return false; - this.ensure(); + const cat = category || this.categoryFor(id); + if (!this.categoryOn(cat)) return false; + if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && cat !== "notify") return false; + const gap = Math.max(Number(minGap) || 0, this.idMinGap?.[id] ?? 0); + const t = performance.now() / 1000; + if (t - (this.sampleLast[id] || 0) < gap) return false; + this.sampleLast[id] = t; + return true; + }, + playSample(key, minGap = 0.16, category = null) { const base = this.samples[key]; if (!base) return false; - const t = performance.now() / 1000; - if (t - (this.sampleLast[key] || 0) < minGap) return true; - this.sampleLast[key] = t; + const cat = category || this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops"); + if (!this.canPlay(key, cat, minGap)) return false; + this.lastPlayedId = key; + this.lastPlayedAt = performance.now() / 1000; + window.TarinaiEvents?.emit("audio:play", { id: key, category: cat, synthetic: false }); + this.ensure(); const node = base.cloneNode(true); - node.volume = base.volume; + node.volume = Math.min(1, base.volume * (this.categoryGain?.[cat] ?? 1) * (this.idGain?.[key] ?? 1)); + this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16)); node.play().catch(() => {}); return true; }, - tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0) { - if (!this.enabled) return; + tone(freq, dur = 0.08, type = "sine", gain = 0.035, slide = 0, category = "ops") { + if (!this.enabled || !this.categoryOn(category)) return; const ctx = this.ensure(); if (!ctx) return; + if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return; + this.beginVoice(dur); + gain = Math.min(0.95, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1))); const t0 = ctx.currentTime; const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, t0); - if (slide) osc.frequency.linearRampToValueAtTime(Math.max(40, freq + slide), t0 + dur); + if (slide) osc.frequency.linearRampToValueAtTime(Math.max(36, freq + slide), t0 + dur); g.gain.setValueAtTime(0.0001, t0); - g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01); + g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), t0 + Math.min(0.018, dur * 0.35)); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); - osc.connect(g).connect(ctx.destination); + osc.connect(g).connect(this.outputNode() || ctx.destination); osc.start(t0); - osc.stop(t0 + dur + 0.02); + osc.stop(t0 + dur + 0.03); + }, + noise(dur = 0.08, gain = 0.030, category = "ops", filterFreq = 900) { + if (!this.enabled || !this.categoryOn(category)) return; + const ctx = this.ensure(); + if (!ctx) return; + if ((this.activeVoices || 0) >= (this.maxConcurrent || 16) && category !== "notify") return; + this.beginVoice(dur); + gain = Math.min(0.90, Math.max(0.0002, gain * (this.categoryGain?.[category] ?? 1) * (this._synthIdGain || 1))); + const length = Math.max(1, Math.floor(ctx.sampleRate * dur)); + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 1.5); + const src = ctx.createBufferSource(); + const filter = ctx.createBiquadFilter(); + const g = ctx.createGain(); + filter.type = "lowpass"; + filter.frequency.value = filterFreq; + g.gain.setValueAtTime(gain, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur); + src.buffer = buffer; + src.connect(filter).connect(g).connect(this.outputNode() || ctx.destination); + src.start(); + }, + play(id, opts = {}) { + const category = opts.category || this.categoryFor(id, "ops"); + const minGap = opts.minGap ?? 0.10; + if (!this.canPlay(id, category, minGap)) return false; + this.lastPlayedId = id; + this.lastPlayedAt = performance.now() / 1000; + window.TarinaiEvents?.emit("audio:play", { id, category, synthetic: !this.samples[id] }); + if (this.samples[id]) { + const base = this.samples[id]; + this.ensure(); + const node = base.cloneNode(true); + node.volume = Math.min(1, base.volume * (this.categoryGain?.[category] ?? 1) * (this.idGain?.[id] ?? 1)); + this.beginVoice(Math.max(0.08, Number(node.duration) || 0.16)); + node.play().catch(() => {}); + return true; + } + this.synth(id, category); + return true; + }, + synth(id, category) { + const prevSynthGain = this._synthIdGain || 1; + this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1); + const t = (ms, fn) => setTimeout(() => { + const prev = this._synthIdGain || 1; + this._synthIdGain = Math.max(0.25, this.idGain?.[id] ?? 1); + try { fn(); } finally { this._synthIdGain = prev; } + }, ms); + try { + switch (id) { + case "sfx_notify": case "sfx_log_push": + this.tone(880, 0.055, "sine", 0.022, 80, category); t(62, () => this.tone(1180, 0.075, "triangle", 0.020, -40, category)); break; + case "sfx_ui_click": this.tone(520, 0.035, "square", 0.010, 80, category); break; + case "sfx_ui_fold": this.tone(420, 0.045, "triangle", 0.012, -130, category); break; + case "sfx_place": this.tone(360, 0.055, "triangle", 0.020, -80, category); break; + case "sfx_delete": this.noise(0.06, 0.018, category, 1600); this.tone(740, 0.045, "sine", 0.012, -240, category); break; + case "sfx_poke": this.tone(180, 0.085, "sawtooth", 0.023, -70, category); break; + case "sfx_grab": this.tone(300, 0.050, "triangle", 0.018, 90, category); break; + case "sfx_drop": this.tone(220, 0.060, "triangle", 0.018, -80, category); break; + case "sfx_water_hose": this.noise(0.13, 0.018, category, 2400); break; + case "sfx_water_clean": this.noise(0.08, 0.014, category, 3200); this.tone(1120, 0.040, "sine", 0.009, 180, category); break; + case "sfx_drink": this.tone(720, 0.045, "sine", 0.012, -120, category); t(42, () => this.tone(620, 0.040, "sine", 0.010, -80, category)); break; + case "sfx_ball_hit": this.tone(260, 0.065, "triangle", 0.020, 120, category); break; + case "sfx_stone_impact": this.tone(110, 0.080, "sawtooth", 0.032, -50, category); this.noise(0.05, 0.018, category, 600); break; + case "sfx_firecracker_fuse": this.noise(0.18, 0.018, category, 5200); break; + case "sfx_firecracker_explode": this.tone(90, 0.120, "sawtooth", 0.060, -30, category); t(42, () => this.tone(55, 0.200, "square", 0.040, -20, category)); this.noise(0.16, 0.032, category, 900); break; + case "sfx_genkotsu_fall": this.tone(760, 0.20, "sawtooth", 0.018, -500, category); this.noise(0.16, 0.018, category, 1800); break; + case "sfx_genkotsu_impact": this.tone(64, 0.20, "square", 0.070, -18, category); this.noise(0.18, 0.040, category, 720); break; + case "sfx_ground_rumble": this.tone(46, 0.34, "sawtooth", 0.032, -8, category); t(120, () => this.tone(39, 0.32, "triangle", 0.026, 6, category)); break; + case "sfx_grass": this.noise(0.08, 0.011, category, 3000); break; + case "sfx_zunchi": this.tone(96, 0.070, "triangle", 0.014, -24, category); this.noise(0.05, 0.010, category, 460); break; + case "sfx_ant_drag": this.noise(0.12, 0.012, category, 1800); this.tone(210, 0.040, "triangle", 0.010, -70, category); break; + case "sfx_ant_die": this.tone(150, 0.038, "square", 0.012, -55, category); break; + case "sfx_ant_nest": this.noise(0.11, 0.018, category, 780); this.tone(170, 0.060, "triangle", 0.014, -40, category); break; + case "sfx_queen_ant": this.tone(240, 0.085, "triangle", 0.025, -40, category); t(70, () => this.noise(0.08, 0.014, category, 1300)); break; + case "sfx_birth": this.tone(520, 0.08, "sine", 0.028, 100, category); t(70, () => this.tone(760, 0.10, "triangle", 0.024, 80, category)); break; + case "sfx_death": this.tone(150, 0.22, "sine", 0.028, -70, category); break; + case "sfx_damage_soft": this.tone(300, 0.055, "triangle", 0.018, -90, category); break; + case "sfx_damage_heavy": this.tone(120, 0.090, "sawtooth", 0.028, -70, category); break; + case "sfx_fight_hit": this.tone(110, 0.055, "square", 0.018, 70, category); t(35, () => this.tone(90, 0.045, "sawtooth", 0.014, -40, category)); break; + case "sfx_fight_finish": this.tone(150, 0.090, "triangle", 0.024, -60, category); t(80, () => this.tone(260, 0.070, "sine", 0.016, 60, category)); break; + case "sfx_sleep": this.tone(320, 0.12, "sine", 0.016, -90, category); break; + case "sfx_wake": this.tone(650, 0.055, "triangle", 0.017, 170, category); break; + case "sfx_disease": this.tone(210, 0.11, "sawtooth", 0.020, -130, category); this.noise(0.07, 0.012, category, 700); break; + case "sfx_heal": this.tone(720, 0.060, "sine", 0.018, 150, category); t(55, () => this.tone(960, 0.075, "triangle", 0.018, 130, category)); break; + case "sfx_phase": this.tone(440, 0.06, "sine", 0.018, 60, category); break; + default: this.tone(440, 0.06, "sine", 0.016, 0, category); + } + } finally { + this._synthIdGain = prevSynthGain; + } }, bubble(text) { const s = String(text || ""); - if (s.includes("\u3082\u3050")) return this.playSample("eat", 0.20); - if (s.includes("\u3076\u308a")) return this.playSample("poop", 0.28); - if (s.includes("\u306f\u3063")) return this.playSample("stress", 0.80); - if (s.includes("Zzz")) return this.playSample("sleep", 1.25); - if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("flee", 0.70); - if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("hau", 0.45); + if (s.includes("\u3082\u3050")) return this.playSample("sfx_eat", 0.20, "voice") || this.play("sfx_eat", { category: "voice", minGap: 0.20 }); + if (s.includes("\u3076\u308a")) return this.playSample("voice_poop", 0.28, "voice") || this.play("sfx_zunchi", { category: "ops", minGap: 0.28 }); + if (s.includes("\u306f\u3063")) return this.playSample("voice_stress", 0.80, "voice") || this.play("sfx_damage_soft", { category: "voice", minGap: 0.80 }); + if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 }); + if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 }); + if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 }); return false; }, - eat() { if (!this.playSample("eat", 0.20)) this.tone(660 + Math.random() * 140, 0.055, "triangle", 0.026, 120); }, - poke() { this.tone(180, 0.09, "sawtooth", 0.022, -70); }, - birth() { this.tone(520, 0.08, "sine", 0.028, 100); setTimeout(() => this.tone(760, 0.10, "triangle", 0.024, 80), 70); }, - death() { this.tone(150, 0.22, "sine", 0.028, -70); }, - fight() { this.tone(110, 0.055, "square", 0.018, 70); setTimeout(() => this.tone(90, 0.045, "sawtooth", 0.014, -40), 35); }, - explode() { if (!this.playSample("explosion", 0.30)) { this.tone(90, 0.12, "sawtooth", 0.06, -30); setTimeout(() => this.tone(55, 0.20, "square", 0.04, -20), 40); } }, - phase() { this.tone(440, 0.06, "sine", 0.018, 60); }, + eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); }, + poke() { this.play("sfx_poke", { category: "ops", minGap: 0.07 }); }, + birth() { this.play("sfx_birth", { category: "voice", minGap: 0.18 }); }, + death() { this.play("sfx_death", { category: "voice", minGap: 0.20 }); }, + fight() { this.play("sfx_fight_hit", { category: "voice", minGap: 0.09 }); }, + fightFinish() { this.play("sfx_fight_finish", { category: "voice", minGap: 0.40 }); }, + explode() { if (!this.playSample("sfx_firecracker_explode", 0.30, "ops")) this.play("sfx_firecracker_explode", { category: "ops", minGap: 0.30 }); }, + phase() { this.play("sfx_phase", { category: "notify", minGap: 1.0 }); }, + notify() { this.play("sfx_notify", { category: "notify", minGap: 0.12 }); }, + uiClick() { this.play("sfx_ui_click", { category: "ops", minGap: 0.035 }); }, + uiFold() { this.play("sfx_ui_fold", { category: "ops", minGap: 0.08 }); }, + place(type = "") { + if (type === "genkotsu") return this.play("sfx_genkotsu_fall", { category: "ops", minGap: 0.10 }); + if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 }); + if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 }); + if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 }); + if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 }); + return this.play("sfx_place", { category: "ops", minGap: 0.08 }); + }, + delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); }, + grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); }, + drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); }, + waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); }, + waterClean() { this.play("sfx_water_clean", { category: "ops", minGap: 0.22 }); }, + ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); }, + stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); }, + genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); }, + damage(amount = 0) { this.play(amount >= 18 ? "sfx_damage_heavy" : "sfx_damage_soft", { category: "voice", minGap: amount >= 18 ? 0.16 : 0.09 }); }, + disease() { this.play("sfx_disease", { category: "voice", minGap: 0.55 }); }, + heal() { this.play("sfx_heal", { category: "voice", minGap: 0.40 }); }, + antDrag() { this.play("sfx_ant_drag", { category: "voice", minGap: 0.50 }); }, + antDie() { this.play("sfx_ant_die", { category: "voice", minGap: 0.18 }); }, + antNest() { this.play("sfx_ant_nest", { category: "ops", minGap: 0.28 }); }, + queenAnt() { this.play("sfx_queen_ant", { category: "voice", minGap: 0.80 }); }, }; +audio.applySoundPack?.(window.TARINAI_SOUND_PACK); +audio.loadSettings(); +window.TarinaiEvents?.on?.("audio:test", ev => audio.play(ev.detail?.id || "sfx_notify", ev.detail || {})); window.TarinaiAudio = audio; diff --git a/js/data.js b/js/data.js index dea50df..4547cbe 100644 --- a/js/data.js +++ b/js/data.js @@ -1,72 +1,159 @@ "use strict"; const SPRITES = [ - { id: "smile", label: "\u901a\u5e38\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_01_smile.png" }, - { id: "angry", label: "\u6012\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_02_angry.png" }, - { id: "teary", label: "\u6d99\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_03_teary.png" }, - { id: "jito", label: "\u3058\u3068\u76ee\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_04_jito.png" }, - { id: "drool", label: "\u3088\u3060\u308c\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_05_drool.png" }, - { id: "cry", label: "\u6ce3\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_06_cry.png" }, - { id: "sleep", label: "\u7720\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_07_sleep.png" }, - { id: "pokan", label: "\u307d\u304b\u3093\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_08_pokan.png" }, - { id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.png", actionOnly: true }, - { id: "hurt", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_10_hurt.png", actionOnly: true }, - { id: "weak", label: "\u5f31\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_11_weak.png", actionOnly: true }, - { id: "back", label: "\u5965\u5411\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_12_back.png", actionOnly: true }, - { id: "flee", label: "\u6557\u8d70\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_13_flee.png", actionOnly: true }, - { id: "zunda_eat", label: "\u305a\u3093\u3060\u9905\u98df\u3079\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_14_zunda_eat.png", actionOnly: true }, - { id: "hurt2", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_15_hurt2.png", actionOnly: true }, - { id: "normal_smirk", label: "\u901a\u5e38\u305f\u308a\u306a\u3044\u30fb\u306b\u3084\u308a", path: "assets/sprites/tarinai_16_normal_smirk.png", displayOnly: true }, - { id: "normal_tongue", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u820c", path: "assets/sprites/tarinai_17_normal_tongue.png", displayOnly: true }, - { id: "fear", label: "\u6050\u6016\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_18_fear.png", actionOnly: true }, - { id: "flee_fear2", label: "\u6557\u8d70\u6050\u6016\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_19_flee_fear2.png", actionOnly: true }, - { id: "sleep2", label: "\u7720\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_20_sleep2.png", actionOnly: true }, - { id: "normal_happy", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3054\u304d\u3052\u3093", path: "assets/sprites/tarinai_21_normal_happy.png", displayOnly: true }, - { id: "fear_blue", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u9752\u3056\u3081", path: "assets/sprites/tarinai_22_fear_blue.png", actionOnly: true }, - { id: "fear_cry", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u6d99", path: "assets/sprites/tarinai_23_fear_cry.png", actionOnly: true }, - { id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.png", actionOnly: true }, - { id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.png", actionOnly: true }, - { id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.png", actionOnly: true }, - { id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.png", actionOnly: true }, - { id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.png", displayOnly: true }, + { id: "smile", label: "\u901a\u5e38\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_01_smile.webp" }, + { id: "angry", label: "\u6012\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_02_angry.webp" }, + { id: "teary", label: "\u6d99\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_03_teary.webp" }, + { id: "jito", label: "\u3058\u3068\u76ee\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_04_jito.webp" }, + { id: "drool", label: "\u3088\u3060\u308c\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_05_drool.webp" }, + { id: "cry", label: "\u6ce3\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_06_cry.webp" }, + { id: "sleep", label: "\u7720\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_07_sleep.webp" }, + { id: "pokan", label: "\u307d\u304b\u3093\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_08_pokan.webp" }, + { id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.webp", actionOnly: true }, + { id: "hurt", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_10_hurt.webp", actionOnly: true }, + { id: "weak", label: "\u5f31\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_11_weak.webp", actionOnly: true }, + { id: "back", label: "\u5965\u5411\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_12_back.webp", actionOnly: true }, + { id: "flee", label: "\u6557\u8d70\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_13_flee.webp", actionOnly: true }, + { id: "zunda_eat", label: "\u305a\u3093\u3060\u9905\u98df\u3079\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_14_zunda_eat.webp", actionOnly: true }, + { id: "hurt2", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_15_hurt2.webp", actionOnly: true }, + { id: "normal_smirk", label: "\u901a\u5e38\u305f\u308a\u306a\u3044\u30fb\u306b\u3084\u308a", path: "assets/sprites/tarinai_16_normal_smirk.webp", displayOnly: true }, + { id: "normal_tongue", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u820c", path: "assets/sprites/tarinai_17_normal_tongue.webp", displayOnly: true }, + { id: "fear", label: "\u6050\u6016\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_18_fear.webp", actionOnly: true }, + { id: "flee_fear2", label: "\u6557\u8d70\u6050\u6016\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_19_flee_fear2.webp", actionOnly: true }, + { id: "sleep2", label: "\u7720\u308a\u305f\u308a\u306a\u30442", path: "assets/sprites/tarinai_20_sleep2.webp", actionOnly: true }, + { id: "normal_happy", label: "\u4f4e\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3054\u304d\u3052\u3093", path: "assets/sprites/tarinai_21_normal_happy.webp", displayOnly: true }, + { id: "fear_blue", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u9752\u3056\u3081", path: "assets/sprites/tarinai_22_fear_blue.webp", actionOnly: true }, + { id: "fear_cry", label: "\u6050\u6016\u305f\u308a\u306a\u3044\u30fb\u6d99", path: "assets/sprites/tarinai_23_fear_cry.webp", actionOnly: true }, + { id: "stress_dizzy", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u3050\u308b\u3050\u308b", path: "assets/sprites/tarinai_24_stress_dizzy.webp", actionOnly: true }, + { id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.webp", actionOnly: true }, + { id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.webp", actionOnly: true }, + { id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.webp", actionOnly: true }, + { id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.webp", displayOnly: true }, + { id: "hungry_70", label: "\u7a7a\u817970\u4ee5\u4e0a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_29_hungry_70.webp", displayOnly: true }, + { id: "zunchi_slave_alt", label: "\u305a\u3093\u3061\u3069\u308c\u3044 2", path: "assets/sprites/tarinai_30_zunchi_slave_alt.webp", displayOnly: true }, + { id: "sunbath_1", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 1", path: "assets/sprites/tarinai_31_sunbath_01.webp", actionOnly: true }, + { id: "sunbath_2", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 2", path: "assets/sprites/tarinai_32_sunbath_02.webp", actionOnly: true }, + { id: "sunbath_3", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 3", path: "assets/sprites/tarinai_33_sunbath_03.webp", actionOnly: true }, ]; const DECOR_ASSETS = [ - { id: "zunchi", path: "assets/objects/zunchi.png" }, - { id: "zunchi_02", path: "assets/objects/zunchi_02.png" }, - { id: "ant_queen", path: "assets/objects/ant_queen.png" }, - { id: "ant_worker", path: "assets/objects/ant_worker.png" }, - { id: "genkotsu", path: "assets/objects/genkotsu.png" }, + { id: "zunchi", path: "assets/objects/zunchi.webp" }, + { id: "zunchi_02", path: "assets/objects/zunchi_02.webp" }, + { id: "ant_queen", path: "assets/objects/ant_queen.webp" }, + { id: "ant_worker", path: "assets/objects/ant_worker.webp" }, + { id: "genkotsu", path: "assets/objects/genkotsu.webp" }, + { id: "pushpin", path: "assets/objects/pushpin.webp" }, + { id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" }, + { id: "oshibyo", path: "assets/objects/oshibyo.webp" }, + { id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" }, ]; const TOOL_DEFINITIONS = Object.freeze({ - observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.png", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, - delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.png", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, + observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, + delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, - pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, - new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.png", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, + pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや物を移動させる。冷凍庫にも持っていける。" }, + new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, - zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, - sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.png", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, - love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, - fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, - water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.png", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, - sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.png", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, - grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.png", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, - stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, - genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 72, amount: 100, icon: "assets/ui/tool_genkotsu.png", tooltip: "\u4e0a\u304b\u3089\u6b63\u7fa9\u3052\u3093\u3053\u3064\u3092\u843d\u3068\u3057\u3001\u30a2\u30ea\u3068\u305f\u308a\u306a\u3044\u306b\u30c0\u30e1\u30fc\u30b8\u3068\u5439\u3063\u98db\u3073\u3092\u4e0e\u3048\u308b\u3002" }, - bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, - nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.png", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, - ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.png", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, - ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.png", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.png", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, - fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, - fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, + freeze: { id: "freeze", label: "冷凍", placeable: false, scalable: false, iconText: "\u{1F9CA}", tooltip: "つまむでたりないを冷凍個体UIまで運ぶと冷凍保存する。" }, + zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, + sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, + + laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" }, + protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" }, + niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" }, + ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" }, + mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" }, + mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" }, + giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" }, + dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" }, + zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" }, + grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, + stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, + bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, + nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, + ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, + ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, + signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 42, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "短いテキストを書き込める大きめの看板。クリックで編集できる。たりないに対する効果は無し" }, + firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, + pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "転がってたりないに刺さる。" }, + oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "おしり鋲", placeable: true, scalable: false, radius: 5, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "転がってずんちのおでかけを防止。" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "複製機", placeable: true, scalable: false, radius: 44, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを減らさず皿に出し続ける。" }, + cannon: { id: "cannon", itemType: "cannon", label: "大砲", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_cannon.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを向いている方向へ撃ち出す。クリック位置で向きを変える。" }, + fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, + fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true }, trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, - ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 8, amount: 24, simulationOnly: true }, + ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, }); + +const ITEM_TRAITS = Object.freeze({ + obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h", "duplicator", "cannon"]), + food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + pin: new Set(["pushpin", "oshibyo"]), + kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), + machine: new Set(["duplicator", "cannon"]), + sleepFurniture: new Set(["bed", "nest_box"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo", "duplicator", "cannon"]), +}); + +function itemHasTrait(type = "", trait = "") { + return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); +} + +function itemTraitsFor(type = "") { + const out = []; + for (const [trait, set] of Object.entries(ITEM_TRAITS)) if (set.has(String(type || ""))) out.push(trait); + return out; +} + +function isPinType(type = "") { return itemHasTrait(type, "pin"); } +function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isMachineType(type = "") { return itemHasTrait(type, "machine"); } +function isMachineConsumableType(type = "") { + const t = String(type || ""); + if (!t) return false; + if (typeof isServingFoodType === "function" && isServingFoodType(t)) return true; + return ["grass", "water", "water_bowl", "zunchi", "ant_corpse"].includes(t); +} +function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } +function lodgedPinFor(tarinai = null, worldRef = null) { + if (!tarinai?.stuckPushpinId) return null; + const items = worldRef?.items || tarinai.world?.items || []; + return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; +} +function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } +function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } + +const PIN_BEHAVIORS = Object.freeze({ + pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, + oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true, scale: 0.50 }, +}); + +function pinBehaviorFor(type = "") { + return PIN_BEHAVIORS[String(type || "")] || null; +} + + +const TOOL_CATEGORIES = Object.freeze([ + { id: "operate", label: "操作", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, + { id: "food", label: "飲食", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, + { id: "medicine", label: "薬", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, + { id: "habitat", label: "生活", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator", "cannon", "fence_v", "fence_h"] }, + { id: "hazard", label: "危険物", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo"] }, +]); + +function toolCategories() { + return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0)); +} + function toolDefinition(id = "") { return TOOL_DEFINITIONS[id] || null; } @@ -93,6 +180,18 @@ function itemAmountFor(type = "", fallback = 80) { return itemDefinition(type)?.amount ?? fallback; } +const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); +const TOOL_SIZE_LABELS = Object.freeze({ small: "小", medium: "中", large: "大" }); +const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); +function normalizedToolSizeFor(worldRef = null, type = "") { + const def = itemDefinition(type); + if (!def?.scalable) return "medium"; + const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; + return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; +} +function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } +function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } + function scalableToolIds() { return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); } @@ -104,12 +203,12 @@ function toolTipsFromDefinitions() { const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, - teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 }, - jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 }, + teary: { x: 17, y: 17, w: 474, h: 434, imageW: 508, imageH: 468 }, + jito: { x: 18, y: 18, w: 456, h: 432, imageW: 492, imageH: 468 }, drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 }, - cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 }, - sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 }, - pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 }, + cry: { x: 15, y: 15, w: 471, h: 438, imageW: 501, imageH: 468 }, + sleep: { x: 18, y: 18, w: 467, h: 432, imageW: 503, imageH: 468 }, + pokan: { x: 18, y: 17, w: 475, h: 434, imageW: 511, imageH: 468 }, stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 }, hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 }, weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 }, @@ -128,12 +227,22 @@ const ALPHA_BOUNDS = { stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 }, stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 }, intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 }, - zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 }, + birth_ritual: { x: 0, y: 0, w: 512, h: 456, imageW: 512, imageH: 456 }, + zunchi_slave: { x: 17, y: 22, w: 477, h: 431, imageW: 512, imageH: 468 }, + hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 }, + zunchi_slave_alt: { x: 33, y: 56, w: 446, h: 402, imageW: 512, imageH: 468 }, + sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, + sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 }, + sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 }, zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 }, ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, + pushpin: { x: 6, y: 6, w: 82, h: 186, imageW: 95, imageH: 200 }, + pushpin_stuck: { x: 7, y: 7, w: 135, h: 135, imageW: 150, imageH: 150 }, + oshibyo: { x: 0, y: 0, w: 198, h: 449, imageW: 198, imageH: 449 }, + oshibyo_stuck: { x: 0, y: 0, w: 247, h: 247, imageW: 247, imageH: 247 }, }; const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; @@ -147,12 +256,12 @@ const CONFIG = { zunchiLimit: 56, traceLimit: 64, splatLimit: 48, - effectLimit: 96, + effectLimit: 78, worldPadding: 30, hungerPerMinute: 11.2, lonelinessPerMinute: 7.6, energyPerMinute: 8.2, - stressDecayPerMinute: 9, + stressDecayPerMinute: 10.2, reproductionCooldown: 18, dayLength: 120, childGrowTime: 58, @@ -160,7 +269,7 @@ const CONFIG = { lightingQuality: "cinematic", weatherChangeMin: 46, weatherChangeMax: 96, - bubbleLimit: 42, + bubbleLimit: 32, grassLimit: 96, highPopulationMode: 70, ultraPopulationMode: 120, diff --git a/js/debug_tools.js b/js/debug_tools.js new file mode 100644 index 0000000..30a7b7c --- /dev/null +++ b/js/debug_tools.js @@ -0,0 +1,39 @@ +"use strict"; + +(function () { + const params = new URLSearchParams(location.search || ""); + const enabled = params.get("debug") === "1" || params.get("debug") === "true"; + if (!enabled) return; + const panel = document.createElement("div"); + panel.id = "debugOverlay"; + panel.style.cssText = [ + "position:fixed", "left:8px", "top:76px", "z-index:99999", "min-width:230px", "max-width:360px", + "font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", + "background:rgba(18,24,30,.78)", "border:1px solid rgba(255,255,255,.18)", "border-radius:10px", + "padding:8px 10px", "pointer-events:none", "white-space:pre-wrap", "box-shadow:0 8px 24px rgba(0,0,0,.22)" + ].join(";"); + document.addEventListener("DOMContentLoaded", () => document.body.appendChild(panel)); + let lastEvents = []; + let lastAudio = []; + window.TarinaiEvents?.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; }); + window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; }); + window.setInterval(() => { + const w = window.world; + const perf = window.TarinaiPerformance?.debugSnapshot(w) || {}; + const input = window.TarinaiInputMode?.snapshot?.() || {}; + panel.textContent = [ + `tarinai v${window.TARINAI_VERSION || "?"}`, + `fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`, + `items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`, + `effects ${(w?.effects || []).length}`, + `terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`, + `drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`, + `dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`, + `sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`, + `input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`, + `audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`, + `effects ${window.TarinaiEffectRegistry?.debugSnapshot?.().length || 0} registered`, + `events ${lastEvents.join(", ")}`, + ].join("\n"); + }, 500); +})(); diff --git a/js/disease_registry.js b/js/disease_registry.js new file mode 100644 index 0000000..ca63c50 --- /dev/null +++ b/js/disease_registry.js @@ -0,0 +1,63 @@ +"use strict"; + +(function (global) { + const rawDefinitions = { + disease_zunchi: { + id: "disease_zunchi", + flag: "zunchiDisease", + label: "\u305a\u3093\u3061\u75c5", + symptoms: ["stress", "energy_loss", "infection"], + displayText: "\u305a\u3093\u3061\u75c5", + color: "rgba(113,93,59,0.74)", + mysteryEligible: true, + cureRules: ["water", "natural_recovery"] + }, + disease_sleep: { + id: "disease_sleep", + flag: "sleepDisease", + label: "\u306d\u3080\u308a\u75c5", + symptoms: ["sleep_lock", "no_counterattack"], + displayText: "\u306d\u3080\u308a\u75c5", + color: "rgba(104,84,168,0.78)", + mysteryEligible: true, + cureRules: ["damage"] + }, + disease_explosion: { + id: "disease_explosion", + flag: "explosionDisease", + label: "\u7206\u767a\u75c5", + symptoms: ["explodes_after_timer", "water_seeking"], + displayText: "\u7206\u767a\u75c5", + color: "rgba(210,90,44,0.78)", + baseInfectionChance: 0.15, + mysteryEligible: true, + cureRules: ["water", "zunda_mochi"] + }, + disease_fight: { + id: "disease_fight", + flag: "fightDisease", + label: "\u304d\u305a\u3064\u304d\u75c5", + symptoms: ["fight_lash_out", "zunda_seeking"], + displayText: "\u304d\u305a\u3064\u304d\u75c5", + color: "rgba(180,86,52,0.80)", + mysteryEligible: true, + cureRules: ["zunda_mochi", "natural_recovery"] + } + }; + + class DiseaseRegistry extends global.TarinaiRegistryBase { + byFlag(flag = "") { return [...this.defs.values()].find(def => def.flag === flag) || null; } + label(id = "") { return super.label(id, id || ""); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } + infectionChance(id = "", fallback = 0) { + const value = this.get(id)?.baseInfectionChance; + return Number.isFinite(value) ? value : fallback; + } + mysteryPool() { return [...this.defs.values()].filter(def => def.mysteryEligible).map(def => def.id); } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id: def.id || id, label: def.label, flag: def.flag, cures: def.cureRules || [] })); } + } + + const registry = new DiseaseRegistry(rawDefinitions); + global.TarinaiDiseaseRegistry = registry; + global.TARINAI_DISEASE_DEFINITIONS = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/effect_registry.js b/js/effect_registry.js new file mode 100644 index 0000000..4d73aa4 --- /dev/null +++ b/js/effect_registry.js @@ -0,0 +1,270 @@ +"use strict"; + +(function (global) { + const rawDefinitions = { + laxative: { + label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, + tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`, + }, + ammo: { + label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, + tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`, + }, + protein: { + label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d", + }, + niteropu: { + label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d", + }, + mercury: { + label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", + tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`, + }, + giant_drug: { + label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + dwarf_drug: { + label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + love_mochi: { + label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + fight_mochi: { + label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + sleep_drug: { + label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], + }, + mystery_drug: { + label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], + }, + zunda_juice: { + label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], + }, + disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, + disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, + }; + + Object.assign(rawDefinitions.laxative, { + modifiers: { hungerReliefMultiplier: 0 }, + tickInterval: 3, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.ammo, { + modifiers: { speedMultiplier: 10 }, + visualEffectId: "fall", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.protein, { + modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 }, + visualEffectId: "fall_up_red", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.niteropu, { + modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 }, + visualEffectId: "fall_down_purple", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.mercury, { + modifiers: { lifeMultiplierPerUse: 1.3 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.giant_drug, { + modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.dwarf_drug, { + modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) { + Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" }); + } + Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false }); + + Object.assign(rawDefinitions.love_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress - (context.source === "eating" ? nutrition * 0.25 : 8), 0, 130); + return true; + }, + }); + Object.assign(rawDefinitions.fight_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress + (context.source === "eating" ? nutrition * 0.22 : 10), 0, 130); + if (context.source === "eating") tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); + return true; + }, + }); + Object.assign(rawDefinitions.sleep_drug, { + onApply(tarinai, context = {}) { + if (context.source === "eating") { + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress - nutrition * 0.10, 0, 130); + tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); + tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); + tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); + const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); + const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; + if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; + return true; + } + return tarinai.infectSleepDisease?.(context.item || null) || true; + }, + }); + Object.assign(rawDefinitions.laxative, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1))); + }, + onTick(tarinai) { + tarinai.forceLaxativePoop?.(); + return true; + }, + }); + Object.assign(rawDefinitions.ammo, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1))); + }, + }); + Object.assign(rawDefinitions.protein, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); }, + }); + Object.assign(rawDefinitions.niteropu, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); }, + }); + Object.assign(rawDefinitions.mercury, { + onApply(tarinai) { return tarinai.setMercuryEffect?.(); }, + }); + Object.assign(rawDefinitions.giant_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.dwarf_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.zunda_juice, { + onApply(tarinai) { return tarinai.clearAllItemEffects?.(); }, + }); + Object.assign(rawDefinitions.disease_zunchi, { + onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; }, + }); + Object.assign(rawDefinitions.disease_sleep, { + onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_explosion, { + onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_fight, { + onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, + }); + + class EffectRegistry extends global.TarinaiRegistryBase { + label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } + display(id = "", timer = 0, tarinai = null) { + const def = this.get(id); + if (!def) return ""; + return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || ""); + } + apply(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onApply !== "function") return false; + return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + tick(id = "", tarinai = null, dt = 0, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onTick !== "function") return false; + return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this })); + } + remove(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onRemove !== "function") return false; + return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + modifier(id = "", key = "", fallback = 1) { + const value = this.get(id)?.modifiers?.[key]; + return Number.isFinite(value) ? value : fallback; + } + timerValue(tarinai = null, id = "") { + const def = this.get(id); + if (!def || !tarinai) return 0; + if (def.timerProp) return Number(tarinai[def.timerProp] || 0); + if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0); + return 0; + } + isActive(tarinai = null, id = "") { + if (!tarinai) return false; + const def = this.get(id); + if (!def) return false; + if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id; + if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id; + if (id === "mercury") return tarinai.lifeItemMode === "mercury"; + return this.timerValue(tarinai, id) > 0.04; + } + activeSummary(tarinai = null) { + const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"]; + return order + .filter(id => this.isActive(tarinai, id)) + .map(id => ({ + id, + label: this.label(id), + detail: this.display(id, this.timerValue(tarinai, id), tarinai), + })); + } + labels() { return super.labels(); } + colors() { return super.colors(); } + randomPool() { + const itemEffects = [...this.defs.entries()] + .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) + .map(([id]) => id) + .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); + const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || []; + return [...new Set([...itemEffects, ...diseases])]; + } + permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); } + timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); } + debugSnapshot() { + return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" })); + } + } + + const registry = new EffectRegistry(rawDefinitions); + global.TarinaiEffectRegistry = registry; + global.TARINAI_EFFECT_DEFINITIONS = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/event_bus.js b/js/event_bus.js new file mode 100644 index 0000000..94d9322 --- /dev/null +++ b/js/event_bus.js @@ -0,0 +1,52 @@ +"use strict"; + +class TarinaiEventBus { + constructor({ historyLimit = 180 } = {}) { + this.handlers = new Map(); + this.history = []; + this.historyLimit = historyLimit; + } + + on(type, handler) { + if (!type || typeof handler !== "function") return () => {}; + let list = this.handlers.get(type); + if (!list) { + list = new Set(); + this.handlers.set(type, list); + } + list.add(handler); + return () => this.off(type, handler); + } + + off(type, handler) { + const list = this.handlers.get(type); + if (!list) return; + list.delete(handler); + if (!list.size) this.handlers.delete(type); + } + + emit(type, detail = {}) { + if (!type) return null; + const event = { type, detail: detail || {}, at: performance?.now?.() ?? Date.now() }; + this.history.unshift(event); + if (this.history.length > this.historyLimit) this.history.length = this.historyLimit; + const direct = this.handlers.get(type); + if (direct) for (const handler of [...direct]) this.safeCall(handler, event); + const wildcard = this.handlers.get("*"); + if (wildcard) for (const handler of [...wildcard]) this.safeCall(handler, event); + return event; + } + + safeCall(handler, event) { + try { handler(event); } + catch (err) { console.error("event handler failed", event.type, err); } + } + + recent(filter = null, limit = 40) { + const list = filter ? this.history.filter(ev => ev.type === filter) : this.history; + return list.slice(0, limit); + } +} + +window.TarinaiEventBus = TarinaiEventBus; +window.TarinaiEvents = window.TarinaiEvents || new TarinaiEventBus(); diff --git a/js/family_graph.js b/js/family_graph.js new file mode 100644 index 0000000..5534f89 --- /dev/null +++ b/js/family_graph.js @@ -0,0 +1,49 @@ +"use strict"; + +(function (global) { + function buildGraph(nodes = [], opts = {}) { + const idOf = opts.idOf || (n => n?.id); + const edgesOf = opts.edgesOf || ((n) => [...(n?.parents || []), ...(n?.children || [])]); + const byId = new Map(nodes.filter(Boolean).map(n => [idOf(n), n]).filter(([id]) => id)); + const graph = new Map([...byId.keys()].map(id => [id, new Set()])); + const connect = (a, b) => { + if (!a || !b || a === b || !graph.has(a) || !graph.has(b)) return; + graph.get(a).add(b); + graph.get(b).add(a); + }; + for (const node of byId.values()) { + const id = idOf(node); + for (const next of edgesOf(node, byId)) connect(id, next); + } + return { graph, byId }; + } + + function connectedComponents(nodes = [], opts = {}) { + const { graph, byId } = buildGraph(nodes, opts); + const sort = opts.sort || null; + const nodeList = [...byId.values()].sort(sort || (() => 0)); + const seen = new Set(); + const comps = []; + for (const node of nodeList) { + const start = opts.idOf ? opts.idOf(node) : node.id; + if (!start || seen.has(start)) continue; + const stack = [start]; + const comp = []; + seen.add(start); + while (stack.length) { + const id = stack.pop(); + const item = byId.get(id); + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (!opts.filter || opts.filter(comp)) comps.push(sort ? comp.sort(sort) : comp); + } + return comps; + } + + global.TarinaiFamilyGraph = { buildGraph, connectedComponents }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/food_registry.js b/js/food_registry.js new file mode 100644 index 0000000..f19667d --- /dev/null +++ b/js/food_registry.js @@ -0,0 +1,57 @@ +"use strict"; + +(function (global) { + const rawDefinitions = { + food: { label: "\u98df\u3079\u7269", nutrition: 8.5, hungerRelief: 0.95, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, + sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 7.2, hungerRelief: 0.75, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, + love_mochi: { label: "\u3078\u3053\u9905", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, + fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, + sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, + laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, + protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, + niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, + ammo: { label: "\u5f3e\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, + mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, + mercury: { label: "\u6c34\u9280", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, + giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, + dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, + zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, + grass: { label: "\u8349", nutrition: 0.46, hungerRelief: 0.46, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 0.62, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } + }; + + class FoodRegistry extends global.TarinaiRegistryBase { + servingTypes() { + return [...this.defs.entries()] + .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) + .map(([id]) => id); + } + typesByInterest(...interests) { + const wanted = new Set(interests.flat().filter(Boolean)); + return [...this.defs.entries()] + .filter(([, def]) => wanted.has(def.interest)) + .map(([id]) => id); + } + paramEffectTypes() { + return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); + } + nutrition(type = "", fallback = 0) { + const value = this.get(type)?.nutrition; + return Number.isFinite(value) ? value : fallback; + } + hungerRelief(type = "", fallback = 0) { + const value = this.get(type)?.hungerRelief; + return Number.isFinite(value) ? value : fallback; + } + effectId(type = "") { return this.get(type)?.effectId || ""; } + passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } + hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } + } + + const registry = new FoodRegistry(rawDefinitions); + global.TarinaiFoodRegistry = registry; + global.TARINAI_FOOD_DEFINITIONS = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/freeze_system.js b/js/freeze_system.js new file mode 100644 index 0000000..e25f3cc --- /dev/null +++ b/js/freeze_system.js @@ -0,0 +1,358 @@ +"use strict"; + +(function (global) { + const STORAGE_KEY = "tarinai_frozen_storage_v1"; + const MAX_PENDING_LOSSES = 16; + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js"); + const clonePlain = Snapshot.clonePlain; + + const htmlEscape = global.TarinaiUIHelpers.htmlEscape; + + function frozenList(worldRef = global.world) { + if (!worldRef) return []; + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + return worldRef.frozenTarinai; + } + + function saveFrozenStore(worldRef = global.world) { + try { + global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef))); + } catch (_) {} + } + + function loadFrozenStore(worldRef = global.world) { + if (!worldRef) return []; + try { + const raw = global.localStorage?.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || []; + } catch (_) { + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + } + return frozenList(worldRef); + } + + function spritePathFor(type = "smile") { + const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || [])); + const meta = sprites.find(s => s.id === type) || sprites[0]; + return meta?.path || "assets/sprites/tarinai_01_smile.webp"; + } + + function ensureStyles() { + if (document.getElementById("freezeSystemStyles")) return; + const style = document.createElement("style"); + style.id = "freezeSystemStyles"; + style.textContent = ` + .frozen-panel { margin-top: 10px; padding: 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); } + .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; border:0; background:transparent; padding:0; font-weight:700; font-size:13px; color:#4f5f70; margin:0; cursor:pointer; text-align:left; } + .frozen-panel-title::before { content:"▸"; width:14px; color:rgba(79,95,112,.74); } + .frozen-panel:not(.collapsed) .frozen-panel-title::before { content:"▾"; } + .frozen-panel-body { margin-top:8px; } + .frozen-panel.collapsed .frozen-panel-body { display:none; } + .frozen-panel-count { font-size:12px; opacity:.72; } + .frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; } + .frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } + .frozen-card:active { cursor:grabbing; } + .frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); } + .frozen-card img { width:34px; height:34px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } + .frozen-card-main { min-width:0; flex:1; } + .frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; } + canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; } + `; + document.head.appendChild(style); + } + + function ensurePanel() { + ensureStyles(); + let panel = document.getElementById("frozenPanel"); + if (panel) { + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); + if (operateBody && panel.parentNode !== operateBody) operateBody.appendChild(panel); + return panel; + } + panel = document.createElement("section"); + panel.id = "frozenPanel"; + panel.className = "frozen-panel"; + panel.classList.add("collapsed"); + panel.innerHTML = ` + +
+ `; + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); + const anchor = global.ui?.toolPalette || document.getElementById("toolPalette"); + if (operateBody) operateBody.appendChild(panel); + else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); + else document.body.appendChild(panel); + return panel; + } + + function renderFrozenPanel(worldRef = global.world) { + const panel = ensurePanel(); + const listEl = panel.querySelector("#frozenList"); + const countEl = panel.querySelector("#frozenPanelCount"); + const list = frozenList(worldRef); + if (countEl) countEl.textContent = `${list.length}`; + if (!listEl) return; + if (!list.length) { + listEl.innerHTML = `
冷凍中のたりないはいません。
`; + return; + } + listEl.innerHTML = list.map(entry => { + const data = entry.data || entry; + const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / 訃報${entry.pendingLosses.length}` : ""; + const gen = data.generation ? `第${data.generation}世代` : ""; + const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || ""); + return ``; + }).join(""); + } + + function makeFrozenEntry(t, worldRef = global.world) { + const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"])); + data.sleeping = false; + data.insideNestBoxId = ""; + data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle"); + data.targetKey = ""; + const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`); + return { + freezeId: id, + frozenAt: Date.now(), + worldTime: worldRef?.time || 0, + name: t.name || "たりない", + familyKey: t.familyKey || "", + liveId: t.id || "", + liveToken: t.liveToken || 0, + type: t.type || "smile", + data, + pendingLosses: [], + }; + } + + function findTarinaiAt(worldRef, x, y) { + const arr = worldRef?.tarinai || []; + for (let i = arr.length - 1; i >= 0; i--) { + const t = arr[i]; + if (!t || t.dead) continue; + if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t; + } + return null; + } + + function freezeTarinai(t, worldRef = global.world) { + if (!t || t.dead || !worldRef) return false; + const entry = makeFrozenEntry(t, worldRef); + worldRef.clearLiveReferencesTo?.(t); + if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id); + worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t); + if (worldRef.selected === t) worldRef.selected = null; + frozenList(worldRef).push(entry); + worldRef.drawListDirty = true; + worldRef.familyTreeDirty = true; + worldRef.updateItemCounts?.(); + worldRef.rebuildSpatial?.(true); + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + global.showToast?.(`${t.name}を冷凍保存しました。`); + worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] }); + global.renderStats?.(); + global.render?.(); + return true; + } + + function updateLiveIdAfterThaw(worldRef, t) { + if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map(); + const id = t.id || ""; + const conflict = id && worldRef.liveTarinai.has(id); + if (!id || conflict) { + worldRef.assignTarinaiLiveId?.(t); + return; + } + worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t }); + const m = /^t(\d+)$/.exec(id); + if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1); + worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0); + } + + function applyPendingLossesOnThaw(t, entry, worldRef) { + const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : []; + if (!losses.length) return; + const unique = []; + const seen = new Set(); + for (const loss of losses) { + const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss); + if (seen.has(key)) continue; + seen.add(key); + unique.push(loss); + } + const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0; + const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); + if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); + else { + t.stress = clamp((t.stress || 0) + stress, 0, 130); + t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); + } + t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)"); + t.recordChangeCause?.("冷凍中の訃報", "ストレス", { value: stress }); + t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death"); + worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] }); + } + + function thawFrozen(freezeId, x, y, worldRef = global.world) { + const list = frozenList(worldRef); + const index = list.findIndex(e => (e.freezeId || e.id) === freezeId); + if (index < 0 || !worldRef) return false; + const entry = list[index]; + const data = clonePlain(entry.data || entry) || {}; + let t = null; + try { + t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true }); + } catch (_) { + return false; + } + t.dead = false; + t.deathReason = ""; + t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); + t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); + t.vx = 0; + t.vy = 0; + t.target = null; + t.panicTarget = null; + t.insideNestBoxId = ""; + t.sleeping = false; + if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている"); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); + t.thought = "解凍されて少し驚いている"; + t.frozenAt = 0; + updateLiveIdAfterThaw(worldRef, t); + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + worldRef.tarinai.push(t); + worldRef.recordFamily?.(t); + applyPendingLossesOnThaw(t, entry, worldRef); + list.splice(index, 1); + worldRef.selected = t; + worldRef.drawListDirty = true; + worldRef.familyTreeDirty = true; + worldRef.updateItemCounts?.(); + worldRef.rebuildSpatial?.(true); + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + global.showToast?.(`${t.name}を解凍しました。`); + worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] }); + global.renderStats?.(); + global.render?.(); + return true; + } + + function bindFreezeSystem() { + const worldRef = global.world; + loadFrozenStore(worldRef); + renderFrozenPanel(worldRef); + const panel = ensurePanel(); + if (panel.dataset.freezeBound !== "1") { + panel.dataset.freezeBound = "1"; + panel.addEventListener("click", (e) => { + const title = e.target.closest(".frozen-panel-title"); + if (!title) return; + const collapsed = !panel.classList.toggle("collapsed"); + title.setAttribute("aria-expanded", collapsed ? "true" : "false"); + }); + panel.addEventListener("dragstart", (e) => { + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + const id = card.dataset.frozenId || ""; + e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`); + e.dataTransfer?.setData("application/x-tarinai-frozen", id); + e.dataTransfer.effectAllowed = "move"; + global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready"); + }); + panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready")); + panel.addEventListener("click", (e) => { + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + pendingThawId = card.dataset.frozenId || ""; + for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card); + global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。"); + }); + } + const canvas = global.canvas || document.getElementById("gameCanvas"); + if (canvas && canvas.dataset.freezeDropBound !== "1") { + canvas.dataset.freezeDropBound = "1"; + canvas.addEventListener("dragover", (e) => { + const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain"); + if (!text) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + canvas.classList.add("freeze-drop-ready"); + }); + canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready")); + canvas.addEventListener("drop", (e) => { + const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, ""); + if (!id) return; + e.preventDefault(); + canvas.classList.remove("freeze-drop-ready"); + const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true }; + })(); + thawFrozen(id, p.x, p.y, worldRef); + }); + } + } + + let pendingThawId = ""; + + function tryPendingThawAt(x, y, worldRef = global.world) { + if (!pendingThawId) return false; + const id = pendingThawId; + pendingThawId = ""; + document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw")); + return thawFrozen(id, x, y, worldRef); + } + + function afterSnapshotRestored(worldRef = global.world) { + if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = []; + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + } + + function patchWorldPrototype() { + const apply = (proto) => { + const originalHandleClick = proto.handleClick; + proto.handleClick = function (x, y) { + if (tryPendingThawAt(x, y, this)) return; + if (this.tool === "freeze") { + global.showToast?.("冷凍は『つまむ』でたりないを冷凍個体UIへ運んでください。"); + return; + } + return originalHandleClick.call(this, x, y); + }; + }; + if (global.TarinaiPatcher?.patchWorld?.("freezeSystem", apply)) return; + const World = global.World; + if (!World || World.prototype.__freezeSystemPatched) return; + World.prototype.__freezeSystemPatched = true; + apply(World.prototype); + } + + patchWorldPrototype(); + + global.TarinaiFreezeSystem = { + bindFreezeSystem, + renderFrozenPanel, + freezeTarinai, + thawFrozen, + frozenList, + loadFrozenStore, + saveFrozenStore, + afterSnapshotRestored, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/health.js b/js/health.js index 33fc663..690a16c 100644 --- a/js/health.js +++ b/js/health.js @@ -1,14 +1,16 @@ "use strict"; const HEALTH = (() => { - const MAJOR_DAMAGE_WINDOW = 10; + const MAJOR_DAMAGE_WINDOW = 24; + const WEAKENED_TRACE_WINDOW = 45; const WEAKENED_SUFFIX = "\u3067\u8870\u5f31"; const CAUSES = Object.freeze({ fight: "\u55a7\u5629", hunger: "\u98e2\u9913", stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a", - poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f", + poke: "\u3064\u3064\u304b\u308c\u3059\u304e", firecracker: "\u7206\u7af9", + genkotsu: "\u3052\u3093\u3053\u3064", accident: "\u4e8b\u6545", collision: "\u885d\u7a81", outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664", @@ -18,6 +20,8 @@ const HEALTH = (() => { fightDisease: "\u304d\u305a\u3064\u304d\u75c5", zunchiDisease: "\u305a\u3093\u3061\u75c5", antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f", + giantDrug: "\u5de8\u5927\u85ac", + dwarfDrug: "\u77ee\u5c0f\u85ac", }); const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; @@ -26,6 +30,9 @@ const HEALTH = (() => { if (!text) return ""; if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; + if (/\u3052\u3093\u3053\u3064|genkotsu/.test(text)) return CAUSES.genkotsu; + if (/\u5de8\u5927\u85ac|giant/.test(text)) return CAUSES.giantDrug; + if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision; @@ -58,7 +65,7 @@ const HEALTH = (() => { t.lastDamageCause = cause; t.lastDamageAmount = Math.max(0, amount || 0); t.lastDamageAt = now; - if (t.lastDamageAmount >= 10 || t.lastDamageAmount >= Math.max(6, (t.energy || 0) * 0.36)) { + if (t.lastDamageAmount >= 6 || t.lastDamageAmount >= Math.max(4, (t.energy || 0) * 0.22)) { t.lastMajorDamageCause = cause; t.lastMajorDamageAmount = t.lastDamageAmount; t.lastMajorDamageAt = now; @@ -88,14 +95,19 @@ const HEALTH = (() => { const recent = recentDamageCause(t, 20); const fighting = isFightingContext(t, recent); if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision; - if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 22 || t.mood <= 20 || t.stress >= 118)) { + if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) { return weakenedDeathReasonFor(t.lastMajorDamageCause); } + const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW); + const traceCanWeaken = weakenedTrace && weakenedTrace !== CAUSES.stress && weakenedTrace !== CAUSES.hunger && weakenedTrace !== CAUSES.lifespan && weakenedTrace !== CAUSES.outOfBounds; + if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace || opts.fromDamage) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) { + return weakenedDeathReasonFor(weakenedTrace); + } if (direct && direct !== CAUSES.stress) return direct; if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease; if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger; if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight; - if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 20 || t.stress >= 118 || opts.fromDamage)) return recent; + if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126 || opts.fromDamage)) return weakenedDeathReasonFor(recent); if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan; if (direct) return direct; if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress; @@ -108,7 +120,7 @@ const HEALTH = (() => { if (text.includes(WEAKENED_SUFFIX)) return text; const cause = dominantDeathCause(t, text, opts) || CAUSES.accident; if (cause.includes(WEAKENED_SUFFIX)) return cause; - const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision; + const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu; if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause); return cause; } @@ -121,7 +133,9 @@ const HEALTH = (() => { let cause = ""; if (actualLoss > 0.01) { cause = rememberDamage(t, actualLoss, reason); + t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason }); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); + audio.damage?.(actualLoss); if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); if (t.wakeFromDamage) t.wakeFromDamage(cause || reason); if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason); @@ -142,6 +156,7 @@ const HEALTH = (() => { return Object.freeze({ MAJOR_DAMAGE_WINDOW, + WEAKENED_TRACE_WINDOW, CAUSES, causeLabel, recentDamageCause, diff --git a/js/input_mode_manager.js b/js/input_mode_manager.js new file mode 100644 index 0000000..9d198b4 --- /dev/null +++ b/js/input_mode_manager.js @@ -0,0 +1,119 @@ +"use strict"; + +(function (global) { + const MODES = Object.freeze(["auto", "camera", "tool", "family"]); + const STORAGE_KEY = "tarinai_mobile_input_mode_v1"; + + class InputModeManager { + constructor() { + this.storageKey = STORAGE_KEY; + this.currentMode = this.loadMode(); + this.gestureState = { + active: false, + kind: "", + startedAt: 0, + pointerCount: 0, + }; + this.classification = this.classify(); + } + + loadMode() { + try { + const mode = global.localStorage?.getItem(STORAGE_KEY) || "auto"; + return MODES.includes(mode) ? mode : "auto"; + } catch (_) { + return "auto"; + } + } + + setMode(mode = "auto") { + this.currentMode = MODES.includes(mode) ? mode : "auto"; + try { global.localStorage?.setItem(STORAGE_KEY, this.currentMode); } catch (_) {} + this.emit("input:mode", this.snapshot()); + return this.currentMode; + } + + classify() { + const nav = global.navigator || {}; + const ua = String(nav.userAgent || ""); + const mm = typeof global.matchMedia === "function" ? global.matchMedia.bind(global) : null; + const width = Number(global.innerWidth || 9999); + const height = Number(global.innerHeight || 9999); + const shortSide = Math.min(width, height); + const longSide = Math.max(width, height); + const pointerCoarse = Boolean(mm?.("(pointer: coarse)")?.matches); + const hasHover = Boolean(mm?.("(hover: hover)")?.matches); + const noHover = Boolean(mm?.("(hover: none)")?.matches); + const isTouchDevice = Number(nav.maxTouchPoints || 0) > 0 || "ontouchstart" in global; + const isCompactViewport = shortSide <= 720 && longSide <= 1100; + const userAgentMobile = /Android|iPhone|iPod|iPad|Mobile|Silk|Tablet/i.test(ua); + const touchFirst = Boolean(pointerCoarse && noHover && !hasHover && isTouchDevice && (isCompactViewport || userAgentMobile)); + this.classification = Object.freeze({ + isTouchDevice, + isCompactViewport, + hasHover, + pointerCoarse, + userAgentMobile, + touchFirst, + width, + height, + }); + return this.classification; + } + + refresh() { + const previous = this.classification; + const next = this.classify(); + if (JSON.stringify(previous) !== JSON.stringify(next)) this.emit("input:classification", this.snapshot()); + return next; + } + + beginGesture(kind = "", pointerCount = 1) { + this.gestureState = { + active: true, + kind: String(kind || ""), + startedAt: performance?.now?.() ?? Date.now(), + pointerCount: Math.max(0, Number(pointerCount) || 0), + }; + return this.gestureState; + } + + endGesture() { + this.gestureState = { ...this.gestureState, active: false, pointerCount: 0 }; + return this.gestureState; + } + + canPanCanvas() { + if (!this.classification?.touchFirst) return true; + return this.currentMode === "auto" || this.currentMode === "camera"; + } + + canPlaceTools() { + if (!this.classification?.touchFirst) return true; + return this.currentMode === "auto" || this.currentMode === "tool"; + } + + canFamilyTreeCaptureTouch() { + if (!this.classification?.touchFirst) return true; + return this.currentMode === "auto" || this.currentMode === "family"; + } + + snapshot() { + return Object.freeze({ + ...this.classification, + currentMode: this.currentMode, + gestureState: { ...this.gestureState }, + canPanCanvas: this.canPanCanvas(), + canPlaceTools: this.canPlaceTools(), + canFamilyTreeCaptureTouch: this.canFamilyTreeCaptureTouch(), + }); + } + + emit(type, detail) { + global.TarinaiEvents?.emit?.(type, detail); + } + } + + global.InputModeManager = InputModeManager; + global.TarinaiInputMode = global.TarinaiInputMode || new InputModeManager(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_visual_registry.js b/js/item_visual_registry.js new file mode 100644 index 0000000..5c29984 --- /dev/null +++ b/js/item_visual_registry.js @@ -0,0 +1,144 @@ +"use strict"; + +(function (global) { + const raw = String.raw`{ + "water": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"] + }, + "zunda_juice": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"] + }, + "mercury": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] + }, + "water_bowl": { + "renderer": "water_bowl", + "shape": "bowl_with_horizontal_oval_water", + "palette": { + "bowlFill": "rgba(171,120,76,0.72)", + "bowlStroke": "rgba(94,69,48,0.55)", + "waterFill": "rgba(86,157,224,0.58)", + "waterStroke": "rgba(62,113,178,0.36)", + "highlight": "rgba(255,255,255,0.54)" + }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"] + }, + "sleep_drug": { + "renderer": "sleep_tablet", + "shape": "moon_tablet", + "palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "moon", "purple"] + }, + "laxative": { + "renderer": "laxative_tablet", + "shape": "rounded_tablet_zunchi_motif", + "palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "zunchi motif", "tan"] + }, + "mystery_drug": { + "renderer": "mystery_tablet", + "shape": "question_mark_tablet", + "palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "question mark", "purple"] + }, + "ammo": { + "renderer": "bullet", + "shape": "bullet", + "palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bullet", "gold"] + }, + "protein": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "orange"] + }, + "niteropu": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "purple"] + }, + "giant_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" }, + "scale": 1.08, + "uiPreviewScale": 1.08, + "fieldScale": 1.08, + "accessibleShapeTraits": ["pill", "large", "orange"] + }, + "dwarf_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" }, + "scale": 0.92, + "uiPreviewScale": 0.92, + "fieldScale": 0.92, + "accessibleShapeTraits": ["pill", "small", "purple"] + }, + "ball": { + "renderer": "emoji", + "shape": "soccer_ball", + "palette": {}, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["round", "soccer ball"], + "emojiFallback": "\u26bd" + } + }`; + + const defs = Object.freeze(JSON.parse(raw)); + + function itemVisualDefinition(type = "") { + return defs[String(type || "")] || null; + } + + function itemVisualPalette(type = "") { + return itemVisualDefinition(type)?.palette || null; + } + + global.TARINAI_ITEM_VISUALS = defs; + global.itemVisualDefinition = itemVisualDefinition; + global.itemVisualPalette = itemVisualPalette; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/items.js b/js/items.js index 30c8d86..223bcab 100644 --- a/js/items.js +++ b/js/items.js @@ -1,7 +1,11 @@ // tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data "use strict"; -const FOOD_SERVING_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"]); +const FOOD_SERVING_TYPES = new Set(window.TarinaiFoodRegistry?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); +const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiFoodRegistry?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); +function isParamEffectItemType(type = "") { + return PARAM_EFFECT_ITEM_TYPES.has(type); +} const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 }; function foodServingsForSize(size = "medium") { return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium; @@ -9,6 +13,203 @@ function foodServingsForSize(size = "medium") { function isServingFoodType(type = "") { return FOOD_SERVING_TYPES.has(type); } +const PASSIVE_FOOD_DECAY_PER_SECOND = 0.004; +const PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055; +function passiveFoodDecayInterval(worldRef = null) { + // Two decay ticks per half-day: dayLength/4. Default day is 120s -> 30s interval. + const day = Number(worldRef?.config?.dayLength || CONFIG?.dayLength || 120) || 120; + return Math.max(12, day / 4); +} +function passiveFoodDecayRate(item) { + const size = item?.toolSize || "medium"; + const sizeMul = size === "small" ? 0.74 : (size === "large" ? 1.32 : 1); + const typeMul = window.TarinaiFoodRegistry?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1)); + return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; +} + +function itemVisualFor(type = "") { + return typeof itemVisualDefinition === "function" ? itemVisualDefinition(type) : null; +} + +function visualPaletteFor(type = "", fallback = {}) { + return itemVisualFor(type)?.palette || fallback || {}; +} + +function visualFieldScaleFor(type = "") { + const visual = itemVisualFor(type); + return visual?.fieldScale || visual?.scale || 1; +} + +function drawOvalWaterDropSprite(ctx, r, palette = {}) { + const fill = palette.fill || "rgba(86, 157, 224, 0.82)"; + const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)"; + const highlight = palette.highlight || "rgba(255,255,255,0.64)"; + ctx.save(); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1.6, r * 0.13); + // Horizontal oval item droplet silhouette for fallen water / zunda juice / mercury; rain overlay itself is a separate diagonal-line effect. + ctx.beginPath(); + ctx.ellipse(0, 0, r * 1.42, r * 0.56, -0.04, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.globalAlpha = 0.72; + ctx.fillStyle = highlight; + ctx.beginPath(); + ctx.ellipse(-r * 0.46, -r * 0.16, r * 0.34, r * 0.13, -0.18, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +function drawSleepTabletSprite(ctx, r, palette = {}) { + const fill = palette.fill || "#ebe4ff"; + const stroke = palette.stroke || "rgba(74, 60, 144, 0.72)"; + const accent = palette.accent || "rgba(112, 91, 188, 0.82)"; + ctx.save(); + ctx.rotate(-0.18); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1.8, r * 0.14); + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 0.52; + ctx.strokeStyle = "rgba(255,255,255,0.94)"; + ctx.lineWidth = Math.max(1.2, r * 0.08); + ctx.beginPath(); + ctx.moveTo(-r * 0.56, -r * 0.28); + ctx.quadraticCurveTo(-r * 0.14, -r * 0.48, r * 0.48, -r * 0.20); + ctx.stroke(); + ctx.restore(); +} + +function drawLaxativeTabletSprite(ctx, r) { + ctx.save(); + ctx.fillStyle = "#f4d49a"; + ctx.strokeStyle = "rgba(98, 61, 29, 0.82)"; + ctx.lineWidth = Math.max(1.8, r * 0.14); + roundedRect(ctx, -r * 1.02, -r * 0.54, r * 2.04, r * 1.08, r * 0.34); + ctx.fill(); ctx.stroke(); + // Embedded zunchi mound motif: silhouette-based, not color-only. + ctx.fillStyle = "rgba(96, 70, 38, 0.88)"; + ctx.strokeStyle = "rgba(56, 39, 22, 0.42)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + const lumps = [ + [-0.38, 0.11, 0.25, 0.17], + [-0.12, -0.03, 0.29, 0.20], + [0.20, 0.10, 0.27, 0.18], + [0.03, -0.23, 0.20, 0.14], + ]; + for (const [x,y,w,h] of lumps) { + ctx.beginPath(); + ctx.ellipse(r*x, r*y, r*w, r*h, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + ctx.globalAlpha = 0.45; + ctx.strokeStyle = "rgba(255,255,255,0.90)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + ctx.beginPath(); + ctx.moveTo(-r * 0.68, -r * 0.32); + ctx.quadraticCurveTo(-r * 0.24, -r * 0.48, r * 0.46, -r * 0.26); + ctx.stroke(); + ctx.restore(); +} + +function drawMysteryTabletSprite(ctx, r) { + ctx.save(); + ctx.fillStyle = "#f3ecff"; + ctx.strokeStyle = "rgba(91, 55, 150, 0.84)"; + ctx.lineWidth = Math.max(1.9, r * 0.15); + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.94, r * 0.78, 0.10, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(91,55,150,0.92)"; + ctx.font = `bold ${Math.round(r * 1.35)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("?", 0, -r * 0.04); + ctx.globalAlpha = 0.52; + ctx.strokeStyle = "rgba(255,255,255,0.90)"; + ctx.lineWidth = Math.max(1.1, r * 0.08); + ctx.beginPath(); + ctx.moveTo(-r * 0.52, -r * 0.34); + ctx.quadraticCurveTo(-r * 0.12, -r * 0.54, r * 0.42, -r * 0.28); + ctx.stroke(); + ctx.restore(); +} + +function drawBulletSprite(ctx, r) { + ctx.save(); + ctx.rotate(0.0); + const g = ctx.createLinearGradient(-r, 0, r, 0); + g.addColorStop(0, "#b06f23"); + g.addColorStop(0.48, "#f4c46a"); + g.addColorStop(1, "#a05a1f"); + ctx.fillStyle = g; + ctx.strokeStyle = "rgba(87, 51, 22, 0.78)"; + ctx.lineWidth = Math.max(1.7, r * 0.13); + ctx.beginPath(); + ctx.moveTo(r * 0.86, 0); + ctx.quadraticCurveTo(r * 0.42, -r * 0.52, -r * 0.34, -r * 0.52); + ctx.lineTo(-r * 0.88, -r * 0.40); + ctx.lineTo(-r * 0.88, r * 0.40); + ctx.lineTo(-r * 0.34, r * 0.52); + ctx.quadraticCurveTo(r * 0.42, r * 0.52, r * 0.86, 0); + ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(255,237,176,0.62)"; + ctx.lineWidth = Math.max(1.0, r * 0.07); + ctx.beginPath(); + ctx.moveTo(-r * 0.44, -r * 0.34); + ctx.lineTo(r * 0.42, -r * 0.20); + ctx.stroke(); + ctx.restore(); +} + +function machineDishPosition(machine) { + const r = machine?.r || 42; + return { x: machine.x, y: machine.y + r * 1.18 }; +} + +function machineMuzzlePosition(machine) { + const r = machine?.r || 42; + const a = Number.isFinite(machine?.cannonAngle) ? machine.cannonAngle : 0; + return { x: machine.x + Math.cos(a) * r * 0.98, y: machine.y + Math.sin(a) * r * 0.98 }; +} + +function makeMachineConsumableCopy(type, x, y, source = null, opts = {}) { + const it = new Item(type, x, y); + it.machineProduced = true; + it.machineProducerId = source?.id || ""; + it.sourceMachineType = source?.type || ""; + it.noDecay = true; + if (opts.infiniteServing) it.infiniteServing = true; + if (opts.projectile) { + it.cannonProjectile = true; + it.projectileSourceId = source?.id || ""; + it.projectileTimer = 0; + it.projectileHitMemo = {}; + } + if (typeof isServingFoodType === "function" && isServingFoodType(type)) { + it.toolSize = source?.machineSlotSize || "medium"; + it.foodServingScale = 1; + it.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize) : 5; + it.foodServingsRemaining = it.foodServingsMax; + it.amount = it.foodServingsMax; + } + if (type === "water") it.amount = 120; + if (type === "grass") { it.amount = Math.max(it.amount || 0, 120); it.growth = Math.max(it.growth || 0, 0.82); } + if (type === "zunchi") { it.amount = 240; it.stage = "fresh"; it.stageTimer = 0; it.freshness = 1; } + return it; +} class Item { constructor(type, x, y) { @@ -38,6 +239,18 @@ class Item { this.comfort = rand(0.86, 1.14); this.wear = 0; } + if (type === "signboard") { + this.text = ""; + this.textEditedAt = -999; + } + if (type === "duplicator" || type === "cannon") { + this.machineSlotType = ""; + this.machineSlotSize = "medium"; + this.machineSpawnTimer = rand(0.4, 1.1); + this.machineLastSlotType = ""; + this.cannonAngle = type === "cannon" ? -Math.PI * 0.25 : 0; + this.cannonFireTimer = rand(0.8, 1.6); + } if (type === "ball") { this.vx = rand(-5, 5); this.vy = rand(-5, 5); @@ -81,14 +294,37 @@ class Item { this.dropMax = 0; this.dropImpactDone = false; this.impactFlash = 0; + this.lingerTimer = 0; + this.lingerDuration = 2.0; + } + if (isPinType(type)) { + this.amount = 100; + this.vx = rand(-120, 120); + this.vy = rand(-90, 90); + this.prevX = x; + this.prevY = y; + this.spin = rand(-0.35, 0.35); + this.spinVelocity = rand(-5.4, 5.4); + this.pinState = "loose"; + this.pinTargetId = ""; + this.pinAttachAngle = 0; + this.pinAttachDistance = 0; + this.pinOffsetY = 0; + this.pinDamageTick = 0; + this.pinFallCheckTimer = 0; + this.pinLogAt = -999; + this.deletable = true; } if (isServingFoodType(type)) { this.toolSize = "medium"; this.foodServingScale = 1; this.foodServingsMax = foodServingsForSize(this.toolSize); this.foodServingsRemaining = this.foodServingsMax; + this.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval()); this.amount = this.foodServingsRemaining; } + if (window.TarinaiNeedSystem?.itemRolesFor) window.TarinaiNeedSystem.itemRolesFor(this); + if (window.TarinaiNeedSystem?.ensureItemNeedEffects) window.TarinaiNeedSystem.ensureItemNeedEffects(this); } update(dt, worldRef = world) { this.age += dt; @@ -100,8 +336,39 @@ class Item { if (worldRef.itemDropImpact) worldRef.itemDropImpact(this); } } - if (this.type === "water") this.amount -= dt * 0.9; - if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type) && !Number.isFinite(this.foodServingsRemaining)) this.amount -= dt * 0.12; + if (this.type === "water" && !this.noDecay && !this.infiniteServing) this.amount -= dt * 0.9; + if (isServingFoodType(this.type) && !this.noDecay && !this.infiniteServing) { + const interval = passiveFoodDecayInterval(worldRef); + this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt; + let ticks = 0; + while (this.passiveFoodDecayTimer >= interval && ticks < 3) { + this.passiveFoodDecayTimer -= interval; + ticks++; + if (Number.isFinite(this.foodServingsRemaining)) { + const before = Math.max(0, this.foodServingsRemaining || 0); + const lost = Math.min(before, interval * passiveFoodDecayRate(this)); + if (lost > 0) { + this.foodServingsRemaining = Math.max(0, before - lost); + this.amount = this.foodServingsRemaining; + const penalty = window.TarinaiFoodRegistry?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; + worldRef?.registerFoodSpoilage?.(lost * penalty, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); + if (this.foodServingsRemaining <= 0.015) this.amount = 0; + } + } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) { + const before = this.amount || 0; + const lost = Math.min(before, interval * 0.12); + this.amount -= lost; + if (lost > 0) { + const penalty = window.TarinaiFoodRegistry?.hygienePenalty?.(this.type) ?? 0.002; + worldRef?.registerFoodSpoilage?.(lost * penalty, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); + } + } + } + } if (this.type === "trace") this.amount -= dt * 1.35; if (this.type === "splat") this.amount -= dt * 1.05; if (this.type === "ant_corpse") this.amount -= dt * 0.018; @@ -114,11 +381,19 @@ class Item { } if (this.type === "genkotsu") { this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); - if (this.dropImpactDone) this.amount -= dt * 115; - else this.amount = Math.max(this.amount || 0, 100); + if (this.dropImpactDone) { + this.lingerTimer = Math.max(0, (this.lingerTimer || 0) - dt); + if ((this.lingerTimer || 0) <= 0) this.amount = 0; + } else { + this.amount = Math.max(this.amount || 0, 100); + } } if (this.type === "ball") this.updateBall(dt, worldRef); + if (isPinType(this.type)) this.updatePushpin(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); + if (this.cannonProjectile) this.updateConsumableProjectile(dt, worldRef); + if (this.type === "duplicator") this.updateDuplicator(dt, worldRef); + if (this.type === "cannon") this.updateCannon(dt, worldRef); if (this.type === "grass") { this.lifecycleTimer += dt; const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1; @@ -139,23 +414,208 @@ class Item { } } + machineDishPosition() { + return machineDishPosition(this); + } + + clearMachineProducedItems(worldRef, keepType = "") { + for (const it of worldRef?.items || []) { + if (!it || it.dead || it.machineProducerId !== this.id) continue; + if (keepType && it.type === keepType && this.type === "duplicator") continue; + it.amount = 0; + } + } + + updateDuplicator(dt, worldRef) { + if (!worldRef || !this.machineSlotType) return; + if (this.machineLastSlotType && this.machineLastSlotType !== this.machineSlotType) this.clearMachineProducedItems(worldRef); + this.machineLastSlotType = this.machineSlotType; + this.machineSpawnTimer = Math.max(0, (this.machineSpawnTimer || 0) - dt); + const dish = this.machineDishPosition(); + let serving = null; + for (const it of worldRef.items || []) { + if (!it || it.dead || it.machineProducerId !== this.id || it.type !== this.machineSlotType) continue; + serving = it; + break; + } + if (!serving && this.machineSpawnTimer <= 0) { + serving = makeMachineConsumableCopy(this.machineSlotType, dish.x, dish.y, this, { infiniteServing: true }); + serving.r = Math.max(6, (serving.r || 12) * 0.92); + worldRef.items.push(serving); + worldRef.itemCounts[serving.type] = (worldRef.itemCounts[serving.type] || 0) + 1; + this.machineSpawnTimer = 1.2; + worldRef.rebuildSpatial?.(true); + } + if (serving) { + serving.x = dish.x; + serving.y = dish.y; + serving.vx = 0; + serving.vy = 0; + serving.prevX = serving.x; + serving.prevY = serving.y; + serving.noDecay = true; + serving.infiniteServing = true; + serving.machineProduced = true; + serving.machineProducerId = this.id; + if (typeof isServingFoodType === "function" && isServingFoodType(serving.type)) { + if (!Number.isFinite(serving.foodServingsMax) || serving.foodServingsMax <= 0) serving.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(serving.toolSize || "medium") : 5; + serving.foodServingsRemaining = serving.foodServingsMax; + serving.amount = serving.foodServingsMax; + } else if (serving.type === "water") serving.amount = Math.max(serving.amount || 0, 120); + else if (serving.type === "grass") serving.amount = Math.max(serving.amount || 0, 120); + else if (serving.type === "zunchi") serving.amount = Math.max(serving.amount || 0, 240); + } + } + + updateCannon(dt, worldRef) { + if (!worldRef || !this.machineSlotType) return; + this.cannonFireTimer = Math.max(0, (this.cannonFireTimer || 0) - dt); + if (this.cannonFireTimer > 0) return; + this.cannonFireTimer = rand(2.2, 3.4); + const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; + const muzzle = machineMuzzlePosition(this); + const shot = makeMachineConsumableCopy(this.machineSlotType, muzzle.x, muzzle.y, this, { projectile: true }); + shot.r = Math.max(5, (shot.r || 12) * 0.78); + const speed = rand(470, 620); + shot.vx = Math.cos(a) * speed + rand(-18, 18); + shot.vy = Math.sin(a) * speed + rand(-18, 18); + shot.prevX = shot.x; + shot.prevY = shot.y; + shot.spinVelocity = rand(-6, 6) + speed / Math.max(8, shot.r || 10) * 0.12; + shot.amount = Math.max(shot.amount || 0, typeof isServingFoodType === "function" && isServingFoodType(shot.type) ? (shot.foodServingsMax || 5) : itemAmountFor(shot.type, 60)); + worldRef.items.push(shot); + worldRef.itemCounts[shot.type] = (worldRef.itemCounts[shot.type] || 0) + 1; + worldRef.effects?.push(new Effect("ring", muzzle.x, muzzle.y, { size: Math.max(16, (this.r || 42) * 0.42), life: 0.22, color: "rgba(255,238,160,0.52)" })); + worldRef.log?.(`大砲が${toolLabel(shot.type)}を撃ち出した。`, "accident"); + audio.place?.(shot.type); + worldRef.rebuildSpatial?.(true); + } + + updateConsumableProjectile(dt, worldRef) { + if (!worldRef) return; + this.projectileTimer = (this.projectileTimer || 0) + dt; + this.prevX = this.x; + this.prevY = this.y; + const speedBefore = Math.hypot(this.vx || 0, this.vy || 0); + if (speedBefore > 0.05) { + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 28, bounce: 0.46, frictionBase: 0.62, frictionRate: 1.25, spin: false, stopSpeed: 0.12, zeroBelow: 14 }); + } else { + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.46; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.46; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.46; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.46; } + const slow = Math.pow(0.62, dt * 1.25); + this.vx *= slow; + this.vy *= slow; + } + this.spin = (this.spin || 0) + speedBefore * dt / Math.max(6, this.r || 10); + this.resolveProjectileTarinaiCollision(dt, worldRef, speedBefore); + } + const speedAfter = Math.hypot(this.vx || 0, this.vy || 0); + if (speedAfter < 18 && (this.projectileTimer || 0) > 0.30) { + this.cannonProjectile = false; + this.noDecay = false; + this.projectileHitMemo = {}; + this.vx = 0; + this.vy = 0; + } + if ((this.projectileTimer || 0) > 28) this.cannonProjectile = false; + } + + resolveProjectileTarinaiCollision(dt, worldRef, speed = 0) { + if (!worldRef || speed < 210 || (this.amount || 0) <= 0) return; + const range = Math.max(42, (this.r || 12) + speed * Math.max(dt || 0.016, 0.016) + 22); + for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (this.r || 12)) continue; + const now = worldRef.time || 0; + this.projectileHitMemo = this.projectileHitMemo || {}; + if (this.projectileHitMemo[t.id] && this.projectileHitMemo[t.id] + 0.58 > now) continue; + this.projectileHitMemo[t.id] = now; + const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 0.95, 130, 560); + t.vx = (t.vx || 0) + nx * impulse + rand(-18, 18); + t.vy = (t.vy || 0) + ny * impulse * 0.70 + rand(-14, 12); + t.fallTimer = Math.max(t.fallTimer || 0, 0.72); + t.fallMax = Math.max(t.fallMax || 0.72, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + const label = toolLabel(this.type) || "食べ物"; + if (t.enterPanic) t.enterPanic({ threat: this, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, fear: 0.72, stress: 6, stressDuration: 3.0, surpriseTimer: 0.48, cause: "cannon_projectile_hit" }); + else t.setActionState?.("panic", { target: { x: this.x, y: this.y }, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, wake: true }); + if (worldRef.applyImpactDamage) worldRef.applyImpactDamage(t, clamp((speed - 190) / 30, 1.4, 16), "大砲"); + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.50, 0.78); + worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(17, (this.r || 12) * 1.15), life: 0.22, color: "rgba(220,160,70,0.52)" })); + if (worldRef.relationNotice?.(t.id, this.id || "projectile", "cannon-projectile-hit", 2.8)) worldRef.log?.(`${t.name}は大砲から飛んできた${label}にぶつかって吹き飛ばされた。`, "accident", { participants: [t] }); + this.vx *= -0.12; + this.vy *= -0.12; + this.cannonProjectile = false; + this.noDecay = false; + break; + } + } + updateZunchiMotion(dt, worldRef) { const speed = Math.hypot(this.vx || 0, this.vy || 0); if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } - const slow = Math.pow(0.68, dt * 2.4); - this.vx *= slow; - this.vy *= slow; + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); + } else { + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } + const slow = Math.pow(0.68, dt * 2.4); + this.vx *= slow; + this.vy *= slow; + worldRef.drawListDirty = true; + } + this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed); this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); - worldRef.drawListDirty = true; + } + + resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { + if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return; + const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); + for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue; + const now = worldRef.time || 0; + if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue; + this.lastHitTarinaiAt = this.lastHitTarinaiAt || {}; + this.lastHitTarinaiAt[t.id] = now; + const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 1.15, 150, 620); + t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); + t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); + t.fallTimer = Math.max(t.fallTimer || 0, 0.85); + t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); + else { + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); + t.addStress ? t.addStress(7, { threshold: 8, duration: 3.2 }) : (t.stress = clamp((t.stress || 0) + 7, 0, 130)); + } + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); + worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + this.vx *= -0.18; + this.vy *= -0.18; + break; + } } updateBall(dt, worldRef) { @@ -360,6 +820,171 @@ class Item { worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" })); } + updatePushpin(dt, worldRef) { + if (!worldRef) return; + if (this.pinState === "lodged") { + this.updateLodgedPushpin(dt, worldRef); + return; + } + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); + } else { + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + } + this.tryStickPushpin(worldRef); + } + + tryStickPushpin(worldRef) { + if (!worldRef?.tarinai?.length) return false; + if (this.pinState === "lodged") return false; + let best = null; + let bestD = Infinity; + const hitRange = Math.max(10, (this.r || 8) * 1.25); + for (const t of worldRef.tarinai) { + if (!t || t.dead) continue; + const d = distXY(this.x, this.y, t.x, t.y); + const hit = (t.radius || 16) * 0.86 + hitRange; + if (d <= hit && d < bestD) { best = t; bestD = d; } + } + if (!best) return false; + return this.attachPushpin(best, worldRef, bestD); + } + + attachPushpin(t, worldRef, d = null) { + if (!t || t.dead) return false; + if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + const dx = this.x - t.x; + const dy = this.y - t.y; + const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); + this.pinState = "lodged"; + this.deletable = false; + this.pinTargetId = t.id; + const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; + this.pinAttachAngle = isOshibyo ? (visualSide < 0 ? -0.48 : 0.48) : Math.atan2(dy || -1, dx || visualSide); + this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.76 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.22 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + this.pinFlipX = visualSide; + this.pinDamageTick = 0; + this.pinFallCheckTimer = 0; + this.vx = 0; + this.vy = 0; + this.spinVelocity = 0; + this.spin = this.pinAttachAngle; + t.stuckPushpinId = this.id; + t.sleeping = false; + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); + if (isOshibyo) { + t.thought = "おしり鋲が刺さってずんちが出なくなった"; + worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] }); + worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + return true; + } + if (t.enterPanic) { + t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); + } else { + t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); + t.fearTimer = Math.max(t.fearTimer || 0, 1.3); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); + t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130)); + } + if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); + if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { + this.pinLogAt = worldRef.time || 0; + worldRef.log?.(`${t.name}に画鋲が刺さった。`, "accident", { participants: [t] }); + } + worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); + return true; + } + + updateLodgedPushpin(dt, worldRef) { + const t = worldRef.tarinai?.find(o => o && !o.dead && o.id === this.pinTargetId) || null; + if (!t) { + this.detachPushpin(worldRef, "owner-lost"); + return; + } + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + t.stuckPushpinId = this.id; + this.deletable = false; + const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1; + this.pinFlipX = visualSide; + this.x = t.x + visualSide * (isOshibyo ? (t.radius || 16) * 0.72 : (t.radius || 16) * 0.36); + this.y = isOshibyo ? t.y + (t.radius || 16) * 0.22 : t.y - (t.radius || 16) * 0.04; + this.prevX = this.x; + this.prevY = this.y; + this.spin = (isOshibyo ? 0.42 : 0.28) * (this.pinFlipX < 0 ? -1 : 1); + this.vx = t.vx || 0; + this.vy = t.vy || 0; + if (isOshibyo) { + if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "おしり鋲でずんちが溜まってつらい"; + return; + } + this.pinDamageTick = (this.pinDamageTick || 0) + dt; + while (this.pinDamageTick >= 0.55) { + this.pinDamageTick -= 0.55; + if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲"); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); + t.addStress ? t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressPerTick ?? 2.6), 0, 130)); + if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); + } + t.sleeping = false; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); + else { + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); + t.fearTimer = Math.max(t.fearTimer || 0, 0.65); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); + } + this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; + while (this.pinFallCheckTimer >= 1.0) { + this.pinFallCheckTimer -= 1.0; + if (Math.random() < 0.10) { + this.detachPushpin(worldRef, "fall"); + return; + } + } + } + + detachPushpin(worldRef, reason = "released") { + const t = worldRef?.tarinai?.find(o => o && o.id === this.pinTargetId) || null; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const wasOshibyo = Boolean(behavior?.blocksZunchi); + const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; + if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; + if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; + this.pinState = "loose"; + this.pinTargetId = ""; + this.pinDamageTick = 0; + this.pinFallCheckTimer = 0; + this.deletable = true; + if (reason === "pinch") { + this.vx = 0; + this.vy = 0; + this.spinVelocity = 0; + } else { + this.vx = rand(-24, 24); + this.vy = rand(-10, 18); + this.spinVelocity = rand(-1.6, 1.6); + this.spin += rand(-0.25, 0.25); + if (t) { + this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30)); + this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30)); + } + worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); + if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); + } + + if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { + worldRef.spawnBurstZunchi(t || this, storedZunchi, this); + } + } + updateGrass(dt, worldRef) { const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7; const weather = worldRef.weather || "sunny"; @@ -571,21 +1196,88 @@ class Item { ctx.fill(); } } + } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { + const visual = itemVisualFor(this.type); + const palette = visual?.palette || {}; + const renderer = visual?.renderer || "split_pill"; + const meta = { + fill: palette.fill || "#fffaf1", + stroke: palette.stroke || "#8d6f48", + accent: palette.highlight || palette.accent || "#ddd", + kind: renderer === "powder_pile" ? "powder" : renderer === "oval_droplet" ? "droplet" : renderer === "laxative_tablet" ? "laxative" : renderer === "mystery_tablet" ? "mystery" : renderer === "bullet" ? "bullet" : "pill", + }; + const localR = this.r * (visual?.fieldScale || visual?.scale || 1); + ctx.rotate(Math.sin(this.seed) * 0.10); + if (meta.kind === "powder") { + ctx.save(); + ctx.fillStyle = "rgba(68,46,28,0.18)"; + ctx.beginPath(); + ctx.ellipse(0, localR * 0.50, localR * 1.22, localR * 0.34, 0, 0, Math.PI * 2); + ctx.fill(); + for (let i = 0; i < 18; i++) { + const px = randSeed(this.seed + i * 7, -localR * 0.85, localR * 0.85); + const layer = 1 - Math.abs(px) / Math.max(1, localR * 0.90); + const py = randSeed(this.seed + i * 11, -localR * 0.15, localR * 0.48) - layer * localR * 0.48; + const rr = randSeed(this.seed + i * 13, localR * 0.14, localR * 0.32) * (0.75 + layer * 0.45); + ctx.fillStyle = i % 3 === 0 ? meta.accent : meta.fill; + ctx.strokeStyle = meta.stroke; + ctx.globalAlpha = 0.86 + layer * 0.12; + ctx.lineWidth = 1.0; + ctx.beginPath(); + ctx.ellipse(px, py, rr * 1.18, rr * 0.72, randSeed(this.seed + i, -0.45, 0.45), 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + ctx.globalAlpha = 1; + ctx.fillStyle = "rgba(255,255,255,0.62)"; + ctx.beginPath(); + ctx.ellipse(-localR * 0.30, -localR * 0.42, localR * 0.28, localR * 0.10, -0.35, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } else if (meta.kind === "droplet") { + drawOvalWaterDropSprite(ctx, localR, { fill: meta.fill, stroke: meta.stroke, highlight: meta.accent }); + } else if (meta.kind === "laxative") { + drawLaxativeTabletSprite(ctx, localR); + } else if (meta.kind === "mystery") { + drawMysteryTabletSprite(ctx, localR); + } else if (meta.kind === "bullet") { + drawBulletSprite(ctx, localR); + } else if (meta.kind === "box") { + ctx.fillStyle = meta.fill; + ctx.strokeStyle = meta.stroke; + ctx.lineWidth = 2; + roundedRect(ctx, -localR * 0.96, -localR * 0.78, localR * 1.92, localR * 1.56, localR * 0.24); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = meta.accent; + for (let i = 0; i < 5; i++) { + ctx.beginPath(); + ctx.ellipse(randSeed(this.seed + i, -localR * 0.50, localR * 0.50), randSeed(this.seed + i + 5, -localR * 0.30, localR * 0.34), localR * 0.18, localR * 0.34, 0.28, 0, Math.PI * 2); + ctx.fill(); + } + ctx.strokeStyle = "rgba(110,76,30,0.52)"; + ctx.beginPath(); + ctx.moveTo(-localR * 0.70, localR * 0.50); + ctx.lineTo(localR * 0.70, -localR * 0.50); + ctx.stroke(); + } else { + ctx.save(); + ctx.fillStyle = meta.fill; + ctx.strokeStyle = meta.stroke; + ctx.lineWidth = 2.2; + ctx.rotate(-0.38); + roundedRect(ctx, -localR * 1.15, -localR * 0.46, localR * 2.30, localR * 0.92, localR * 0.46); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = meta.accent; + ctx.globalAlpha = 0.78; + ctx.fillRect(-1.5, -localR * 0.42, 3.0, localR * 0.84); + ctx.globalAlpha = 0.36; + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.beginPath(); + ctx.ellipse(-localR * 0.48, -localR * 0.16, localR * 0.32, localR * 0.10, -0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } } else if (this.type === "sleep_drug") { - ctx.fillStyle = "#f5f0ff"; - ctx.strokeStyle = "rgba(112, 83, 170, 0.55)"; - ctx.lineWidth = 2; - ctx.rotate(-0.45); - roundedRect(ctx, -this.r * 0.95, -this.r * 0.45, this.r * 1.90, this.r * 0.90, this.r * 0.45); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(152, 116, 218, 0.58)"; - ctx.fillRect(-1, -this.r * 0.42, 2, this.r * 0.84); - ctx.rotate(0.45); - ctx.fillStyle = "rgba(74,58,104,0.72)"; - ctx.font = `${Math.round((this.r || 13) * 0.78)}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("Z", 0, this.r * 1.18); + drawSleepTabletSprite(ctx, this.r * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug")); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); @@ -622,30 +1314,138 @@ class Item { ctx.fill(); } ctx.restore(); + } else if (this.type === "duplicator" || this.type === "cannon") { + const r = this.r || 42; + const isCannon = this.type === "cannon"; + ctx.save(); + ctx.shadowColor = "rgba(40,35,30,0.24)"; + ctx.shadowBlur = 6; + ctx.shadowOffsetY = 3; + if (!isCannon) { + // Upper machine body. + const g = ctx.createLinearGradient(0, -r * 1.0, 0, r * 0.32); + g.addColorStop(0, "#e7f1ff"); + g.addColorStop(1, "#8aa2bd"); + ctx.fillStyle = g; + ctx.strokeStyle = "rgba(48,68,92,0.70)"; + ctx.lineWidth = Math.max(2, r * 0.06); + roundedRect(ctx, -r * 0.82, -r * 1.05, r * 1.64, r * 0.92, r * 0.18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = this.machineSlotType ? "rgba(100,214,102,0.92)" : "rgba(255,180,72,0.92)"; + ctx.beginPath(); ctx.arc(r * 0.48, -r * 0.76, r * 0.12, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = "rgba(42,55,68,0.54)"; + ctx.lineWidth = Math.max(1.6, r * 0.04); + ctx.beginPath(); ctx.moveTo(-r * 0.48, -r * 0.78); ctx.lineTo(r * 0.20, -r * 0.78); ctx.stroke(); + // Plate for tarinai below. + ctx.fillStyle = "rgba(245,244,233,0.96)"; + ctx.strokeStyle = "rgba(116,96,75,0.58)"; + ctx.lineWidth = Math.max(2, r * 0.05); + ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.95, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(196,180,154,0.22)"; + ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.58, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); + if (this.machineSlotType) { + ctx.fillStyle = "rgba(42,36,28,0.72)"; + ctx.font = `${Math.max(10, Math.round(r * 0.25))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(toolLabel(this.machineSlotType), 0, r * 0.52); + } + } else { + const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0; + // Base plate. + ctx.fillStyle = "#6c7685"; + ctx.strokeStyle = "rgba(42,48,58,0.72)"; + ctx.lineWidth = Math.max(2, r * 0.06); + ctx.beginPath(); ctx.ellipse(0, r * 0.72, r * 0.78, r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + // Barrel. + ctx.rotate(a); + const bg = ctx.createLinearGradient(-r * 0.34, -r * 0.26, r * 1.08, r * 0.26); + bg.addColorStop(0, "#596575"); + bg.addColorStop(0.55, "#2f3946"); + bg.addColorStop(1, "#1f2630"); + ctx.fillStyle = bg; + ctx.strokeStyle = "rgba(16,20,26,0.82)"; + roundedRect(ctx, -r * 0.28, -r * 0.26, r * 1.32, r * 0.52, r * 0.18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(10,12,16,0.88)"; + ctx.beginPath(); ctx.ellipse(r * 1.02, 0, r * 0.16, r * 0.23, 0, 0, Math.PI * 2); ctx.fill(); + ctx.rotate(-a); + ctx.fillStyle = "#8793a5"; + ctx.beginPath(); ctx.arc(0, 0, r * 0.36, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + if (this.machineSlotType) { + ctx.fillStyle = "rgba(42,36,28,0.72)"; + ctx.font = `${Math.max(10, Math.round(r * 0.23))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(toolLabel(this.machineSlotType), 0, -r * 0.62); + } + } + ctx.restore(); + } else if (isPinType(this.type)) { + const stuck = this.pinState === "lodged"; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); + const isOshibyo = Boolean(behavior?.blocksZunchi); + const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); + ctx.save(); + if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); + else if ((this.pinFlipX || 1) < 0) ctx.scale(-1, 1); + ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)"; + ctx.shadowBlur = 3; + ctx.shadowOffsetY = 2; + if (img) { + const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; + const ratio = metrics?.ratio || (stuck ? 1 : 2.0); + const w = isOshibyo ? (stuck ? this.r * 2.45 : this.r * 1.25) : (stuck ? this.r * 2.15 : this.r * 1.78); + const h = w * ratio; + ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); + } else if (stuck) { + ctx.fillStyle = "#d92736"; + ctx.strokeStyle = "rgba(125,18,30,0.78)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2); + ctx.fillStyle = "rgba(176,20,30,0.82)"; + ctx.fill(); + } else { + ctx.fillStyle = "#d92736"; + ctx.strokeStyle = "rgba(125,18,30,0.78)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(210,210,215,0.92)"; + ctx.lineWidth = Math.max(1.5, this.r * 0.12); + ctx.beginPath(); + ctx.moveTo(0, this.r * 0.42); + ctx.lineTo(0, this.r * 1.65); + ctx.stroke(); + } + ctx.restore(); } else if (this.type === "water_bowl") { - ctx.fillStyle = "rgba(171, 120, 76, 0.72)"; - ctx.strokeStyle = "rgba(94, 69, 48, 0.55)"; + const palette = visualPaletteFor("water_bowl"); + ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; + ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(86, 157, 224, 0.58)"; - ctx.strokeStyle = "rgba(62, 113, 178, 0.36)"; + ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; + ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.54)"; + ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "water") { - ctx.fillStyle = "rgba(86, 157, 224, 0.50)"; - ctx.strokeStyle = "rgba(62, 113, 178, 0.42)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.55)"; - ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill(); + drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); } else if (this.type === "grass") { const growth = this.growth ?? clamp(this.amount / 120, 0, 1); const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; @@ -729,6 +1529,36 @@ class Item { ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "signboard") { + ctx.save(); + ctx.fillStyle = styleNight ? "#6d543b" : (styleWarm ? "#a67848" : "#8d633e"); + ctx.strokeStyle = styleNight ? "#34281c" : "#573c27"; + ctx.lineWidth = 2.2; + ctx.fillRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); + ctx.strokeRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); + roundedRect(ctx, -this.r * 1.18, -this.r * 1.18, this.r * 2.36, this.r * 1.18, this.r * 0.14); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.16)"; + ctx.fillRect(-this.r * 0.92, -this.r * 0.96, this.r * 1.02, this.r * 0.14); + const raw = String(this.text || "").replace(/\s+/g, " ").trim(); + if (raw) { + const charsPerLine = 7; + const lines = []; + for (let i = 0; i < raw.length && lines.length < 2; i += charsPerLine) lines.push(raw.slice(i, i + charsPerLine)); + ctx.fillStyle = styleNight ? "rgba(255,249,236,0.92)" : "rgba(51,34,19,0.92)"; + ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.38))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + lines.forEach((line, index) => ctx.fillText(line, 0, -this.r * 0.68 + index * this.r * 0.34)); + } else { + ctx.fillStyle = styleNight ? "rgba(255,249,236,0.62)" : "rgba(51,34,19,0.50)"; + ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.30))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("…", 0, -this.r * 0.58); + } + ctx.restore(); } else if (this.type === "ant_nest") { const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); diff --git a/js/main.js b/js/main.js index 3b9286f..0c32d50 100644 --- a/js/main.js +++ b/js/main.js @@ -7,6 +7,55 @@ let perfResizeTimer = 0; let lastPerfLevel = 0; window.__tarinaiFps = 0; +const LOADING_SPRITES = [ + "assets/sprites/tarinai_01_smile.webp", + "assets/sprites/tarinai_02_angry.webp", + "assets/sprites/tarinai_03_teary.webp", + "assets/sprites/tarinai_04_jito.webp", + "assets/sprites/tarinai_05_drool.webp", + "assets/sprites/tarinai_06_cry.webp", + "assets/sprites/tarinai_07_sleep.webp", + "assets/sprites/tarinai_08_pokan.webp", + "assets/sprites/tarinai_11_weak.webp", + "assets/sprites/tarinai_21_normal_happy.webp", + "assets/sprites/tarinai_29_hungry_70.webp", +]; +let loadingSpriteTimer = 0; + +function shuffledLoadingSprites() { + const list = [...LOADING_SPRITES]; + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } + return list.slice(0, 4); +} + +function renderLoadingSprites() { + const strip = document.getElementById("loadingSpriteStrip"); + if (!strip) return; + strip.innerHTML = ""; + const frag = document.createDocumentFragment(); + for (const src of shuffledLoadingSprites()) { + const card = document.createElement("div"); + card.className = "loading-sprite-card"; + const img = document.createElement("img"); + img.src = src; + img.alt = ""; + img.loading = "eager"; + card.appendChild(img); + frag.appendChild(card); + } + strip.appendChild(frag); +} + +function initLoadingShowcase() { + renderLoadingSprites(); + if (loadingSpriteTimer) clearInterval(loadingSpriteTimer); + loadingSpriteTimer = window.setInterval(renderLoadingSprites, 1200); +} + + function setLoadingProgress(text = "", ratio = 0) { const screen = document.getElementById("loadingScreen"); if (!screen) return; @@ -20,6 +69,7 @@ function setLoadingProgress(text = "", ratio = 0) { } function hideLoadingScreen() { + if (loadingSpriteTimer) { clearInterval(loadingSpriteTimer); loadingSpriteTimer = 0; } setLoadingProgress("\u8d77\u52d5\u5b8c\u4e86", 1); const screen = document.getElementById("loadingScreen"); if (!screen) return; @@ -34,6 +84,7 @@ function loop() { fpsFrames += 1; if (fpsTimer >= 0.5) { window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001)); + window.TarinaiPerformance?.updateFps?.(window.__tarinaiFps, world); fpsTimer = 0; fpsFrames = 0; } @@ -49,7 +100,7 @@ function loop() { } perfResizeTimer = 0; } - const statsInterval = perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48; + const statsInterval = world.performanceQuality?.().statsInterval ?? (perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48); if (statsTimer > statsInterval) { renderStats(); statsTimer = 0; @@ -57,6 +108,7 @@ function loop() { requestAnimationFrame(loop); } +initLoadingShowcase(); setLoadingProgress("\u521d\u671f\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u307f\u4e2d", 0.08); loadImages(null, (done, total) => { const p = total > 0 ? done / total : 1; @@ -79,3 +131,12 @@ loadImages(null, (done, total) => { loop(); requestAnimationFrame(hideLoadingScreen); }); + +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.30.0"; + navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); + }); +} + + diff --git a/js/need_behavior_system.js b/js/need_behavior_system.js new file mode 100644 index 0000000..de476ba --- /dev/null +++ b/js/need_behavior_system.js @@ -0,0 +1,567 @@ +"use strict"; + +(function (global) { + const NEED_KEYS = Object.freeze(["food", "sleep", "health", "safety", "social", "fulfill"]); + const NEED_LABELS = Object.freeze({ + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", + }); + const NEED_PHRASES = Object.freeze({ + food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", + sleep: "\u306d\u3080\u3044", + health: "\u8abf\u5b50\u304c\u60aa\u3044", + safety: "\u3042\u3076\u306a\u3044", + social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", + fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b", + }); + const NEED_PRIORITY = Object.freeze(["safety", "health", "food", "sleep", "social", "fulfill"]); + + function createId(prefix = "id") { + if (global.crypto?.randomUUID) return `${prefix}_${global.crypto.randomUUID()}`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`; + } + + function createDefaultNeeds() { + return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + } + + function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); + } + + function getNeedDisplayValue(value) { + return Math.round(quantizeNeed(value) / 10); + } + + function personalityValue(t, key) { + if (typeof global.effectivePersonalityValue === "function") return Number(global.effectivePersonalityValue(t, key)) || 0; + return Number(t?.currentPersonality?.[key] ?? t?.birthPersonality?.[key] ?? 0) || 0; + } + + function needPressure(value) { + const x = clamp((Number(value) || 0) / 100, 0, 1); + return x * x * 100; + } + + function deriveStressFromNeeds(needs = {}) { + return clamp( + needPressure(needs.food) * 0.10 + + needPressure(needs.sleep) * 0.12 + + needPressure(needs.health) * 0.25 + + needPressure(needs.safety) * 0.30 + + needPressure(needs.social) * 0.08 + + needPressure(needs.fulfill) * 0.15, + 0, + 130 + ); + } + + function itemRolesFor(item) { + if (!item) return {}; + if (!item.roles) item.roles = {}; + const type = String(item.type || ""); + const roles = item.roles; + if (["sweet", "food", "grass", "ant_corpse", "zunchi", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(type)) roles.food = true; + if (type === "water" || type === "water_bowl") roles.drink = true; + if (["sweet", "protein", "water", "water_bowl", "zunda_juice"].includes(type)) roles.medicine = true; + if (type === "bed" || type === "nest_box") roles.sleepPlace = true; + if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat", "zunchi", "ant_nest", "cannon"].includes(type)) roles.danger = true; + if (type === "grass") roles.grassMaterial = true; + if (type === "ball") roles.playObject = true; + if (item.isStructure) roles.ownedStructure = true; + return roles; + } + + function ensureItemNeedEffects(item) { + if (!item) return createDefaultNeeds(); + if (!item.needEffects) item.needEffects = createDefaultNeeds(); + return item.needEffects; + } + + function findNearestItemWithRole(world, t, role, maxDist = 640, predicate = null) { + let best = null; + let bestD = maxDist; + const items = Number.isFinite(maxDist) && world?.nearbyItems ? world.nearbyItems(t.x, t.y, maxDist) : (world?.items || []); + for (const it of items) { + if (!it || it.dead || (it.amount != null && it.amount <= 0)) continue; + const roles = itemRolesFor(it); + if (!roles[role]) continue; + if (predicate && !predicate(it)) continue; + if (t?.shouldAvoidTarget?.(it)) continue; + const d = distXY(t.x, t.y, it.x, it.y); + if (d < bestD) { best = it; bestD = d; } + } + return best; + } + + function findNearestSleepPlace(world, t, maxDist = 560) { + const own = findOwnedStructure(world, t, "grass_bed", maxDist); + return own || findNearestItemWithRole(world, t, "sleepPlace", maxDist); + } + + function findNearbyDanger(world, t, maxDist = 180) { + const ant = (world?.nearbyAnts?.(t.x, t.y, maxDist, true) || []).find(a => a && !a.dead); + if (ant) return ant; + return findNearestItemWithRole(world, t, "danger", maxDist); + } + + function findNearbyMaterial(world, t, materialRole, maxDist = 520) { + return findNearestItemWithRole(world, t, materialRole, maxDist); + } + + function ownedStructures(world, t, type = "") { + return (world?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id && (!type || it.type === type)); + } + + function findOwnedStructure(world, t, type = "", maxDist = Infinity) { + let best = null; + let bestD = maxDist; + for (const it of ownedStructures(world, t, type)) { + const d = distXY(t.x, t.y, it.x, it.y); + if (d < bestD) { best = it; bestD = d; } + } + return best; + } + + function hasOwnedStructure(world, t, type) { + return Boolean(findOwnedStructure(world, t, type)); + } + + function canActNormally(t) { + return !t.dead && t.state !== "sleep" && t.state !== "fight" && t.state !== "panic" && t.state !== "intimidate" && t.birthRitualTimer <= 0.04 && t.fightTimer <= 0.04 && t.defeatedTimer <= 0.04; + } + + function updateAttachedStructures(world, t) { + for (const s of ownedStructures(world, t)) { + if (s.type !== "plushie" || s.carriedById !== t.id || !s.onHead) continue; + s.x = t.x + (t.facingDir ? t.facingDir() : 1) * t.radius * 0.12; + s.y = t.y - t.radius * 1.10; + s.amount = Math.max(1, s.hp || 1); + if (t.needs) t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 0.08) }; + } + } + + function updateNeeds(t, world) { + const prev = t.needsInitialized && t.needs ? { ...t.needs } : null; + const pAgg = personalityValue(t, "aggression"); + const pOpen = personalityValue(t, "openness"); + const pSoc = personalityValue(t, "sociability"); + const pNeu = personalityValue(t, "neuroticism"); + const raw = createDefaultNeeds(); + raw.food = clamp((t.hunger || 0) * 0.88 + (findNearestItemWithRole(world, t, "drink", 120) ? 6 : 0), 0, 100); + const dayP = world?.dayProgress?.() ?? 0.5; + const night = dayP >= 0.62 || dayP < 0.18 ? 12 : 0; + raw.sleep = clamp((100 - (t.energy || 0)) * 0.98 + night, 0, 100); + raw.health = clamp( + (t.zunchiDisease ? 38 : 0) + (t.sleepDisease ? 22 : 0) + (t.explosionDisease ? 30 : 0) + (t.fightDisease ? 34 : 0) + + clamp((100 - (t.energy || 100)) * 0.22, 0, 26) + clamp((t.zunchiStain || 0) * 0.34, 0, 34) + Number(t.needShock?.health || 0), + 0, + 100 + ); + const danger = findNearbyDanger(world, t, 210); + const recentDamage = (world?.time || 0) - (t.lastDamageAt || -999) < 8 ? clamp((t.lastDamageAmount || 0) * 2.4, 0, 52) : 0; + const fear = clamp((t.fearTimer || 0) * 22, 0, 45); + raw.safety = clamp((danger ? 42 : 0) + recentDamage + fear + Number(t.needShock?.safety || 0), 0, 100); + raw.social = clamp((t.loneliness || 0) * (0.78 + Math.max(0, pSoc) * 0.16) + (t.reproductionTimer <= 4 ? 18 : 0), 0, 100); + const hasBed = hasOwnedStructure(world, t, "grass_bed"); + const hasPlushie = hasOwnedStructure(world, t, "plushie"); + const play = findNearestItemWithRole(world, t, "playObject", 280) ? 10 : 0; + raw.fulfill = clamp((hasBed ? 0 : 26) + (hasPlushie ? 0 : 20) + play + Math.max(0, pOpen) * 16 + Number(t.needShock?.fulfill || 0), 0, 100); + raw.safety *= clamp(1 - Math.max(0, pAgg) * 0.12 + Math.max(0, pNeu) * 0.16, 0.78, 1.22); + raw.health *= clamp(1 + Math.max(0, pNeu) * 0.12, 0.9, 1.16); + raw.social *= clamp(1 + Math.max(0, pSoc) * 0.12, 0.9, 1.16); + raw.fulfill *= clamp(1 + Math.max(0, pOpen) * 0.12, 0.9, 1.18); + const needs = createDefaultNeeds(); + for (const key of NEED_KEYS) needs[key] = quantizeNeed(raw[key]); + t.previousNeeds = prev; + t.needs = needs; + t.needsInitialized = true; + t.stress = deriveStressFromNeeds(needs); + handleNeedShockReaction(t, prev, needs); + return needs; + } + + function handleNeedShockReaction(t, prev, needs, breaker = null) { + if (!prev || !needs || t.birthRitualTimer > 0.04 || t.fightTimer > 0.04) return false; + const safetyDelta = (needs.safety || 0) - (prev.safety || 0); + const healthDelta = (needs.health || 0) - (prev.health || 0); + const fulfillDelta = (needs.fulfill || 0) - (prev.fulfill || 0); + if (safetyDelta < 40 && healthDelta < 45 && fulfillDelta < 50) return false; + const attacker = breaker || t.needShockBreaker || null; + if (attacker && personalityValue(t, "aggression") > 0.6) { + t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.6); + t.intimidateTargetId = attacker.id || null; + t.setActionState?.("intimidate", { target: attacker, reason: "\u5927\u4e8b\u306a\u3082\u306e\u3092\u58ca\u3055\u308c\u3066\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true }); + } else { + t.panicTimer = Math.max(t.panicTimer || 0, 3.0); + t.enterPanic?.({ target: t.panicDestination?.(attacker, true) || null, reason: "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.8, wake: true, cause: "need_shock" }); + } + t.needShockBreaker = null; + return true; + } + + function addNeedShock(t, values = {}, breaker = null) { + if (!t) return; + const shock = { ...(t.needShock || {}) }; + for (const key of NEED_KEYS) shock[key] = clamp(Number(shock[key] || 0) + Number(values[key] || 0), 0, 100); + t.needShock = shock; + t.needShockBreaker = breaker || t.needShockBreaker || null; + const prev = t.needsInitialized && t.needs ? { ...t.needs } : null; + updateNeeds(t, t.world); + handleNeedShockReaction(t, prev, t.needs, breaker); + } + + function decayNeedShock(t, dt) { + if (!t?.needShock) return; + for (const key of NEED_KEYS) t.needShock[key] = Math.max(0, Number(t.needShock[key] || 0) - dt * (key === "fulfill" ? 2.2 : 2.8)); + } + + function chooseTopNeedRandom(needs) { + const maxNeed = Math.max(...NEED_KEYS.map(key => Number(needs?.[key] || 0))); + const tiedNeeds = NEED_KEYS.filter(key => Number(needs?.[key] || 0) === maxNeed); + return tiedNeeds[Math.floor(Math.random() * tiedNeeds.length)] || "fulfill"; + } + + function weightedRandom(items) { + const total = items.reduce((sum, a) => sum + Math.max(0.01, Number(a.weight || 1)), 0); + let pickPoint = Math.random() * total; + for (const item of items) { + pickPoint -= Math.max(0.01, Number(item.weight || 1)); + if (pickPoint <= 0) return item; + } + return items[items.length - 1] || null; + } + + function buildReasonText(chosenNeed, tiedNeeds, action) { + const tied = Array.isArray(tiedNeeds) ? tiedNeeds : [chosenNeed]; + const ignored = NEED_PRIORITY.find(key => key !== chosenNeed && tied.includes(key)); + const phrase = action?.phrase || "\u5f85\u3063\u305f"; + if (ignored) return `${NEED_PHRASES[ignored]}\u3051\u3069\u3001${phrase}\u3002`; + return `${NEED_PHRASES[chosenNeed]}\u306e\u3067\u3001${phrase}\u3002`; + } + + function moveTowardOrUse(t, target, state, label) { + if (!target) return false; + if (distXY(t.x, t.y, target.x, target.y) > Math.max(34, (target.r || 12) + t.radius * 0.75)) { + t.setActionState?.(state, { target, reason: label }); + return true; + } + return false; + } + + function createStructure(world, owner, type, x, y) { + const def = StructureRegistry[type]; + if (!def) return null; + const structure = def.create(owner, x, y, world); + structure.update = updateStructure; + structure.draw = drawStructure; + structure.damage = damageStructure; + world.items.push(structure); + world.itemCounts[structure.type] = (world.itemCounts[structure.type] || 0) + 1; + world.markTerrainDirty?.(`structure:${structure.type}`); + world.rebuildSpatial?.(true); + return structure; + } + + function hydrateStructure(structure) { + if (!structure || !structure.isStructure) return structure; + structure.update = updateStructure; + structure.draw = drawStructure; + structure.damage = damageStructure; + const def = StructureRegistry[structure.type]; + if (def) { + structure.roles = structure.roles || { ...def.roles }; + structure.needEffects = structure.needEffects || { ...def.needEffects }; + structure.r = structure.r || (structure.type === "plushie" ? 14 : 28); + structure.amount = Math.max(0, structure.hp || structure.amount || 1); + } + return structure; + } + + function consumeGrassMaterial(grass) { + if (!grass || grass.dead) return false; + grass.amount = Math.max(0, Number(grass.amount || 0) - 34); + grass.growth = clamp((grass.growth || 0.5) - 0.24, 0.04, 1.2); + if (grass.amount <= 1) grass.amount = 0; + return true; + } + + function finishBuild(t, type, grass) { + const world = t.world; + const x = clamp(t.x + rand(-18, 18), CONFIG.worldPadding, world.w - CONFIG.worldPadding); + const y = clamp(t.y + rand(10, 30), CONFIG.worldPadding, world.h - CONFIG.worldPadding); + consumeGrassMaterial(grass); + const s = createStructure(world, t, type, x, y); + if (s) { + t.buildTimer = 0; + t.buildType = ""; + t.buildMaterialId = ""; + t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 26) }; + StructureRegistry[type].onUse?.(t, s, world); + } + return Boolean(s); + } + + function runBuildAction(t, type, label) { + const grass = findNearbyMaterial(t.world, t, "grassMaterial", 520); + if (!grass) return false; + if (moveTowardOrUse(t, grass, "seek_food", label)) { + t.buildType = type; + t.buildMaterialId = grass.id; + return true; + } + t.buildType = type; + t.buildMaterialId = grass.id; + t.buildTimer = Math.max(0, Number(t.buildTimer || 0)) + 1; + t.setActionState?.("idle", { target: grass, reason: label }); + if (t.buildTimer >= (StructureRegistry[type]?.buildTime || 4)) return finishBuild(t, type, grass); + return true; + } + + const TARINAI_ACTIONS = [ + { id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it))), + run(t, world) { const item = findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it)); return moveTowardOrUse(t, item, "seek_food", this.label) || t.beginEating?.(item, this.label); } }, + { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 22, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "drink", 620)), + run(t, world) { const item = findNearestItemWithRole(world, t, "drink", 620); return moveTowardOrUse(t, item, "seek_water", this.label) || (t.setActionState?.("seek_water", { target: item, reason: this.label }), true); } }, + { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, + condition: (t, world) => Boolean(findNearestSleepPlace(world, t, 620)), + run(t, world) { const bed = findNearestSleepPlace(world, t, 620); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; return t.startSleeping?.(bed, this.label); } }, + { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true, + run(t) { return t.startSleeping?.(null, this.label); } }, + { id: "use_medicine", need: "health", label: "\u4f53\u306b\u3088\u3055\u305d\u3046\u306a\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u624b\u5f53\u3066\u3057\u305f", weight: 35, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "medicine", 620)), + run(t, world) { const item = findNearestItemWithRole(world, t, "medicine", 620); return moveTowardOrUse(t, item, itemRolesFor(item).drink ? "seek_water" : "seek_food", this.label) || t.beginEating?.(item, this.label); } }, + { id: "rest_to_recover", need: "health", label: "\u4f53\u3092\u4f11\u3081\u3066\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true, + run(t) { t.setActionState?.("idle", { target: null, reason: this.label }); t.energy = clamp((t.energy || 0) + 1.0, 0, 100); return true; } }, + { id: "flee", need: "safety", label: "\u96e2\u308c\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u9003\u3052\u305f", weight: 32, + condition: (t, world) => Boolean(findNearbyDanger(world, t, 260)), + run(t, world) { const danger = findNearbyDanger(world, t, 260); t.setActionState?.("panic", { target: t.panicDestination?.(danger, true) || null, reason: this.label, wake: true }); t.panicTimer = Math.max(t.panicTimer || 0, 2.0); return true; } }, + { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u308d\u3046\u3068\u3057\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 28, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)), + run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return t.startSleeping?.(bed, this.label); } }, + { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, + condition: (t, world) => Boolean(world.nearestOther?.(t, 560)), + run(t, world) { const other = world.nearestOther?.(t, 560); t.setActionState?.(t.parentToFollow?.() === other ? "follow_parent" : "seek_friend", { target: other, reason: this.label }); return true; } }, + { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 8, + condition: (t, world) => personalityValue(t, "aggression") > 0.45 && Boolean(world.nearestOther?.(t, 220)), + run(t, world) { const other = world.nearestOther?.(t, 220); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.0); t.intimidateTargetId = other?.id || null; t.setActionState?.("intimidate", { target: other, reason: this.label }); return true; } }, + { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 26, + condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "playObject", 460)), + run(t, world) { const ball = findNearestItemWithRole(world, t, "playObject", 460); t.setActionState?.("play_ball", { target: ball, reason: this.label }); t.goodMode = "smile"; return true; } }, + { id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3092\u4f5c\u3063\u305f", weight: 24, + condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "grass_bed") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)), + run(t) { return runBuildAction(t, "grass_bed", this.label); } }, + { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 18, + condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "plushie") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)), + run(t) { return runBuildAction(t, "plushie", this.label); } }, + { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 18, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)), + run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return true; } }, + { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u5927\u4e8b\u306b\u3057\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3067\u843d\u3061\u7740\u3044\u305f", weight: 22, + condition: (t, world) => Boolean(findOwnedStructure(world, t, "plushie", Infinity)), + run(t, world) { const plushie = findOwnedStructure(world, t, "plushie", Infinity); StructureRegistry.plushie.onUse(t, plushie, world); t.setActionState?.("idle", { target: plushie, reason: this.label }); return true; } }, + { id: "wander_lightly", need: "fulfill", label: "\u8efd\u304f\u6563\u6b69\u3057\u3066\u3044\u308b", phrase: "\u6563\u6b69\u3057\u305f", weight: 12, condition: () => true, + run(t) { t.wanderAngle += rand(-1.7, 1.7); t.setActionState?.("idle", { target: null, reason: this.label }); return true; } }, + ]; + + function chooseActionForNeed(need, t, world) { + const candidates = TARINAI_ACTIONS.filter(a => a.need === need && (!a.condition || a.condition(t, world))); + return weightedRandom(candidates) || TARINAI_ACTIONS.find(a => a.id === "wander_lightly"); + } + + function runNeedDecision(t, world, dt = 0.1) { + updateAttachedStructures(world, t); + decayNeedShock(t, dt); + const needs = updateNeeds(t, world); + const maxNeed = Math.max(...NEED_KEYS.map(key => needs[key])); + const tiedNeeds = NEED_KEYS.filter(key => needs[key] === maxNeed); + const chosenNeed = chooseTopNeedRandom(needs); + const action = chooseActionForNeed(chosenNeed, t, world); + if (!action || !action.run(t, world, dt)) { + t.setActionState?.("idle", { target: null, reason: "\u5f85\u3063\u3066\u3044\u308b" }); + } + const reasonText = buildReasonText(chosenNeed, tiedNeeds, action); + t.intent = { + need: chosenNeed, + tiedNeeds: tiedNeeds.slice(), + actionId: action?.id || "idle", + actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b", + reasonText, + }; + t.need = chosenNeed; + t.thought = reasonText; + return t.intent; + } + + function updateStructure(dt, world) { + if (this.dead) return; + this.age = (this.age || 0) + dt; + if (this.type === "plushie" && this.carriedById) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === this.carriedById); + if (owner) { + this.x = owner.x + (owner.facingDir ? owner.facingDir() : 1) * owner.radius * 0.12; + this.y = owner.y - owner.radius * 1.10; + } else { + this.carriedById = ""; + this.onHead = false; + } + } + this.amount = Math.max(0, this.hp || 0); + } + + function damageStructure(amount = 1, breaker = null, world = null) { + this.hp = Math.max(0, Number(this.hp || 0) - Math.max(0, Number(amount) || 0)); + if (this.hp > 0) return false; + const w = world || global.world; + const def = StructureRegistry[this.type]; + def?.onDestroyed?.(this, w, breaker); + this.amount = 0; + try { + Object.defineProperty(this, "dead", { value: true, configurable: true, writable: true }); + } catch (_) { + // Item.dead is a getter derived from amount; amount=0 is enough for those instances. + } + w?.markTerrainDirty?.(`structure-destroyed:${this.type}`); + return true; + } + + function drawStructure(ctx, time, lighting) { + const r = this.r || (this.type === "plushie" ? 14 : 28); + ctx.save(); + ctx.translate(this.x, this.y); + if (this.type === "grass_bed") { + ctx.fillStyle = "rgba(175, 196, 102, 0.88)"; + ctx.strokeStyle = "rgba(90, 118, 55, 0.72)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 3, r * 1.55, r * 0.74, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = "rgba(71, 112, 48, 0.55)"; + ctx.lineWidth = 1.6; + for (let i = 0; i < 15; i++) { + const x = randSeed((this.seed || 1) + i, -r * 1.1, r * 1.1); + const y = randSeed((this.seed || 1) + i + 20, -r * 0.32, r * 0.46); + const len = randSeed((this.seed || 1) + i + 40, r * 0.30, r * 0.68); + const a = randSeed((this.seed || 1) + i + 60, -0.7, 0.7); + ctx.beginPath(); + ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5); + ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); + ctx.stroke(); + } + } else if (this.type === "plushie") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : null; + ctx.rotate(Math.sin(time * 2.2 + (this.seed || 0)) * 0.08); + if (img) ctx.drawImage(img, -r * 0.8, -r * 0.8, r * 1.6, r * 1.45); + else { + ctx.fillStyle = "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.68, r * 0.58, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); + } + + const StructureRegistry = { + grass_bed: { + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { grassMaterial: 1 }, + roles: { sleepPlace: true, ownedStructure: true }, + needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 }, + create(owner, x, y) { + return { id: createId("structure"), type: "grass_bed", isStructure: true, ownerId: owner.id, hp: 100, attachment: 20, usedCount: 0, x, y, r: 28, amount: 100, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } }; + }, + onUse(user, structure) { + const owner = user?.id === structure?.ownerId; + structure.usedCount = (structure.usedCount || 0) + 1; + structure.attachment = clamp((structure.attachment || 0) + (owner ? 2 : 0.4), 0, 100); + user.needShock = { ...(user.needShock || {}) }; + user.needShock.sleep = Math.max(0, Number(user.needShock.sleep || 0) - (owner ? 18 : 8)); + user.needShock.safety = Math.max(0, Number(user.needShock.safety || 0) - (owner ? 12 : 4)); + user.needShock.fulfill = Math.max(0, Number(user.needShock.fulfill || 0) - (owner ? 18 : 6)); + }, + onDestroyed(structure, world, breaker) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId); + if (owner) addNeedShock(owner, { safety: 45, fulfill: 52 }, breaker); + }, + }, + plushie: { + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { grassMaterial: 1 }, + roles: { carriedObject: true, ownedStructure: true, fulfillObject: true }, + needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 }, + create(owner, x, y) { + return { id: createId("structure"), type: "plushie", isStructure: true, ownerId: owner.id, hp: 1, attachment: 30, usedCount: 0, x, y, r: 14, amount: 1, carriedById: owner.id, onHead: true, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } }; + }, + onUse(user, structure) { + if (!user || !structure) return; + structure.attachment = clamp((structure.attachment || 0) + 1.5, 0, 100); + user.needShock = { ...(user.needShock || {}), fulfill: Math.max(0, Number(user.needShock?.fulfill || 0) - 20), safety: Math.max(0, Number(user.needShock?.safety || 0) - 3) }; + }, + onDestroyed(structure, world, breaker) { + const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId); + if (owner) addNeedShock(owner, { safety: 58, fulfill: 70 }, breaker); + }, + }, + }; + + global.TarinaiNeedSystem = { + NEED_KEYS, + NEED_LABELS, + NEED_PHRASES, + TARINAI_ACTIONS, + StructureRegistry, + createDefaultNeeds, + quantizeNeed, + updateNeeds, + chooseTopNeedRandom, + chooseActionForNeed, + buildReasonText, + getNeedDisplayValue, + findNearestItemWithRole, + findNearestSleepPlace, + findNearbyDanger, + findNearbyMaterial, + findOwnedStructure, + ownedStructures, + itemRolesFor, + ensureItemNeedEffects, + deriveStressFromNeeds, + addNeedShock, + runNeedDecision, + createStructure, + hydrateStructure, + damageStructure, + }; + + global.createDefaultNeeds = createDefaultNeeds; + global.quantizeNeed = quantizeNeed; + global.updateNeeds = updateNeeds; + global.chooseTopNeedRandom = chooseTopNeedRandom; + global.chooseActionForNeed = chooseActionForNeed; + global.buildReasonText = buildReasonText; + global.getNeedDisplayValue = getNeedDisplayValue; + global.findNearestItemWithRole = findNearestItemWithRole; + global.findNearestSleepPlace = findNearestSleepPlace; + global.findNearbyDanger = findNearbyDanger; + global.findNearbyMaterial = findNearbyMaterial; + global.StructureRegistry = StructureRegistry; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/patch_helpers.js b/js/patch_helpers.js new file mode 100644 index 0000000..db2f6c7 --- /dev/null +++ b/js/patch_helpers.js @@ -0,0 +1,16 @@ +"use strict"; + +(function (global) { + const applied = new Set(); + function patchPrototype(key, owner, patchFn) { + if (!key || !owner?.prototype || typeof patchFn !== "function") return false; + const fullKey = `${owner.name || "Owner"}:${key}`; + if (applied.has(fullKey) || owner.prototype[`__${key}Patched`]) return false; + patchFn(owner.prototype); + applied.add(fullKey); + owner.prototype[`__${key}Patched`] = true; + return true; + } + function patchWorld(key, patchFn) { return patchPrototype(key, global.World, patchFn); } + global.TarinaiPatcher = { patchPrototype, patchWorld, applied }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/performance_manager.js b/js/performance_manager.js new file mode 100644 index 0000000..8c6e27b --- /dev/null +++ b/js/performance_manager.js @@ -0,0 +1,63 @@ +"use strict"; + +class TarinaiPerformanceManager { + constructor() { + this.fps = 60; + this.level = 0; + this.lastLevel = 0; + this.samples = []; + this.sampleLimit = 20; + } + + updateFps(fps, worldRef = null) { + const n = Number(fps); + if (!Number.isFinite(n) || n <= 0) return this.level; + this.samples.push(n); + if (this.samples.length > this.sampleLimit) this.samples.shift(); + const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length; + this.fps = Math.round(avg); + const next = this.computeLevel(avg); + if (next !== this.level) { + this.lastLevel = this.level; + this.level = next; + window.TarinaiEvents?.emit("performance:level", { level: this.level, previous: this.lastLevel, fps: this.fps, world: worldRef || null }); + } + return this.level; + } + + computeLevel(fps = this.fps) { + if (!fps || !Number.isFinite(fps)) return 0; + if (fps < 22) return 3; + if (fps < 34) return 2; + if (fps < 48) return 1; + return 0; + } + + quality(worldRef = null) { + const level = this.level; + let antCount = 0; + for (const ant of worldRef?.ants || []) if (ant && !ant.dead) antCount += 1; + const itemCount = (worldRef?.items || []).length || 0; + const effectCount = (worldRef?.effects || []).length || 0; + const antStride = antCount >= 95 || level >= 3 ? 3 : (antCount >= 35 || level >= 2 ? 2 : 1); + return { + fps: this.fps, + level, + antStride, + itemCount, + antCount, + effectCount, + effectLimit: level >= 3 ? 34 : level >= 2 ? 52 : level >= 1 ? 68 : 88, + statsInterval: level >= 3 ? 1.20 : level === 2 ? 0.92 : level === 1 ? 0.68 : 0.48, + terrainRedrawInterval: level >= 3 ? 1.30 : level === 2 ? 1.05 : level === 1 ? 0.82 : 0.62, + backgroundLightStep: level >= 3 ? 64 : level === 2 ? 96 : 180, + uiUpdateInterval: level >= 3 ? 1.2 : level === 2 ? 0.9 : level === 1 ? 0.65 : 0.45, + }; + } + + debugSnapshot(worldRef = null) { + return { version: window.TARINAI_VERSION || "", fps: this.fps, level: this.level, quality: this.quality(worldRef) }; + } +} + +window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager(); diff --git a/js/physics_helpers.js b/js/physics_helpers.js new file mode 100644 index 0000000..b4f08a6 --- /dev/null +++ b/js/physics_helpers.js @@ -0,0 +1,54 @@ +"use strict"; + +(function (global) { + function bounceInsideWorld(obj, worldRef, opts = {}) { + if (!obj || !worldRef) return false; + const p = Math.max(opts.padding ?? 28, CONFIG.worldPadding || 30); + const bx = opts.bounceX ?? opts.bounce ?? 0.72; + const by = opts.bounceY ?? opts.bounce ?? 0.72; + const spinBounce = opts.spinBounce ?? -0.72; + let bounced = false; + if (obj.x < p) { obj.x = p; obj.vx = Math.abs(obj.vx || 0) * bx; bounced = true; } + if (obj.x > worldRef.w - p) { obj.x = worldRef.w - p; obj.vx = -Math.abs(obj.vx || 0) * bx; bounced = true; } + if (obj.y < p) { obj.y = p; obj.vy = Math.abs(obj.vy || 0) * by; bounced = true; } + if (obj.y > worldRef.h - p) { obj.y = worldRef.h - p; obj.vy = -Math.abs(obj.vy || 0) * by; bounced = true; } + if (bounced && Number.isFinite(obj.spinVelocity)) obj.spinVelocity *= spinBounce; + return bounced; + } + + function applyKinematicItemMotion(item, worldRef, dt, opts = {}) { + if (!item || !worldRef) return 0; + const stopSpeed = opts.stopSpeed ?? 0.05; + const speed = Math.hypot(item.vx || 0, item.vy || 0); + item.prevX = item.x; + item.prevY = item.y; + if (speed <= stopSpeed) { + item.vx = 0; + item.vy = 0; + if (Number.isFinite(item.spinVelocity)) { + item.spinVelocity *= Math.pow(opts.spinRestDamp ?? 0.08, dt); + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt; + } + return speed; + } + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + bounceInsideWorld(item, worldRef, opts); + const friction = Math.pow(opts.frictionBase ?? 0.68, dt * (opts.frictionRate ?? 1)); + item.vx *= friction; + item.vy *= friction; + if (opts.spin !== false) { + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(7, item.r || 16); + item.spinVelocity = (item.spinVelocity || 0) * Math.pow(opts.spinFrictionBase ?? 0.38, dt); + } + if (Math.hypot(item.vx || 0, item.vy || 0) < (opts.zeroBelow ?? 0)) { + item.vx = 0; + item.vy = 0; + if (Number.isFinite(item.spinVelocity)) item.spinVelocity *= 0.3; + } + worldRef.drawListDirty = true; + return speed; + } + + global.TarinaiPhysics = { bounceInsideWorld, applyKinematicItemMotion }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/registry_base.js b/js/registry_base.js new file mode 100644 index 0000000..8ec9bb9 --- /dev/null +++ b/js/registry_base.js @@ -0,0 +1,20 @@ +"use strict"; + +(function (global) { + class TarinaiRegistryBase { + constructor(defs = {}) { this.defs = new Map(Object.entries(defs || {})); } + key(id = "") { return String(id || ""); } + get(id = "") { return this.defs.get(this.key(id)) || null; } + has(id = "") { return this.defs.has(this.key(id)); } + entries() { return [...this.defs.entries()]; } + values() { return [...this.defs.values()]; } + label(id = "", fallback = "") { return this.get(id)?.label || fallback || id || ""; } + color(id = "", fallback = "rgba(255,242,160,0.85)") { return this.get(id)?.color || fallback; } + labels() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.label])); } + colors() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.color]).filter(([, color]) => color)); } + debugSnapshot(mapper = null) { + return this.entries().map(([id, def]) => mapper ? mapper(id, def) : ({ id, label: def.label || id })); + } + } + global.TarinaiRegistryBase = TarinaiRegistryBase; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/relation_event_system.js b/js/relation_event_system.js new file mode 100644 index 0000000..fcb3249 --- /dev/null +++ b/js/relation_event_system.js @@ -0,0 +1,86 @@ +"use strict"; + +(function (global) { + const MAX_PENDING_LOSSES = 16; + + + function relationWithDead(observer, dead, worldRef) { + if (!observer || !dead || observer === dead) return ""; + const deadFamily = worldRef?.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); + const observerFamily = observer.familyKey || observer.archiveKey || observer.id || ""; + const deadId = dead.id || dead.releasedLiveId || ""; + const observerId = observer.id || observer.liveId || ""; + if (deadFamily && (observer.parents || []).includes(deadFamily)) return "親"; + if (deadFamily && (observer.children || []).includes(deadFamily)) return "子"; + if (observerFamily && (dead.parents || []).includes(observerFamily)) return "子"; + if (observerFamily && (dead.children || []).includes(observerFamily)) return "親"; + const rel = deadId ? observer.relationships?.[deadId] : null; + const reverse = observerId ? dead.relationships?.[observerId] : null; + if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達"; + return ""; + } + + function applyLiveDeathShock(observer, dead, relation, worldRef) { + const strong = relation === "親" || relation === "子"; + if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた"); + const stress = strong ? 18 : 11; + const fear = strong ? 2.1 : 1.45; + if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); + else { + observer.sleeping = false; + observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true }); + observer.fearTimer = Math.max(observer.fearTimer || 0, fear); + observer.stress = clamp((observer.stress || 0) + stress, 0, 130); + observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); + } + observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress }); + observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death"); + } + + function notifyDeathToRelations(dead, reason = "") { + const worldRef = this || global.world; + if (!worldRef || !dead) return; + const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); + const deadId = dead.id || dead.releasedLiveId || ""; + let liveHits = 0; + for (const other of worldRef.tarinai || []) { + if (!other || other === dead || other.dead) continue; + const relation = relationWithDead(other, dead, worldRef); + if (!relation) continue; + applyLiveDeathShock(other, dead, relation, worldRef); + liveHits += 1; + } + for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) { + const data = entry.data || entry; + const relation = relationWithDead(data, dead, worldRef); + if (!relation) continue; + if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = []; + const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId)); + if (!duplicate) { + entry.pendingLosses.unshift({ + relation, + name: dead.name || "たりない", + familyKey: deadFamily, + liveId: deadId, + reason: reason || dead.deathReason || "死亡", + time: worldRef.time || 0, + }); + if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES; + } + } + if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] }); + global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef); + global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); + } + + function patchWorldPrototype() { + const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; }; + if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return; + const World = global.World; + if (!World) return; + apply(World.prototype); + } + + global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype }; + patchWorldPrototype(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 1b59734..480150e 100644 --- a/js/render.js +++ b/js/render.js @@ -131,11 +131,13 @@ function placementPreviewFor(world) { const pad = CONFIG.worldPadding || 30; let x = p.x; let y = p.y; - const r = itemRadiusFor(type, 12); + const sizeScale = world?.toolSizeScale ? world.toolSizeScale(type) : 1; + const sizeName = world?.toolSizeFor ? world.toolSizeFor(type) : "medium"; + const r = itemRadiusFor(type, 12) * sizeScale; const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; let blocked = false; - let tmp = { type, x, y, r, dead: false }; + let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false }; if ((type === "fence_v" || type === "fence_h") && world.fenceRect) { const rawRect = world.fenceRect(tmp); return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) }; @@ -173,14 +175,14 @@ function drawPlacementPreview(ctx, world) { ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); } } - } else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { + } else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { ctx.beginPath(); - ctx.arc(x, y, Math.max(16, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); + ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } else { ctx.beginPath(); - ctx.ellipse(x, y, Math.max(14, r * 1.55), Math.max(9, r * 0.86), 0, 0, Math.PI * 2); + ctx.ellipse(x, y, Math.max(9, r * 1.55), Math.max(6, r * 0.86), 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } @@ -232,6 +234,102 @@ function backgroundCacheKey(world, lighting) { return `${field}:${weather}:${progressBucket}:${weatherBucket}`; } + + +// Cached low-priority terrain layer: grass, footprints and splats are mostly +// static visual noise. Rendering them into an offscreen layer avoids repeating +// thousands of small strokes every frame while preserving simulation behavior. +const terrainCache = { + canvas: null, + ctx: null, + w: 0, + h: 0, + key: "", +}; + +function isCachedTerrainItem(it) { + if (!it || it.dead) return false; + if (it.machineProduced) return false; + if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true; + return false; +} + +function terrainCacheKey(worldRef, lighting) { + const perf = worldRef.performanceLevel ? worldRef.performanceLevel() : 0; + const lightBucket = Math.round((lighting.light || 0) * (perf >= 2 ? 8 : 14)); + const version = worldRef.terrainVersion || 0; + return `${worldRef.fieldType || "garden"}:${worldRef.w}x${worldRef.h}:${lightBucket}:v${version}`; +} + +function ensureTerrainCache(worldRef, lighting) { + const w = Math.max(1, Math.floor(worldRef.w || 1)); + const h = Math.max(1, Math.floor(worldRef.h || 1)); + const key = terrainCacheKey(worldRef, lighting); + if (!terrainCache.canvas) { + terrainCache.canvas = document.createElement("canvas"); + terrainCache.ctx = terrainCache.canvas.getContext("2d"); + } + if (!worldRef.terrainDirty && terrainCache.w === w && terrainCache.h === h && terrainCache.key === key) return terrainCache.canvas; + terrainCache.w = w; + terrainCache.h = h; + terrainCache.key = key; + terrainCache.canvas.width = w; + terrainCache.canvas.height = h; + const c = terrainCache.ctx; + c.clearRect(0, 0, w, h); + const oldPointer = worldRef.pointer; + try { + // Pointer hover rings should not be baked into the cache. + if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false }; + for (const it of worldRef.items || []) { + if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting); + } + worldRef.terrainDirty = false; + return terrainCache.canvas; + } catch (err) { + console.error("terrain cache redraw failed", err); + terrainCache.key = ""; + return null; + } finally { + if (oldPointer) worldRef.pointer = oldPointer; + } +} + +function visibleWorldRect(worldRef, margin = 160) { + const scale = worldRef.viewScale ? worldRef.viewScale() : 1; + const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale); + const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale); + const cameraX = worldRef.cameraX || 0; + const cameraY = worldRef.cameraY || 0; + return { + left: cameraX - margin, + top: cameraY - margin, + right: cameraX + vw + margin, + bottom: cameraY + vh + margin, + }; +} + +function renderRadiusForEntity(entity) { + if (!entity) return 40; + if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2); + if (entity.type === "fence_v" || entity.type === "fence_h") return Math.max(96, (entity.r || 24) * 4.2); + if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2); + if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0); + if (entity.kind === "queen") return 42; + if (entity.kind === "worker") return 26; + if (entity.radius) return Math.max(56, entity.radius * 2.8); + if (entity.size) return Math.max(34, entity.size * 2.4); + return Math.max(42, (entity.r || 18) * 3.0); +} + +function isEntityVisibleInRect(entity, rect, extra = 0) { + if (!entity || entity.dead) return false; + const r = renderRadiusForEntity(entity) + extra; + const x = Number.isFinite(entity.x) ? entity.x : 0; + const y = Number.isFinite(entity.y) ? entity.y : 0; + return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom; +} + function ensureBackgroundCache(w, h, lighting) { const key = backgroundCacheKey(world, lighting); if (!backgroundCache.canvas) { @@ -540,56 +638,38 @@ function drawRain(ctx, w, h, t) { if (world.weather !== "light_rain") return; ctx.save(); const perf = world.performanceLevel ? world.performanceLevel() : 0; - const count = perf >= 2 ? 58 : 118; - ctx.lineCap = "round"; + const count = perf >= 2 ? 64 : 138; + const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites. + const slant = Math.tan(angle); for (let i = 0; i < count; i++) { - const speed = randSeed(18100 + i, 132, 230); - const cycle = h + 320; - const len = randSeed(18500 + i, 13, 26); - const tilt = randSeed(18600 + i, 5, 12); - const fall = randSeed(18400 + i, -220, cycle) + t * speed; - const y = ((fall % cycle) + cycle) % cycle - 140; - // The droplet's movement vector matches the visible diagonal stroke. - const driftPerY = -tilt / Math.max(1, len); - const xCycle = w + 220; - const rawX = randSeed(18000 + i, -70, w + 120) + fall * driftPerY + Math.sin(t * 0.9 + i * 0.43) * 4; - const x = ((rawX % xCycle) + xCycle) % xCycle - 90; - const alpha = y < 110 ? clamp((y + 68) / 178, 0, 1) : 1; - ctx.globalAlpha = 0.34 + 0.30 * alpha; - ctx.strokeStyle = "rgba(198, 226, 240, 0.92)"; - ctx.lineWidth = randSeed(18700 + i, 1.0, 1.8); + const speed = randSeed(18100 + i, 360, 620); + const len = randSeed(18500 + i, 14, 28); + const cycleY = h + 260; + const cycleX = w + 260; + const fall = randSeed(18400 + i, -220, cycleY) + t * speed; + const y = ((fall % cycleY) + cycleY) % cycleY - 120; + const drift = fall * slant * 0.42; + const rawX = randSeed(18000 + i, -140, w + 180) + drift; + const x = ((rawX % cycleX) + cycleX) % cycleX - 130; + const alpha = y < 60 ? clamp((y + 90) / 150, 0, 1) : 1; + const dx = Math.sin(angle) * len; + const dy = Math.cos(angle) * len; + ctx.globalAlpha = (0.18 + 0.34 * randSeed(19000 + i, 0.35, 1.0)) * alpha; + ctx.strokeStyle = "rgba(88, 150, 218, 0.86)"; + ctx.lineWidth = randSeed(18700 + i, 0.8, 1.45); + ctx.lineCap = "round"; ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x - tilt, y + len); + ctx.moveTo(x - dx * 0.5, y - dy * 0.5); + ctx.lineTo(x + dx * 0.5, y + dy * 0.5); ctx.stroke(); - if (perf < 3 && i % 2 === 0) { - ctx.globalAlpha = 0.18 + 0.18 * alpha; - ctx.fillStyle = "rgba(215, 237, 246, 0.92)"; + if (perf < 3 && i % 4 === 0) { + ctx.globalAlpha = 0.13 * alpha; + ctx.strokeStyle = "rgba(255,255,255,0.70)"; + ctx.lineWidth = 0.7; ctx.beginPath(); - ctx.ellipse(x - tilt * 0.9, y + len + 2, 1.8, 3.0, -0.28, 0, Math.PI * 2); - ctx.fill(); - } - } - if (perf < 3) { - const dripCount = perf >= 2 ? 10 : 18; - for (let i = 0; i < dripCount; i++) { - const phase = (t * randSeed(18800 + i, 0.55, 1.15) + randSeed(18900 + i, 0, 100)) % 1; - const x = randSeed(19000 + i, 12, w - 12); - const topY = -22 + phase * (h + 58); - const bottomY = topY + randSeed(19100 + i, 14, 30); - const lean = randSeed(19200 + i, -6, -2); - ctx.globalAlpha = 0.44; - ctx.strokeStyle = "rgba(206, 233, 247, 0.98)"; - ctx.lineWidth = randSeed(19300 + i, 1.2, 2.2); - ctx.beginPath(); - ctx.moveTo(x, topY); - ctx.lineTo(x + lean, bottomY); + ctx.moveTo(x - dx * 0.5 - 1.5, y - dy * 0.5); + ctx.lineTo(x + dx * 0.5 - 1.5, y + dy * 0.5); ctx.stroke(); - ctx.globalAlpha = 0.26; - ctx.beginPath(); - ctx.ellipse(x + lean, bottomY + 2, 2.1, 3.5, -0.18, 0, Math.PI * 2); - ctx.fillStyle = "rgba(220, 241, 248, 0.98)"; - ctx.fill(); } } ctx.restore(); @@ -620,7 +700,9 @@ function render() { const sceneW = world.w, sceneH = world.h; const screenW = world.viewportW || sceneW; const screenH = world.viewportH || sceneH; - const fieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const baseFieldOffset = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const shakeOffset = world.currentShakeOffset ? world.currentShakeOffset() : { x: 0, y: 0 }; + const fieldOffset = { x: baseFieldOffset.x + (shakeOffset.x || 0), y: baseFieldOffset.y + (shakeOffset.y || 0) }; const cameraX = world.cameraX || 0; const cameraY = world.cameraY || 0; const viewScale = world.viewScale ? world.viewScale() : 1; @@ -648,30 +730,24 @@ function render() { const perf = world.performanceLevel ? world.performanceLevel() : 0; if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting); - if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); + + const visibleRect = visibleWorldRect(world, 180); + const terrainLayer = ensureTerrainCache(world, lighting); beginFieldTransform(); drawPlacementPreview(ctx, world); - for (const it of world.items) { - if (it.type === "trace" || it.type === "splat") it.draw(ctx, world.time, lighting); - } + if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH); for (const ef of world.effects) { - if (ef.type === "bleed") ef.draw(ctx); + if (ef.type === "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } - for (const it of world.items) { - if (it.type !== "zunchi" && it.type !== "trace" && it.type !== "splat") it.draw(ctx, world.time, lighting); - } - for (const it of world.items) { - if (it.type === "zunchi") it.draw(ctx, world.time, lighting); - } - ctx.save(); ctx.lineWidth = 1.15; ctx.setLineDash([4, 4]); ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)"; for (const t of world.tarinai) { + if (!isEntityVisibleInRect(t, visibleRect, 80)) continue; const p = t.parentToFollow?.(); - if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96) continue; + if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue; ctx.beginPath(); ctx.moveTo(t.x, t.y); ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y); @@ -680,15 +756,28 @@ function render() { ctx.setLineDash([]); ctx.restore(); - const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; - for (const t of sorted) t.draw(ctx, world.time, lighting); + const fieldEntries = []; + const pushFieldEntry = (entity, kind, order = 0) => { + if (!entity || entity.dead || !isEntityVisibleInRect(entity, visibleRect)) return; + fieldEntries.push({ entity, kind, order, y: Number.isFinite(entity.y) ? entity.y : 0 }); + }; + for (const it of world.items || []) { + if (isCachedTerrainItem(it)) continue; + const layerOffset = it.type === "plushie" && it.onHead ? 999 : (it.type === "bed" ? -1.0 : ((typeof isMachineType === "function" && isMachineType(it.type)) ? (it.r || 42) * 0.58 : (isPinType(it.type) && it.pinState === "lodged" ? 0.15 : 0))); + pushFieldEntry(it, "item", layerOffset); + } + for (const t of world.tarinai || []) pushFieldEntry(t, "actor", 0.1); + for (const a of world.ants || []) pushFieldEntry(a, "actor", 0.05); + fieldEntries.sort((a, b) => ((a.y + a.order) - (b.y + b.order))); + for (const entry of fieldEntries) entry.entity.draw(ctx, world.time, lighting); for (const ef of world.effects) { - if (ef.type !== "bleed") ef.draw(ctx); + if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } ctx.restore(); // Light affecting the whole visible scene, including characters and ground. drawAtmosphericLighting(ctx, screenW, screenH, lighting); + if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); const drawAtScreenPosition = (entity, drawFn) => { if (!entity) return; @@ -752,11 +841,8 @@ function drawNestBoxTooltip(ctx, world) { const info = world.pointerNestBoxInfo?.(); if (!info) return; const box = info.box; - const capacity = info.capacity || world.nestBoxCapacity?.(box) || 5; - const title = `\u5de3\u7bb1 (${info.occupants.length}/${capacity})\u5339`; - const lines = info.occupants.length - ? [title, ...info.occupants.map(t => t.name)] - : [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"]; + const title = "\u5de3\u7bb1"; + const lines = [title]; ctx.save(); ctx.font = "12px Yomogi, sans-serif"; const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); @@ -784,8 +870,8 @@ function drawSelectedCard(ctx, world, lighting) { if (!t || t.dead || !world.tarinai.includes(t)) return; const w = world.viewportW || world.w; const h = world.viewportH || world.h; - const cardW = 152; - const cardH = 80; + const cardW = 172; + const cardH = 96; let x = t.x + 18; let y = t.y - 100; x = clamp(x, 10, w - cardW - 10); @@ -803,9 +889,25 @@ function drawSelectedCard(ctx, world, lighting) { const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : ""; const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : ""; ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35); - const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : ""; - ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52); - ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68); + const needSystem = window.TarinaiNeedSystem; + const needLabels = needSystem?.NEED_LABELS || { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", + }; + const needKeys = needSystem?.NEED_KEYS || ["food", "sleep", "health", "safety", "social", "fulfill"]; + const needValue = key => needSystem?.getNeedDisplayValue ? needSystem.getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); + const activeNeed = t.intent?.need || t.need || ""; + const rowA = needKeys.slice(0, 3).map(key => `${needLabels[key]}${key === activeNeed ? "\u25b2" : ""} ${needValue(key)}`).join(" "); + const rowB = needKeys.slice(3).map(key => `${needLabels[key]}${key === activeNeed ? "\u25b2" : ""} ${needValue(key)}`).join(" "); + ctx.fillText(rowA, x + 10, y + 52); + ctx.fillText(rowB, x + 10, y + 68); + let actionText = `\u3044\u307e ${t.intent?.actionLabel || stateLabel(t.state)}`; + while (actionText.length > 4 && ctx.measureText(actionText).width > cardW - 20) actionText = `${actionText.slice(0, -4)}...`; + ctx.fillText(actionText, x + 10, y + 84); ctx.restore(); } diff --git a/js/save_system.js b/js/save_system.js new file mode 100644 index 0000000..4840cf8 --- /dev/null +++ b/js/save_system.js @@ -0,0 +1,417 @@ +"use strict"; + +(function (global) { + const STORAGE_PREFIX = "tarinai_save_slot_v1_"; + const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; + const EXPORT_PREFIX_COMPRESSED = "TARINAI-SAVE-V11:"; + // Alpha note: save/import compatibility is not guaranteed yet. + // Hash imports intentionally accept only the current V11 format during alpha. + // V11 stores compact semantic early-game saves when possible, falls back to the V10 binary packet for complex states, and encodes bytes with printable ASCII excluding \, *, ' and _. + const SLOT_COUNT = 9; + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); + const createSnapshot = (...args) => Snapshot.createSnapshot(...args); + const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); + const htmlEscape = global.TarinaiUIHelpers?.htmlEscape || ((v) => String(v ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]))); + + + const BASE94_ALPHABET = (() => { + const excluded = new Set(["\\", "*", "'", "_"]); + let out = ""; + for (let i = 33; i <= 126; i++) { + const ch = String.fromCharCode(i); + if (!excluded.has(ch)) out += ch; + } + return out; + })(); + const BASE94_RADIX = BASE94_ALPHABET.length; + const BASE94_DECODE = (() => { + const map = Object.create(null); + for (let i = 0; i < BASE94_ALPHABET.length; i++) map[BASE94_ALPHABET[i]] = i; + return map; + })(); + + function bytesToBase94(bytes) { + if (!bytes || !bytes.length) return "!"; + let zeros = 0; + while (zeros < bytes.length && bytes[zeros] === 0) zeros++; + const digits = [0]; + for (let i = zeros; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + const x = digits[j] * 256 + carry; + digits[j] = x % BASE94_RADIX; + carry = Math.floor(x / BASE94_RADIX); + } + while (carry > 0) { + digits.push(carry % BASE94_RADIX); + carry = Math.floor(carry / BASE94_RADIX); + } + } + let out = BASE94_ALPHABET[0].repeat(zeros); + for (let i = digits.length - 1; i >= 0; i--) out += BASE94_ALPHABET[digits[i]]; + return out || BASE94_ALPHABET[0]; + } + + function base94ToBytes(text) { + const str = String(text || "").replace(/\s/g, ""); + if (!str) return new Uint8Array(); + let zeros = 0; + while (zeros < str.length && str[zeros] === BASE94_ALPHABET[0]) zeros++; + const bytes = [0]; + for (let i = zeros; i < str.length; i++) { + const digit = BASE94_DECODE[str[i]]; + if (digit === undefined) throw new Error("invalid base94 character"); + let carry = digit; + for (let j = 0; j < bytes.length; j++) { + const x = bytes[j] * BASE94_RADIX + carry; + bytes[j] = x & 255; + carry = x >> 8; + } + while (carry > 0) { + bytes.push(carry & 255); + carry >>= 8; + } + } + const out = new Uint8Array(zeros + bytes.length); + for (let i = 0; i < zeros; i++) out[i] = 0; + for (let i = 0; i < bytes.length; i++) out[out.length - 1 - i] = bytes[i]; + return out; + } + + function bytesToBase64(bytes) { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + + function stripExportPrefix(text, prefixes) { + let value = String(text || "").trim(); + const hashless = value.replace(/^#+/, ""); + if (prefixes.some(prefix => hashless.startsWith(prefix))) value = hashless; + for (const prefix of prefixes) { + if (value.startsWith(prefix)) return value.slice(prefix.length); + } + return value; + } + + function base64ToBytes(text) { + let b64 = stripExportPrefix(text, [EXPORT_PREFIX]).replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4) b64 += "="; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; + } + + async function gzipBytes(bytes) { + if (typeof CompressionStream !== "function") return null; + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + async function gunzipBytes(bytes) { + if (typeof DecompressionStream !== "function") throw new Error("compressed import is not supported in this browser"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + function encodeSnapshotLegacy(snapshot) { + const json = JSON.stringify(snapshot); + const bytes = new TextEncoder().encode(json); + return EXPORT_PREFIX + bytesToBase64(bytes); + } + + async function encodeSnapshot(snapshot) { + if (snapshot?.__tarinaiCompactBytes && snapshot.bytes) { + const raw = snapshot.bytes instanceof Uint8Array ? snapshot.bytes : new Uint8Array(snapshot.bytes); + const compressed = await gzipBytes(raw); + if (compressed && compressed.length < raw.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); + return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(raw); + } + const json = JSON.stringify(snapshot); + const bytes = new TextEncoder().encode(json); + const compressed = await gzipBytes(bytes); + if (compressed && compressed.length < bytes.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed); + return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(bytes); + } + + async function decodeSnapshot(text) { + const raw = String(text || "").trim(); + const normalized = raw.replace(/^#+/, ""); + if (!normalized) throw new Error("empty import text"); + if (!normalized.startsWith(EXPORT_PREFIX_COMPRESSED)) throw new Error("unsupported save hash version"); + const body = stripExportPrefix(normalized, [EXPORT_PREFIX_COMPRESSED]); + const mode = body[0] || "r"; + const payload = body.slice(1); + const bytes = base94ToBytes(payload); + const packet = mode === "z" ? await gunzipBytes(bytes) : bytes; + if (!Snapshot.decodeV11SnapshotBytes) throw new Error("V11 decoder is not available"); + return Snapshot.decodeV11SnapshotBytes(packet); + } + + function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } + + function makeThumbnail() { + const source = global.canvas || document.getElementById("gameCanvas"); + if (!source || !source.width || !source.height) return ""; + try { + const tw = 168; + const th = 96; + const c = document.createElement("canvas"); + c.width = tw; + c.height = th; + const ctx = c.getContext("2d", { alpha: false }); + if (!ctx) return ""; + ctx.fillStyle = "#f6efe3"; + ctx.fillRect(0, 0, tw, th); + const scale = Math.min(tw / source.width, th / source.height); + const w = Math.max(1, Math.round(source.width * scale)); + const h = Math.max(1, Math.round(source.height * scale)); + const x = Math.floor((tw - w) / 2); + const y = Math.floor((th - h) / 2); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(source, x, y, w, h); + return c.toDataURL("image/jpeg", 0.58); + } catch (_) { + return ""; + } + } + + function makeSaveSnapshot() { + const snapshot = createSnapshot(global.world); + snapshot.thumbnail = makeThumbnail(); + return snapshot; + } + + function saveSlot(slot) { + const snapshot = makeSaveSnapshot(); + localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); + return snapshot; + } + + function loadSlot(slot) { + const raw = localStorage.getItem(slotKey(slot)); + if (!raw) throw new Error("empty slot"); + return restoreSnapshot(JSON.parse(raw), global.world); + } + + function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } + + function readSlot(slot) { + const raw = localStorage.getItem(slotKey(slot)); + if (!raw) return null; + try { return JSON.parse(raw); } catch (_) { return null; } + } + + function formatSlotSummary(snapshot) { + if (!snapshot) return "空き"; + const d = snapshot.summary || {}; + const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明"; + return `${date}\n${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""}\n${d.population || 0}匹 / 道具${d.items || 0}`; + } + + function ensureDialog() { + let dialog = document.getElementById("saveDialog"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "saveDialog"; + dialog.className = "field-dialog hidden"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.innerHTML = ` +
+

セーブ / ロード

+

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは圧縮して短く書き出します。

+
+
+
+ + +
+ +
+ +
+ +
+
`; + document.body.appendChild(dialog); + return dialog; + } + + function confirmInGame(message, okLabel = "実行") { + const dialog = ensureDialog(); + const box = dialog.querySelector("#saveConfirmBox"); + if (!box) return Promise.resolve(false); + box.querySelector(".save-confirm-message").textContent = message; + const ok = box.querySelector('[data-save-confirm="ok"]'); + if (ok) ok.textContent = okLabel; + box.classList.remove("hidden"); + return new Promise(resolve => { + const finish = (value) => { + box.classList.add("hidden"); + box.removeEventListener("click", onClick); + resolve(value); + }; + const onClick = (e) => { + const btn = e.target.closest("[data-save-confirm]"); + if (!btn) return; + finish(btn.dataset.saveConfirm === "ok"); + }; + box.addEventListener("click", onClick); + }); + } + + function slotThumbnailHtml(snapshot) { + const src = snapshot?.thumbnail || ""; + if (src) return ``; + return `
No Image
`; + } + + function renderSlotList() { + const dialog = ensureDialog(); + const list = dialog.querySelector("#saveSlotList"); + if (!list) return; + list.innerHTML = ""; + for (let i = 1; i <= SLOT_COUNT; i++) { + const snapshot = readSlot(i); + const row = document.createElement("div"); + row.className = `save-slot-card ${snapshot ? "filled" : "empty"}`; + row.innerHTML = ` +
${slotThumbnailHtml(snapshot)}
+
スロット ${i}
+
${htmlEscape(formatSlotSummary(snapshot)).replace(/\n/g, "
")}
+
+ + + +
`; + list.appendChild(row); + } + } + + function openDialog() { + const dialog = ensureDialog(); + renderSlotList(); + dialog.classList.remove("hidden"); + } + + function closeDialog() { + document.getElementById("saveDialog")?.classList.add("hidden"); + } + + function bindSaveSystem() { + const btn = document.getElementById("saveBtn") || global.ui?.saveBtn; + if (!btn || btn.dataset.saveBound === "1") return; + btn.dataset.saveBound = "1"; + btn.addEventListener("click", () => { + global.audio?.uiClick?.(); + openDialog(); + }); + const dialog = ensureDialog(); + dialog.addEventListener("click", async (e) => { + if (e.target === dialog) { closeDialog(); return; } + if (e.target.closest("#saveConfirmBox")) return; + const saveBtn = e.target.closest("[data-save-slot]"); + const loadBtn = e.target.closest("[data-load-slot]"); + const deleteBtn = e.target.closest("[data-delete-slot]"); + if (saveBtn) { + const slot = Number(saveBtn.dataset.saveSlot); + try { + saveSlot(slot); + renderSlotList(); + global.audio?.notify?.(); + global.showToast?.(`スロット${slot}に保存しました。`); + } catch (error) { + console.warn(error); + global.showToast?.("保存に失敗しました。空き容量を確認してください。"); + } + return; + } + if (loadBtn) { + const slot = Number(loadBtn.dataset.loadSlot); + if (!await confirmInGame(`スロット${slot}を読み込みます。現在の状態は上書きされます。`, "読み込む")) return; + try { + loadSlot(slot); + closeDialog(); + global.audio?.notify?.(); + global.showToast?.(`スロット${slot}を読み込みました。`); + } catch (error) { + console.warn(error); + global.showToast?.("読み込みに失敗しました。"); + } + return; + } + if (deleteBtn) { + const slot = Number(deleteBtn.dataset.deleteSlot); + if (!await confirmInGame(`スロット${slot}を削除しますか?`, "削除")) return; + deleteSlot(slot); + renderSlotList(); + global.audio?.delete?.(); + global.showToast?.(`スロット${slot}を削除しました。`); + return; + } + if (e.target.closest("#saveCloseBtn")) closeDialog(); + if (e.target.closest("#saveExportBtn")) { + const area = dialog.querySelector("#saveHashText"); + const exportBtn = dialog.querySelector("#saveExportBtn"); + try { + if (exportBtn) exportBtn.disabled = true; + if (area) { + area.value = "書き出し中…"; + const fullSnapshot = createSnapshot(global.world); + const exportSnapshot = Snapshot.createCompactSnapshot ? Snapshot.createCompactSnapshot(global.world) : fullSnapshot; + const text = await encodeSnapshot(exportSnapshot); + area.value = text; + area.focus(); + area.select(); + const legacy = encodeSnapshotLegacy(fullSnapshot); + const rate = legacy.length ? Math.round((1 - text.length / legacy.length) * 100) : 0; + global.showToast?.(rate > 0 ? `軽量ハッシュを書き出しました(約${rate}%短縮)。` : "現在の状態を書き出しました。"); + } + } catch (error) { + console.warn(error); + global.showToast?.("書き出しに失敗しました。"); + } finally { + if (exportBtn) exportBtn.disabled = false; + } + } + if (e.target.closest("#saveImportBtn")) { + const area = dialog.querySelector("#saveHashText"); + const text = area?.value || ""; + if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } + if (!await confirmInGame("ハッシュテキストを読み込みます。現在の状態は上書きされます。", "読み込む")) return; + try { + restoreSnapshot(await decodeSnapshot(text), global.world); + closeDialog(); + global.audio?.notify?.(); + global.showToast?.("ハッシュテキストから読み込みました。"); + } catch (error) { + console.warn(error); + global.showToast?.("読み込みに失敗しました。テキストを確認してください。"); + } + } + }); + } + + global.TarinaiSaveSystem = { + createSnapshot, + restoreSnapshot, + encodeSnapshot, + decodeSnapshot, + saveSlot, + loadSlot, + deleteSlot, + readSlot, + bindSaveSystem, + openDialog, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sim_core.js b/js/sim_core.js index a8330e9..2da7fdf 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -64,6 +64,62 @@ function stableUnit(seed, salt = "") { return ((h >>> 0) % 100000) / 100000; } +const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; +const GENETIC_LIMITS = Object.freeze({ + lifeSpanMul: [0.82, 1.24], + attackMul: [0.80, 1.24], + speedMul: [0.86, 1.18], + sizeMul: [0.84, 1.18], +}); + +function clampGeneticValue(key, value) { + const range = GENETIC_LIMITS[key] || [0.75, 1.25]; + return clamp(Number(value) || 1, range[0], range[1]); +} + +function randomGenetics(seed = "") { + return { + lifeSpanMul: clampGeneticValue("lifeSpanMul", 0.82 + stableUnit(seed, "gene-life") * 0.42), + attackMul: clampGeneticValue("attackMul", 0.80 + stableUnit(seed, "gene-attack") * 0.44), + speedMul: clampGeneticValue("speedMul", 0.86 + stableUnit(seed, "gene-speed") * 0.32), + sizeMul: clampGeneticValue("sizeMul", 0.84 + stableUnit(seed, "gene-size") * 0.34), + }; +} + +function normalizeGenetics(source = null, seed = "") { + const fallback = randomGenetics(seed); + const out = {}; + for (const key of GENETIC_KEYS) out[key] = clampGeneticValue(key, source?.[key] ?? fallback[key]); + return out; +} + +function inheritedGenetics(a, b, seed = "") { + const ga = normalizeGenetics(a?.genetics, a?.familyKey || a?.id || "a"); + const gb = normalizeGenetics(b?.genetics, b?.familyKey || b?.id || "b"); + const out = {}; + for (const key of GENETIC_KEYS) { + const base = ((ga[key] || 1) + (gb[key] || 1)) / 2; + const mutation = (stableUnit(seed, `gene-mut-${key}`) - 0.5) * 0.10; + out[key] = clampGeneticValue(key, base + mutation); + } + return out; +} + +function adultScaleFromGenetics(seed = "", genetics = null) { + const g = normalizeGenetics(genetics, seed); + return clamp((0.265 + stableUnit(seed, "adult-scale") * 0.060) * g.sizeMul, 0.205, 0.390); +} + +function childGrowthScaleFactor(growth = 1) { + const x = clamp(Number(growth) || 0, 0, 1); + const smooth = x * x * (3 - 2 * x); + return 0.46 + smooth * 0.54; +} + +function scaleForGrowth(adultScale = 0.28, growth = 1) { + return clamp((Number(adultScale) || 0.28) * childGrowthScaleFactor(growth), 0.10, 0.42); +} + const PERSONALITIES = Object.freeze({}); const PERSONALITY_IDS = []; @@ -73,7 +129,7 @@ function personalityForSeed(seed) { const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"]; const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 }; -const FRIEND_AFFINITY_THRESHOLD = 16; +const FRIEND_AFFINITY_THRESHOLD = 14; const PERSONALITY_LABELS = { aggression: { label: "\u653b\u6483\u6027", low: "\u304a\u3060\u3084\u304b", high: "\u6012\u308a\u3063\u307d\u3044" }, openness: { label: "\u958b\u653e\u6027", low: "\u4fdd\u5b88\u7684", high: "\u904a\u3073\u597d\u304d" }, @@ -91,6 +147,28 @@ function normalizePersonality(source = null) { return out; } +function zunchiSlavePersonalityValue() { + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = -1; + return out; +} + +function applyZunchiSlavePersonality(tarinai, opts = {}) { + if (!tarinai) return false; + const next = zunchiSlavePersonalityValue(); + let changed = false; + tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality || next); + tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || next); + for (const key of PERSONALITY_KEYS) { + if ((tarinai.currentPersonality[key] || 0) !== -1) changed = true; + tarinai.currentPersonality[key] = -1; + if (opts.birth) tarinai.birthPersonality[key] = -1; + } + tarinai.personalityDaily = { day: tarinai.world?.day || 1, total: 0, byKey: {}, byCause: {} }; + if (changed && opts.record !== false) tarinai.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u6027\u683c", { value: -1 }); + return changed; +} + function randomBirthPersonality(seed = "") { const out = {}; for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(stableUnit(seed, `personality-${key}`) * 1.2 - 0.6); @@ -125,6 +203,13 @@ function personalityEffectValue(value) { return trait.direction * (trait.strength === "strong" ? 1 : 0.5); } +function effectivePersonalityValue(tarinai, key) { + const p = ensurePersonality(tarinai).currentPersonality; + const base = clampPersonalityValue(p[key]); + const moodDelta = clampPersonalityValue(tarinai?.world?.colonyMood?.effects?.personality?.[key] || 0); + return clampPersonalityValue(base + moodDelta); +} + function personalityReasonText(reason = "") { const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim(); const map = { @@ -137,6 +222,10 @@ function personalityReasonText(reason = "") { "after repeated contact with poop": "\u305a\u3093\u3061\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", "after repeated contact with corpses": "\u6b7b\u9ab8\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", "after being hurt": "\u75db\u307f\u3092\u899a\u3048\u305f\u5f71\u97ff\u3067", + "after ammo knockback": "\u5f3e\u85ac\u3067\u5f3e\u304d\u98db\u3070\u3057\u305f\u5f71\u97ff\u3067", + "after successful intimidation": "\u5a01\u5687\u306b\u6210\u529f\u3057\u305f\u5f71\u97ff\u3067", + "after failed intimidation": "\u5a01\u5687\u306b\u5931\u6557\u3057\u305f\u5f71\u97ff\u3067", + "after being struck by genkotsu": "\u3052\u3093\u3053\u3064\u3092\u53d7\u3051\u305f\u5f71\u97ff\u3067", }; return map[key] || key; } @@ -157,12 +246,12 @@ function getPersonalityTraitTags(tarinai) { const p = ensurePersonality(tarinai).currentPersonality; const tags = []; for (const key of PERSONALITY_KEYS) { - const trait = getTraitStrength(p[key]); + const trait = getTraitStrength(effectivePersonalityValue(tarinai, key)); if (!trait.direction) continue; const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label); } - const cowardly = (p.neuroticism || 0) >= 0.75 && (p.aggression || 0) <= -0.5; + const cowardly = effectivePersonalityValue(tarinai, "neuroticism") >= 0.75 && effectivePersonalityValue(tarinai, "aggression") <= -0.5; if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5"); return tags; } @@ -171,14 +260,31 @@ function personalityDailyState(tarinai) { const day = tarinai?.world?.day || 1; const state = tarinai.personalityDaily || {}; if (state.day !== day) { - tarinai.personalityDaily = { day, total: 0, byKey: {} }; + tarinai.personalityDaily = { day, total: 0, byKey: {}, byCause: {} }; return tarinai.personalityDaily; } state.byKey = state.byKey || {}; for (const key of Object.keys(state.byKey)) { const v = state.byKey[key]; - if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v) }; - else state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0) }; + if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v), causes: [] }; + else { + const causes = Array.isArray(v?.causes) ? [...new Set(v.causes.map(c => String(c || "").trim()).filter(Boolean))].slice(0, 4) : []; + state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0), causes }; + } + } + state.byCause = state.byCause && typeof state.byCause === "object" ? state.byCause : {}; + for (const cause of Object.keys(state.byCause)) { + const entry = state.byCause[cause] || {}; + const byKey = entry.byKey && typeof entry.byKey === "object" ? entry.byKey : {}; + const nextByKey = {}; + for (const key of PERSONALITY_KEYS) { + const v = byKey[key] || {}; + const up = Math.max(0, Number(v.up) || 0); + const down = Math.max(0, Number(v.down) || 0); + if (up > 0.0001 || down > 0.0001) nextByKey[key] = { up, down }; + } + if (Object.keys(nextByKey).length) state.byCause[cause] = { byKey: nextByKey }; + else delete state.byCause[cause]; } state.total = Math.max(0, Number(state.total) || 0); tarinai.personalityDaily = state; @@ -229,8 +335,23 @@ function adjustPersonality(tarinai, key, delta, reason = "") { if (!actual) return 0; tarinai.currentPersonality[key] = after; state.byKey[key] = used; + if (!Array.isArray(used.causes)) used.causes = []; used[actual > 0 ? "up" : "down"] = (used[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual); state.total = (state.total || 0) + Math.abs(actual); + const label = PERSONALITY_LABELS[key]?.label || key; + const reasonText = personalityReasonText(reason) || "\u6027\u683c\u5909\u5316"; + if (reasonText && !used.causes.includes(reasonText)) used.causes.push(reasonText); + if (used.causes.length > 4) used.causes.length = 4; + if (reasonText) { + state.byCause = state.byCause && typeof state.byCause === "object" ? state.byCause : {}; + const causeEntry = state.byCause[reasonText] || { byKey: {} }; + causeEntry.byKey = causeEntry.byKey && typeof causeEntry.byKey === "object" ? causeEntry.byKey : {}; + const causeKeyEntry = causeEntry.byKey[key] || { up: 0, down: 0 }; + causeKeyEntry[actual > 0 ? "up" : "down"] = (causeKeyEntry[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual); + causeEntry.byKey[key] = causeKeyEntry; + state.byCause[reasonText] = causeEntry; + } + tarinai.recordChangeCause?.(reasonText, label, { value: actual }); const afterBand = personalityBand(after); if (beforeBand !== afterBand) { const text = personalityThresholdLogText(tarinai, key, after, reason, beforeBand); @@ -241,8 +362,7 @@ function adjustPersonality(tarinai, key, delta, reason = "") { } function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) { - const p = ensurePersonality(tarinai).currentPersonality; - const trait = getTraitStrength(p[key]); + const trait = getTraitStrength(effectivePersonalityValue(tarinai, key)); if (trait.direction !== Math.sign(direction || 1)) return false; return trait.strength === "strong" || Math.random() < 0.5; } @@ -420,11 +540,19 @@ class SpatialGrid { this.cellSize = cellSize; this.itemCells = new Map(); this.tarinaiCells = new Map(); + this.antCells = new Map(); + this.obstacleCells = new Map(); + this.foodCells = new Map(); + this.hazardCells = new Map(); } clear() { this.itemCells.clear(); this.tarinaiCells.clear(); + this.antCells.clear(); + this.obstacleCells.clear(); + this.foodCells.clear(); + this.hazardCells.clear(); } keyFor(x, y) { @@ -434,7 +562,8 @@ class SpatialGrid { } add(map, entity) { - const key = this.keyFor(entity.x, entity.y); + if (!entity) return; + const key = this.keyFor(entity.x || 0, entity.y || 0); let bucket = map.get(key); if (!bucket) { bucket = []; @@ -443,17 +572,23 @@ class SpatialGrid { bucket.push(entity); } - rebuild(items, tarinai) { - this.clear(); - for (const it of items) { - if (!it.dead) this.add(this.itemCells, it); - } - for (const t of tarinai) { - if (!t.dead) this.add(this.tarinaiCells, t); - } + classifyItem(it) { + if (!it || it.dead) return; + this.add(this.itemCells, it); + const type = it.type || ""; + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(this.obstacleCells, it); + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(this.foodCells, it); + if (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it); } - nearby(map, x, y, radius, out = []) { + rebuild(items, tarinai, ants = []) { + this.clear(); + for (const it of items || []) this.classifyItem(it); + for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t); + for (const a of ants || []) if (!a.dead) this.add(this.antCells, a); + } + + nearby(map, x, y, radius, out = [], filterDistance = false) { out.length = 0; const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); const minX = Math.floor((x - r) / this.cellSize); @@ -464,7 +599,15 @@ class SpatialGrid { for (let cx = minX; cx <= maxX; cx++) { const bucket = map.get(cx + cy * 100000); if (!bucket) continue; - for (const entity of bucket) out.push(entity); + for (const entity of bucket) { + if (filterDistance) { + const dx = (entity.x || 0) - x; + const dy = (entity.y || 0) - y; + const er = Math.max(entity.r || entity.radius || 0, 0); + if (dx * dx + dy * dy > (r + er) * (r + er)) continue; + } + out.push(entity); + } } } return out; diff --git a/js/snapshot_system.js b/js/snapshot_system.js new file mode 100644 index 0000000..c358ebb --- /dev/null +++ b/js/snapshot_system.js @@ -0,0 +1,3040 @@ +"use strict"; + +(function (global) { + const SNAPSHOT_VERSION = 1; + // Alpha note: save/import data is intentionally not stable yet. + // During alpha, hash-text compatibility may be broken by balance and data-shape changes. + const COMPACT_SNAPSHOT_VERSION = 9; + const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); + const COORDINATE_KEYS = new Set(["x", "y", "prevX", "prevY", "foundingX", "foundingY", "cameraX", "cameraY"]); + + function isPlainObject(value) { + if (!value || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; + } + + function clonePlain(value, depth = 0) { + if (value == null) return value; + const type = typeof value; + if (type === "number") return Number.isFinite(value) ? value : 0; + if (type === "string" || type === "boolean") return value; + if (type === "function" || type === "symbol" || type === "undefined") return undefined; + if (depth > 8) return undefined; + if (Array.isArray(value)) { + const out = []; + for (const v of value) { + const c = clonePlain(v, depth + 1); + if (c !== undefined) out.push(c); + } + return out; + } + if (!isPlainObject(value)) return undefined; + const out = {}; + for (const [key, v] of Object.entries(value)) { + if (DEFAULT_SKIP.has(key)) continue; + const c = clonePlain(v, depth + 1); + if (c !== undefined) out[key] = c; + } + return out; + } + + function copyOwnData(obj, skip = DEFAULT_SKIP) { + const out = {}; + if (!obj) return out; + for (const key of Object.keys(obj)) { + if (skip?.has?.(key)) continue; + const cloned = clonePlain(obj[key]); + if (cloned !== undefined) out[key] = cloned; + } + return out; + } + + function applyOwnData(target, data, skip = DEFAULT_SKIP) { + if (!target || !data) return target; + for (const [key, value] of Object.entries(data)) { + if (skip?.has?.(key) || key.startsWith("__")) continue; + const c = clonePlain(value); + if (c !== undefined) target[key] = c; + } + return target; + } + + function refFor(worldRef, value) { + if (!value || typeof value !== "object") return null; + if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" }; + if ((worldRef?.items || []).includes(value)) return { kind: "item", id: value.id || "", type: value.type || "" }; + if ((worldRef?.ants || []).includes(value)) return { kind: "ant", id: value.id || "" }; + if (Number.isFinite(value.x) && Number.isFinite(value.y)) return { kind: "point", x: value.x, y: value.y, dead: Boolean(value.dead) }; + return null; + } + + function resolveRef(worldRef, ref) { + if (!ref || typeof ref !== "object") return null; + if (ref.kind === "tarinai") return (worldRef.tarinai || []).find(t => t && !t.dead && ((ref.id && t.id === ref.id) || (ref.familyKey && t.familyKey === ref.familyKey))) || null; + if (ref.kind === "item") return (worldRef.items || []).find(it => it && !it.dead && it.id === ref.id) || null; + if (ref.kind === "ant") return (worldRef.ants || []).find(a => a && !a.dead && a.id === ref.id) || null; + if (ref.kind === "point") return { x: Number(ref.x) || 0, y: Number(ref.y) || 0, dead: Boolean(ref.dead) }; + return null; + } + + function worldData(worldRef) { + const keys = [ + "w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai", + "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", + "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason", + "relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents", + "lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength" + ]; + const out = {}; + for (const key of keys) { + const cloned = clonePlain(worldRef?.[key]); + if (cloned !== undefined) out[key] = cloned; + } + return out; + } + + function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { + const data = copyOwnData(entity, skip); + const targetRef = refFor(worldRef, entity?.target); + const panicTargetRef = refFor(worldRef, entity?.panicTarget); + const targetRefObject = refFor(worldRef, entity?.targetRef); + if (targetRef) data.__targetRef = targetRef; + if (panicTargetRef) data.__panicTargetRef = panicTargetRef; + if (targetRefObject) data.__targetRefObject = targetRefObject; + return data; + } + + + const COMPACT_WORLD_KEYS = ["w", "h", "time", "day", "speed", "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "colonyMood", "lastColonyMoodDay"]; + const COMPACT_TARINAI_KEYS = ["familyKey", "id", "liveToken", "name", "type", "x", "y", "vx", "vy", "impulseVx", "impulseVy", "adultScale", "scale", "age", "lifeSpan", "generation", "birthTime", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "dead", "deathReason", "birthPersonality", "currentPersonality", "personalityDaily", "genetics", "parents", "parentNames", "children", "relationships", "favorite", "hasPaired", "reproductionTimer", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "formerName", "zunchiSlaveSpriteVariant", "zunchiStain", "zunchiStainSeed", "digest", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "mysteryDrugHistory", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "sunbathSpriteVariant", "goodMode", "facing", "visualFacing", "fallTimer", "fallDir", "hurtTimer", "fearTimer", "stressBarTimer", "hpBarTimer"]; + const COMPACT_ITEM_KEYS = ["id", "type", "x", "y", "r", "amount", "age", "seed", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "passiveFoodDecayTimer", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "textEditedAt", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "decayTimer", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const COMPACT_ANT_KEYS = ["id", "kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "carryLogAt", "foundingAttempts", "dead"]; + + + // v4 hash format is intentionally alpha-only and compatibility may break. + // It stores gameplay state in short sparse arrays: non-gameplay UI/log/effect data is omitted. + const V4_WORLD_KEYS = COMPACT_WORLD_KEYS; + const V4_TARINAI_KEYS = COMPACT_TARINAI_KEYS; + const V4_ANT_KEYS = COMPACT_ANT_KEYS; + + const V4_ITEM_TYPES = ["zunchi","sweet","love_mochi","fight_mochi","water_bowl","sleep_drug","laxative","protein","niteropu","ammo","mystery_drug","mercury","giant_drug","dwarf_drug","zunda_juice","grass","stone","genkotsu","bed","nest_box","ant_nest","ball","signboard","firecracker","pushpin","oshibyo","duplicator","cannon","fence_v","fence_h","water","trace","splat","food","ant_corpse"]; + const V4_SPRITE_TYPES = ["smile","angry","teary","jito","drool","cry","sleep","pokan","stretch","hurt","weak","back","flee","zunda_eat","hurt2","normal_smirk","normal_tongue","fear","flee_fear2","sleep2","normal_happy","fear_blue","fear_cry","stress_dizzy","stress_sweat","intimidate","birth_ritual","zunchi_slave","hungry_70","zunchi_slave_alt","sunbath_1","sunbath_2","sunbath_3"]; + const V4_ITEM_EXTRA_KEYS = ["id", "r", "amount", "age", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + + function itemTypeList() { + return V4_ITEM_TYPES; + } + + function spriteTypeList() { + return V4_SPRITE_TYPES; + } + + function codeFromList(value, list) { + const idx = list.indexOf(value); + return idx >= 0 ? idx : value; + } + + function valueFromList(value, list) { + return typeof value === "number" ? (list[value] || "") : value; + } + + function isEmptyCompactValue(value) { + return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0) || (isPlainObject(value) && Object.keys(value).length === 0); + } + + function sameCompactValue(a, b, key = "") { + const ca = compactValue(a, 0, key); + const cb = compactValue(b, 0, key); + return JSON.stringify(ca) === JSON.stringify(cb); + } + + function packSparseByKeys(data, keys, defaults = null, options = {}) { + const out = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (options.skip?.has?.(key)) continue; + let value = data?.[key]; + if (key === "relationships") value = compactRelationships(value); + const c = compactValue(value, 0, key); + if (isEmptyCompactValue(c)) continue; + if (defaults && sameCompactValue(c, defaults[key], key)) continue; + out.push(i, c); + } + return out; + } + + function unpackSparseByKeys(pairs, keys) { + const out = {}; + if (!Array.isArray(pairs)) return out; + for (let i = 0; i < pairs.length - 1; i += 2) { + const key = keys[pairs[i]]; + if (!key) continue; + out[key] = clonePlain(pairs[i + 1]); + } + return out; + } + + function itemDefaultsFor(type = "") { + const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; + const out = { + r: typeof itemRadiusFor === "function" ? itemRadiusFor(type, 12) : (def?.radius ?? 12), + amount: typeof itemAmountFor === "function" ? itemAmountFor(type, 80) : (def?.amount ?? 80), + age: 0, + dead: false, + dropTimer: 0, + dropMax: 0, + dropImpactDone: false, + }; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) { + out.toolSize = "medium"; + out.foodServingScale = 1; + out.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5; + out.foodServingsRemaining = out.foodServingsMax; + out.amount = out.foodServingsMax; + } + if (type === "grass") { + out.health = 1; + out.eatenAmount = 0; + out.fertilityBoost = 0; + out.wither = 0; + } + if (type === "bed") out.wear = 0; + if (type === "signboard") out.text = ""; + if (type === "duplicator" || type === "cannon") Object.assign(out, { machineSlotType: "", machineSlotSize: "medium", machineSpawnTimer: 0, machineLastSlotType: "", cannonAngle: type === "cannon" ? -Math.PI * 0.25 : 0, cannonFireTimer: 0 }); + if (type === "zunchi") { + out.stage = "fresh"; + out.stageTimer = 0; + out.fertility = 0; + out.freshness = 1; + } + if (type === "firecracker") { + out.fuseTimer = 5; + out.fuseMax = 5; + } + if (typeof isPinType === "function" && isPinType(type)) { + Object.assign(out, { amount: 100, pinState: "loose", pinTargetId: "", pinAttachAngle: 0, pinAttachDistance: 0, pinOffsetY: 0, pinDamageTick: 0, pinFallCheckTimer: 0, pinLogAt: -999, deletable: true }); + } + return out; + } + + function packV4Item(worldRef, item, typeList) { + const data = snapshotEntity(worldRef, item); + const type = data.type || item?.type || ""; + const defaults = itemDefaultsFor(type); + const extras = packSparseByKeys(data, V4_ITEM_EXTRA_KEYS, defaults, { skip: new Set(["type", "x", "y"]) }); + const row = [codeFromList(type, typeList), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)]; + if (extras.length) row.push(extras); + return row; + } + + function unpackV4Item(row, typeList) { + if (!Array.isArray(row)) return null; + const type = valueFromList(row[0], typeList); + const data = unpackSparseByKeys(row[3] || [], V4_ITEM_EXTRA_KEYS); + data.type = type; + data.x = row[1] || 0; + data.y = row[2] || 0; + return data; + } + + function packV4Tarinai(worldRef, tarinai, spriteList) { + const data = snapshotEntity(worldRef, tarinai); + const pairs = packSparseByKeys(data, V4_TARINAI_KEYS, null); + for (let i = 0; i < pairs.length - 1; i += 2) { + if (V4_TARINAI_KEYS[pairs[i]] === "type") pairs[i + 1] = codeFromList(pairs[i + 1], spriteList); + } + return pairs; + } + + function unpackV4Tarinai(row, spriteList) { + const data = unpackSparseByKeys(row, V4_TARINAI_KEYS); + if (data.type !== undefined) data.type = valueFromList(data.type, spriteList); + return data; + } + + function createV4Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const typeList = itemTypeList(); + const spriteList = spriteTypeList(); + const world = worldData(worldRef); + const frozen = Array.isArray(world.frozenTarinai) ? world.frozenTarinai : []; + delete world.frozenTarinai; + return { + v: COMPACT_SNAPSHOT_VERSION, + w: packSparseByKeys(world, V4_WORLD_KEYS, { + w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, family: {}, colonyMood: { id: "relaxed", label: "relaxed", description: "", effects: { personality: {} } }, lastColonyMoodDay: 0 + }), + t: (worldRef.tarinai || []).filter(Boolean).map(t => packV4Tarinai(worldRef, t, spriteList)), + i: (worldRef.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)).map(it => packV4Item(worldRef, it, typeList)), + a: (worldRef.ants || []).filter(a => a && !a.dead).map(a => packSparseByKeys(snapshotEntity(worldRef, a), V4_ANT_KEYS)), + f: frozen.map(compactFrozenEntry), + }; + } + + function expandV4Snapshot(snapshot) { + if (!snapshot || snapshot.v !== 4) return snapshot; + const typeList = itemTypeList(); + const spriteList = spriteTypeList(); + const world = unpackSparseByKeys(snapshot.w, V4_WORLD_KEYS); + world.frozenTarinai = (snapshot.f || []).map(expandFrozenEntry); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot.t) ? snapshot.t.length : 0, items: Array.isArray(snapshot.i) ? snapshot.i.length : 0 }, + world, + tarinai: (snapshot.t || []).map(row => unpackV4Tarinai(row, spriteList)), + items: (snapshot.i || []).map(row => unpackV4Item(row, typeList)).filter(Boolean), + ants: (snapshot.a || []).map(row => unpackSparseByKeys(row, V4_ANT_KEYS)), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + // v5 hash format: shorter alpha-only hash text. It keeps gameplay state, but intentionally excludes logs, bubbles, UI-only timers, thumbnails, and other presentation state. + const V5_SNAPSHOT_VERSION = 5; + const V5_WORLD_KEYS = ["w", "h", "time", "day", "speed", "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "colonyMood", "lastColonyMoodDay"]; + const V5_TARINAI_KEYS = ["familyKey", "id", "liveToken", "name", "type", "x", "y", "vx", "vy", "impulseVx", "impulseVy", "adultScale", "scale", "age", "lifeSpan", "generation", "birthTime", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "dead", "deathReason", "birthPersonality", "currentPersonality", "personalityDaily", "genetics", "parents", "children", "relationships", "favorite", "hasPaired", "reproductionTimer", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "formerName", "zunchiSlaveSpriteVariant", "zunchiStain", "zunchiStainSeed", "digest", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "mysteryDrugHistory", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "sunbathSpriteVariant", "goodMode", "facing", "visualFacing", "fallTimer", "fallDir", "hurtTimer", "fearTimer"]; + const V5_ITEM_EXTRA_KEYS = ["id", "r", "amount", "age", "dead", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "prevX", "prevY", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "dropImpactDone", "impactFlash", "lingerTimer", "lingerDuration", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinDamageTick", "pinFallCheckTimer", "pinLogAt", "pinFlipX", "deletable", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const V5_ANT_KEYS = ["id", "kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "foundingAttempts", "dead"]; + const V5_NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; + const V5_STATES = ["idle", "seek_food", "seek_friend", "seek_bed", "seek_water", "sleep", "eat", "panic", "fight", "birth_ritual", "intimidate", "ant_attack", "ant_intimidate", "zunchi_sick", "fight_sick", "play_ball", "follow_parent", "cursor_enemy", "cursor_friend", "sunbath"]; + const V5_SIZES = ["small", "medium", "large"]; + const V5_PIN_STATES = ["loose", "lodged"]; + const V5_ZUNCHI_STAGES = ["fresh", "aged", "dry", "dead"]; + const V5_MOODS = ["relaxed", "calm", "prickly", "stinky", "weaklings", "zunda_party", "ball_party", "sun_party", "ant_overrun"]; + const PERSONALITY_ORDER = ["aggression", "openness", "sociability", "neuroticism"]; + const GENETIC_ORDER = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; + + function codeOrValue(value, list) { + if (value === undefined || value === null || value === "") return value; + const idx = list.indexOf(value); + return idx >= 0 ? idx : value; + } + + function valueOrCode(value, list) { + return typeof value === "number" ? (list[value] || "") : value; + } + + function compactMetric(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 10) / 10; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function approxSame(a, b, key = "") { + if (COORDINATE_KEYS.has(key)) return compactCoordinate(a) === compactCoordinate(b); + if (typeof a === "number" || typeof b === "number") return compactNumber(Number(a) || 0) === compactNumber(Number(b) || 0); + return JSON.stringify(compactValue(a, 0, key)) === JSON.stringify(compactValue(b, 0, key)); + } + + function packAxisObject(obj, keys, fallback = null) { + if (!obj || typeof obj !== "object") return undefined; + const arr = keys.map(key => compactNumber(obj[key] ?? fallback?.[key] ?? 0)); + while (arr.length && (arr[arr.length - 1] === 0 || arr[arr.length - 1] === null || arr[arr.length - 1] === undefined)) arr.pop(); + return arr.length ? arr : undefined; + } + + function unpackAxisObject(arr, keys, fallback = null) { + const out = fallback && typeof fallback === "object" ? clonePlain(fallback) || {} : {}; + if (!Array.isArray(arr)) return out; + for (let i = 0; i < Math.min(arr.length, keys.length); i++) out[keys[i]] = compactNumber(Number(arr[i]) || 0); + for (const key of keys) if (!Number.isFinite(out[key])) out[key] = 0; + return out; + } + + function compactItemEffectTimers(value) { + if (!value || typeof value !== "object") return undefined; + const arr = [compactNumber(value.laxative || 0), compactNumber(value.ammo || 0)]; + while (arr.length && !arr[arr.length - 1]) arr.pop(); + return arr.length ? arr : undefined; + } + + function expandItemEffectTimers(value) { + if (!Array.isArray(value)) return value; + return { laxative: Number(value[0] || 0), ammo: Number(value[1] || 0) }; + } + + function compactPersonalityDaily(value) { + if (!value || typeof value !== "object") return undefined; + const out = []; + if (Number.isFinite(value.day) && value.day !== 1) out.push(0, value.day); + if (Number.isFinite(value.total) && value.total) out.push(1, compactNumber(value.total)); + if (value.byKey && typeof value.byKey === "object" && Object.keys(value.byKey).length) out.push(2, compactValue(value.byKey)); + return out.length ? out : undefined; + } + + function expandPersonalityDaily(value) { + if (!Array.isArray(value)) return value; + const out = { day: 1, total: 0, byKey: {}, byCause: {} }; + for (let i = 0; i < value.length - 1; i += 2) { + if (value[i] === 0) out.day = value[i + 1]; + else if (value[i] === 1) out.total = value[i + 1]; + else if (value[i] === 2) out.byKey = clonePlain(value[i + 1]) || {}; + } + return out; + } + + function compactRelationshipsV5(rels) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + const row = [id, compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function expandRelationshipsV5(rows) { + if (!Array.isArray(rows)) return rows; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || !row[0]) continue; + out[row[0]] = { affinity: Number(row[1] || 0), fear: Number(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function v5DefaultTarinaiData(data = {}, worldRef = null) { + const key = data.familyKey || data.id || ""; + const genetics = typeof normalizeGenetics === "function" ? normalizeGenetics(null, key) : null; + const birthPersonality = typeof randomBirthPersonality === "function" ? randomBirthPersonality(key) : null; + const adultScale = typeof adultScaleFromGenetics === "function" ? adultScaleFromGenetics(key, data.genetics || genetics) : undefined; + const out = { + id: "", liveToken: 0, generation: 1, birthTime: 0, impulseVx: 0, impulseVy: 0, state: "idle", dead: false, deathReason: "", favorite: false, hasPaired: false, + parents: [], children: [], relationships: {}, loveMochiTimer: 0, fightMochiTimer: 0, zunchiDisease: false, zunchiDiseaseSeverity: 0, sleepDisease: false, + explosionDisease: false, explosionDiseaseTimer: 0, fightDisease: false, totalFightLosses: 0, totalFightWins: 0, isZunchiSlave: false, zunchiSlaveLocked: false, + formerName: null, zunchiSlaveSpriteVariant: "", zunchiStain: 0, digest: 0, poopCount: 0, oshiriByoZunchiStock: 0, stuckPushpinId: null, + itemEffectTimers: { laxative: 0, ammo: 0 }, laxativePoopTimer: 3, powerItemMode: "", sizeItemMode: "", lifeItemMode: "", mercuryLifeSpanBase: null, + mercuryLifeMultiplier: 1, mysteryDrugHistory: [], insideNestBoxId: null, sunbathTimer: 0, sunbathCooldown: 0, sunbathSpriteVariant: 1, + fallTimer: 0, hurtTimer: 0, fearTimer: 0, + }; + if (genetics) out.genetics = genetics; + if (birthPersonality) { + out.birthPersonality = birthPersonality; + out.currentPersonality = birthPersonality; + } + if (adultScale !== undefined) { + out.adultScale = adultScale; + if ((data.generation || 1) <= 1) out.scale = adultScale; + } + if (typeof stableChoice === "function") out.goodMode = stableChoice(key, "good-mode", typeof displayNormalSprites === "function" ? displayNormalSprites() : []) || "smile"; + if (typeof stableUnit === "function") { + out.facing = stableUnit(key, "facing") < 0.5 ? -1 : 1; + out.visualFacing = out.facing; + out.fallDir = stableUnit(key, "fall-dir") < 0.5 ? -1 : 1; + out.zunchiStainSeed = stableUnit(key, "zunchi-stain"); + } + return out; + } + + function encodeV5TarinaiValue(key, value) { + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "birthPersonality" || key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "relationships") return compactRelationshipsV5(value); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + if (key === "personalityDaily") return compactPersonalityDaily(value); + return compactValue(value, 0, key); + } + + function decodeV5TarinaiValue(key, value) { + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "birthPersonality" || key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "relationships") return expandRelationshipsV5(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + if (key === "personalityDaily") return expandPersonalityDaily(value); + return clonePlain(value); + } + + function encodeV5ItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return codeOrValue(value, V5_SIZES); + if (key === "pinState") return codeOrValue(value, V5_PIN_STATES); + if (key === "stage") return codeOrValue(value, V5_ZUNCHI_STAGES); + if (key === "machineSlotType" || key === "machineLastSlotType" || key === "sourceMachineType") return codeOrValue(value, V4_ITEM_TYPES); + return compactValue(value, 0, key); + } + + function decodeV5ItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return valueOrCode(value, V5_SIZES); + if (key === "pinState") return valueOrCode(value, V5_PIN_STATES); + if (key === "stage") return valueOrCode(value, V5_ZUNCHI_STAGES); + if (key === "machineSlotType" || key === "machineLastSlotType" || key === "sourceMachineType") return valueOrCode(value, V4_ITEM_TYPES); + return clonePlain(value); + } + + function packSparseV5(data, keys, defaults = null, encode = (key, value) => compactValue(value, 0, key)) { + const out = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const encoded = encode(key, data?.[key]); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encode(key, defaults[key]), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackSparseV5(pairs, keys, decode = (key, value) => clonePlain(value)) { + const out = {}; + if (!Array.isArray(pairs)) return out; + for (let i = 0; i < pairs.length - 1; i += 2) { + const key = keys[pairs[i]]; + if (!key) continue; + out[key] = decode(key, pairs[i + 1]); + } + return out; + } + + function v5ReferencedItemIds(worldRef, tarinaiRows = []) { + const ids = new Set(); + for (const t of tarinaiRows || []) { + if (t?.stuckPushpinId) ids.add(t.stuckPushpinId); + if (t?.insideNestBoxId) ids.add(t.insideNestBoxId); + } + for (const a of worldRef?.ants || []) { + if (a?.homeId) ids.add(a.homeId); + if (a?.targetId && String(a.targetId).startsWith("item")) ids.add(a.targetId); + } + for (const it of worldRef?.items || []) { + if (it?.projectileSourceId) ids.add(it.projectileSourceId); + } + return ids; + } + + function v5ItemDefaultsFor(type = "", keepId = false) { + const out = itemDefaultsFor(type); + if (!keepId) out.id = ""; + if (type !== "signboard") out.text = ""; + return out; + } + + function packV5World(worldRef) { + const world = worldData(worldRef); + const mood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + world.colonyMood = codeOrValue(mood, V5_MOODS); + delete world.frozenTarinai; + delete world.family; + const defaults = { w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: 0, lastColonyMoodDay: 0 }; + return packSparseV5(world, V5_WORLD_KEYS, defaults, (key, value) => key === "colonyMood" ? value : compactValue(value, 0, key)); + } + + function unpackV5World(row) { + const world = unpackSparseV5(row, V5_WORLD_KEYS, (key, value) => key === "colonyMood" ? { id: valueOrCode(value, V5_MOODS) || "relaxed" } : clonePlain(value)); + return world; + } + + function packV5Tarinai(worldRef, tarinai) { + const data = snapshotEntity(worldRef, tarinai); + const defaults = v5DefaultTarinaiData(data, worldRef); + return packSparseV5(data, V5_TARINAI_KEYS, defaults, encodeV5TarinaiValue); + } + + function unpackV5Tarinai(row) { + return unpackSparseV5(row, V5_TARINAI_KEYS, decodeV5TarinaiValue); + } + + function packV5Item(worldRef, item, referencedIds) { + const data = snapshotEntity(worldRef, item); + const type = data.type || item?.type || ""; + const keepId = Boolean(data.id && (referencedIds?.has?.(data.id) || type === "ant_nest" || type === "nest_box" || data.pinState === "lodged")); + const defaults = v5ItemDefaultsFor(type, keepId); + const extras = packSparseV5(data, V5_ITEM_EXTRA_KEYS, defaults, encodeV5ItemValue); + const row = [codeFromList(type, V4_ITEM_TYPES), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)]; + if (extras.length) row.push(extras); + return row; + } + + function unpackV5Item(row) { + if (!Array.isArray(row)) return null; + const type = valueFromList(row[0], V4_ITEM_TYPES); + const data = unpackSparseV5(row[3] || [], V5_ITEM_EXTRA_KEYS, decodeV5ItemValue); + data.type = type; + data.x = Number(row[1] || 0); + data.y = Number(row[2] || 0); + return data; + } + + function packV5Ant(worldRef, ant) { + const data = snapshotEntity(worldRef, ant); + const defaults = { r: 6, maxHp: 8, hp: data.maxHp || 8, state: "wander", age: 0, dead: false }; + return packSparseV5(data, V5_ANT_KEYS, defaults, (key, value) => compactValue(value, 0, key)); + } + + function unpackV5Ant(row) { + return unpackSparseV5(row, V5_ANT_KEYS, (key, value) => clonePlain(value)); + } + + function compactFrozenEntryV5(entry = {}) { + const data = entry.data || entry; + const row = packSparseV5(data, V5_TARINAI_KEYS, v5DefaultTarinaiData(data), encodeV5TarinaiValue); + const out = [entry.freezeId || entry.id || "", compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const losses = compactValue((entry.pendingLosses || []).slice(-8)) || []; + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV5(entry = []) { + if (!Array.isArray(entry)) return expandFrozenEntry(entry); + const data = unpackV5Tarinai(entry[3] || []); + return { + freezeId: entry[0] || "", + frozenAt: entry[1] || 0, + worldTime: entry[2] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: clonePlain(entry[4]) || [] + }; + } + + function createV5Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const tarinaiRows = (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiRows); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + return [ + V5_SNAPSHOT_VERSION, + packV5World(worldRef), + (worldRef.tarinai || []).filter(Boolean).map(t => packV5Tarinai(worldRef, t)), + (worldRef.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)).map(it => packV5Item(worldRef, it, referencedIds)), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(compactFrozenEntryV5), + ]; + } + + function expandV5Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V5_SNAPSHOT_VERSION) return snapshot; + const world = unpackV5World(snapshot[1] || []); + world.frozenTarinai = (snapshot[5] || []).map(expandFrozenEntryV5); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot[2]) ? snapshot[2].length : 0, items: Array.isArray(snapshot[3]) ? snapshot[3].length : 0 }, + world, + tarinai: (snapshot[2] || []).map(unpackV5Tarinai), + items: (snapshot[3] || []).map(unpackV5Item).filter(Boolean), + ants: (snapshot[4] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + function compactNumber(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 1000) / 1000; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function compactCoordinate(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value * 10) / 10; + return Object.is(rounded, -0) ? 0 : rounded; + } + + function compactValue(value, depth = 0, keyHint = "") { + if (value == null) return value; + const type = typeof value; + if (type === "number") return COORDINATE_KEYS.has(keyHint) ? compactCoordinate(value) : compactNumber(value); + if (type === "string" || type === "boolean") return value; + if (type === "function" || type === "symbol" || type === "undefined") return undefined; + if (depth > 6) return undefined; + if (Array.isArray(value)) return value.map(v => compactValue(v, depth + 1, keyHint)).filter(v => v !== undefined); + if (!isPlainObject(value)) return undefined; + const out = {}; + for (const [key, v] of Object.entries(value)) { + if (DEFAULT_SKIP.has(key) || key.startsWith("__")) continue; + const c = compactValue(v, depth + 1, key); + if (c !== undefined && c !== null && !(Array.isArray(c) && !c.length)) out[key] = c; + } + return out; + } + + function compactRelationships(rels) { + if (!rels || typeof rels !== "object") return undefined; + const entries = Object.entries(rels) + .map(([id, r]) => [id, compactValue(r)]) + .filter(([, r]) => r && (Math.abs(r.affinity || 0) > 0.08 || Math.abs(r.fear || 0) > 0.08 || Math.abs(r.familiarity || 0) > 0.2 || Math.abs(r.fights || 0) > 0)); + entries.sort((a, b) => { + const score = (r) => Math.abs(r.affinity || 0) + Math.abs(r.fear || 0) + Math.abs(r.familiarity || 0) * 0.25 + Math.abs(r.fights || 0) * 2; + return score(b[1]) - score(a[1]); + }); + const out = {}; + for (const [id, r] of entries.slice(0, 12)) out[id] = r; + return Object.keys(out).length ? out : undefined; + } + + function compactByKeys(data, keys) { + const arr = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + let value = data?.[key]; + if (key === "relationships") value = compactRelationships(value); + arr[i] = compactValue(value, 0, key); + } + while (arr.length && (arr[arr.length - 1] === undefined || arr[arr.length - 1] === null || (Array.isArray(arr[arr.length - 1]) && !arr[arr.length - 1].length))) arr.pop(); + return arr; + } + + function expandByKeys(arr, keys) { + const out = {}; + if (!Array.isArray(arr)) return out; + for (let i = 0; i < Math.min(arr.length, keys.length); i++) { + const value = arr[i]; + if (value === undefined || value === null) continue; + out[keys[i]] = clonePlain(value); + } + return out; + } + + function compactFrozenEntry(entry = {}) { + const data = entry.data || entry; + return { + i: entry.freezeId || entry.id || "", + a: compactNumber(entry.frozenAt || 0), + t: compactNumber(entry.worldTime || 0), + n: entry.name || data.name || "", + k: entry.familyKey || data.familyKey || "", + d: compactByKeys(data, COMPACT_TARINAI_KEYS), + l: compactValue((entry.pendingLosses || []).slice(-8)) || [] + }; + } + + function expandFrozenEntry(entry = {}) { + const data = expandByKeys(entry.d, COMPACT_TARINAI_KEYS); + return { + freezeId: entry.i || "", + frozenAt: entry.a || 0, + worldTime: entry.t || 0, + name: entry.n || data.name || "tarinai", + familyKey: entry.k || data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: clonePlain(entry.l) || [] + }; + } + + + + // v6 hash format: alpha-only, shorter than V5. Individual observation history and names are intentionally omitted. + // Gameplay state is preserved through needs, personality, genetics, relationships, family links, item states, machines, ants, and frozen entries. + const V6_SNAPSHOT_VERSION = 6; + const V6_FIELD_TYPES = ["garden", "cage", "park"]; + const V6_WEATHERS = ["sunny", "cloudy", "light_rain"]; + const V6_CORE_TARINAI_KEYS = ["familyKey", "id", "liveToken", "x", "y", "adultScale", "scale", "age", "lifeSpan", "hunger", "loneliness", "energy", "stress", "affection", "need", "state", "reproductionTimer", "digest", "currentPersonality", "genetics"]; + const V6_EXTRA_TARINAI_KEYS = ["type", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "dead", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V6_LOSS_RELATIONS = ["parent", "child", "mate"]; + const V6_TARINAI_KEYS = [...V6_CORE_TARINAI_KEYS, ...V6_EXTRA_TARINAI_KEYS]; + + function v6IdToCode(value) { + if (!value) return ""; + const m = /^t(\d+)$/.exec(String(value)); + return m ? Number(m[1]) : value; + } + + function v6CodeToId(value) { + if (value === undefined || value === null || value === "") return ""; + return typeof value === "number" ? `t${value}` : String(value); + } + + function v6FamilyMaps(worldRef, frozen = []) { + const map = new Map(); + const list = []; + const add = (value) => { + if (!value) return null; + const key = String(value); + if (!map.has(key)) { + map.set(key, map.size); + list.push(key); + } + return map.get(key); + }; + for (const t of worldRef?.tarinai || []) { + if (!t) continue; + add(t.familyKey || t.archiveKey || t.id); + for (const id of t.parents || []) add(id); + for (const id of t.children || []) add(id); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + add(entry.familyKey || data.familyKey || data.id); + for (const id of data.parents || []) add(id); + for (const id of data.children || []) add(id); + for (const loss of entry.pendingLosses || []) add(loss?.familyKey); + } + return { map, list }; + } + + function v6FamilyCode(value, ctx) { + if (!value) return ""; + if (!ctx?.familyMap) return value; + const existing = ctx.familyMap.get(String(value)); + if (existing !== undefined) return existing; + const next = ctx.familyMap.size; + ctx.familyMap.set(String(value), next); + ctx.familyList?.push?.(String(value)); + return next; + } + + function v6FamilyValue(value, ctx) { + if (value === undefined || value === null || value === "") return ""; + // V6 intentionally restores compact family keys. Exact historic UUIDs are observation history, not gameplay. + return typeof value === "number" ? `f${value + 1}` : String(value); + } + + function v6RelationKey(value) { + return v6IdToCode(value); + } + + function v6RelationId(value) { + return v6CodeToId(value); + } + + function compactRelationshipsV6(rels) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + const row = [v6RelationKey(id), compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function expandRelationshipsV6(rows) { + if (!Array.isArray(rows)) return rows; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || row[0] === undefined || row[0] === null || row[0] === "") continue; + out[v6RelationId(row[0])] = { affinity: Number(row[1] || 0), fear: Number(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function encodeV6TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyCode(value, ctx); + if (key === "id") return v6IdToCode(value); + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) && value.length ? value.map(v => v6FamilyCode(v, ctx)).filter(v => v !== "") : undefined; + if (key === "relationships") return compactRelationshipsV6(value); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + return compactValue(value, 0, key); + } + + function decodeV6TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyValue(value, ctx); + if (key === "id") return v6CodeToId(value); + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v6FamilyValue(v, ctx)).filter(Boolean) : []; + if (key === "relationships") return expandRelationshipsV6(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + return clonePlain(value); + } + + function v6DefaultTarinaiData(data = {}) { + return { + familyKey: "", id: "", liveToken: 0, type: "smile", x: 0, y: 0, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0, + adultScale: 0, scale: 0, age: 0, lifeSpan: 0, generation: 1, birthTime: 0, hunger: 0, loneliness: 0, energy: 0, stress: 0, affection: 0, + need: "", state: "idle", currentPersonality: {}, genetics: {}, parents: [], children: [], relationships: {}, + dead: false, deathReason: "", favorite: false, hasPaired: false, reproductionTimer: 0, loveMochiTimer: 0, fightMochiTimer: 0, + zunchiDisease: false, zunchiDiseaseSeverity: 0, sleepDisease: false, explosionDisease: false, explosionDiseaseTimer: 0, fightDisease: false, + totalFightLosses: 0, totalFightWins: 0, isZunchiSlave: false, zunchiSlaveLocked: false, zunchiSlaveSpriteVariant: "", zunchiStain: 0, + digest: 0, poopCount: 0, oshiriByoZunchiStock: 0, stuckPushpinId: null, itemEffectTimers: { laxative: 0, ammo: 0 }, laxativePoopTimer: 3, + powerItemMode: "", sizeItemMode: "", lifeItemMode: "", mercuryLifeSpanBase: null, mercuryLifeMultiplier: 1, insideNestBoxId: null, + sunbathTimer: 0, sunbathCooldown: 0, sunbathSpriteVariant: 1, goodMode: "", facing: 0, visualFacing: 0, fallTimer: 0, fallDir: 0, hurtTimer: 0, fearTimer: 0, + }; + } + + function packV6Core(data, keys, defaults, encode, ctx) { + const row = keys.map(key => { + const encoded = encode(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) return null; + const def = encode(key, defaults?.[key], ctx); + if (approxSame(encoded, def, key)) return null; + return encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV6Core(row, keys, decode, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, keys.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = keys[i]; + out[key] = decode(key, value, ctx); + } + return out; + } + + function packV6CoreTarinai(data, ctx) { + const row = V6_CORE_TARINAI_KEYS.map(key => { + const encoded = encodeV6TarinaiValue(key, data?.[key], ctx); + return isEmptyCompactValue(encoded) ? null : encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV6CoreTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, V6_CORE_TARINAI_KEYS.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = V6_CORE_TARINAI_KEYS[i]; + out[key] = decodeV6TarinaiValue(key, value, ctx); + } + return out; + } + + function packV6ExtrasTarinai(data, defaults, ctx) { + const out = []; + for (let i = 0; i < V6_EXTRA_TARINAI_KEYS.length; i++) { + const key = V6_EXTRA_TARINAI_KEYS[i]; + const encoded = encodeV6TarinaiValue(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encodeV6TarinaiValue(key, defaults[key], ctx), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackV6ExtrasTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V6_EXTRA_TARINAI_KEYS[row[i]]; + if (!key) continue; + out[key] = decodeV6TarinaiValue(key, row[i + 1], ctx); + } + return out; + } + + function packV6Tarinai(worldRef, tarinai, ctx) { + const data = snapshotEntity(worldRef, tarinai); + delete data.name; + delete data.parentNames; + delete data.formerName; + delete data.birthPersonality; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.recentChangeCauses; + delete data.records; + delete data.deathReason; + delete data.favorite; + delete data.zunchiSlaveSpriteVariant; + delete data.zunchiStain; + delete data.sunbathSpriteVariant; + delete data.goodMode; + delete data.facing; + delete data.visualFacing; + delete data.fallDir; + delete data.hurtTimer; + const defaults = v6DefaultTarinaiData(data); + const core = packV6CoreTarinai(data, ctx); + const extras = packV6ExtrasTarinai(data, defaults, ctx); + return extras.length ? [core, extras] : core; + } + + function unpackV6Tarinai(row, ctx) { + const split = Array.isArray(row) && Array.isArray(row[0]) && Array.isArray(row[1]); + const core = split ? row[0] : row; + const extras = split ? row[1] : []; + const data = unpackV6CoreTarinai(core, ctx); + Object.assign(data, unpackV6ExtrasTarinai(extras, ctx)); + if (!data.generation) data.generation = 1; + if (!data.name) data.name = data.isZunchiSlave || data.zunchiSlaveLocked ? "zunchi_slave" : "tarinai"; + if (!data.birthPersonality && data.currentPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + return data; + } + + function encodeV6WorldValue(key, value) { + if (key === "fieldType") return codeOrValue(value, V6_FIELD_TYPES); + if (key === "weather") return codeOrValue(value, V6_WEATHERS); + if (key === "colonyMood") return codeOrValue(value, V5_MOODS); + return compactValue(value, 0, key); + } + + function decodeV6WorldValue(key, value) { + if (key === "fieldType") return valueOrCode(value, V6_FIELD_TYPES); + if (key === "weather") return valueOrCode(value, V6_WEATHERS); + if (key === "colonyMood") return { id: valueOrCode(value, V5_MOODS) || "relaxed" }; + return clonePlain(value); + } + + function packV6World(worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.frozenTarinai; + delete world.family; + const defaults = { w: 1000, h: 720, time: 0, day: 1, speed: 1, fieldType: "garden", fieldZoom: 1, cameraX: 0, cameraY: 0, weather: "sunny", deadCount: 0, liveIdNext: 1, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed", lastColonyMoodDay: 0 }; + return packSparseV5(world, V5_WORLD_KEYS, defaults, encodeV6WorldValue); + } + + function unpackV6World(row) { + return unpackSparseV5(row, V5_WORLD_KEYS, decodeV6WorldValue); + } + + function packV6Items(worldRef, referencedIds) { + const base = []; + const extras = []; + let index = 0; + for (const item of worldRef.items || []) { + if (!item || item.dead || ["trace", "splat"].includes(item.type)) continue; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + const keepId = Boolean(data.id && (referencedIds?.has?.(data.id) || type === "ant_nest" || type === "nest_box" || data.pinState === "lodged")); + const defaults = v5ItemDefaultsFor(type, keepId); + const extra = packSparseV5(data, V5_ITEM_EXTRA_KEYS, defaults, encodeV5ItemValue); + base.push(codeFromList(type, V4_ITEM_TYPES), compactCoordinate(data.x || 0), compactCoordinate(data.y || 0)); + if (extra.length) extras.push(index, extra); + index += 1; + } + return extras.length ? [base, extras] : [base]; + } + + function unpackV6Items(payload) { + const base = Array.isArray(payload?.[0]) ? payload[0] : []; + const extraPairs = Array.isArray(payload?.[1]) ? payload[1] : []; + const extraByIndex = new Map(); + for (let i = 0; i < extraPairs.length - 1; i += 2) extraByIndex.set(Number(extraPairs[i]) || 0, extraPairs[i + 1]); + const out = []; + for (let i = 0, index = 0; i < base.length - 2; i += 3, index++) { + const type = valueFromList(base[i], V4_ITEM_TYPES); + if (!type) continue; + const data = unpackSparseV5(extraByIndex.get(index) || [], V5_ITEM_EXTRA_KEYS, decodeV5ItemValue); + data.type = type; + data.x = Number(base[i + 1] || 0); + data.y = Number(base[i + 2] || 0); + out.push(data); + } + return out; + } + + function compactLossesV6(losses, ctx) { + if (!Array.isArray(losses) || !losses.length) return []; + const out = []; + for (const loss of losses.slice(-8)) { + if (!loss) continue; + const rel = codeOrValue(loss.relation || "", V6_LOSS_RELATIONS); + const fam = v6FamilyCode(loss.familyKey || "", ctx); + const live = v6IdToCode(loss.liveId || ""); + const row = [rel]; + if (fam !== "") row.push(fam); + if (live !== "") row.push(live); + out.push(row); + } + return out; + } + + function expandLossesV6(rows, ctx) { + if (!Array.isArray(rows)) return []; + return rows.map(row => Array.isArray(row) ? { + relation: valueOrCode(row[0], V6_LOSS_RELATIONS) || "unknown", + name: "unknown", + familyKey: row.length > 1 ? v6FamilyValue(row[1], ctx) : "", + liveId: row.length > 2 ? v6CodeToId(row[2]) : "", + reason: "鬮ョ蠑ア繝サ繝サ・ス繝サ・サ鬮」雋サ・」・ー郢晢スサ繝サ・。", + time: 0, + } : null).filter(Boolean); + } + + function compactFrozenEntryV6(entry = {}, ctx) { + const data = entry.data || entry; + const row = packV6Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const losses = compactLossesV6(entry.pendingLosses || [], ctx); + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV6(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV5(entry); + const data = unpackV6Tarinai(entry[2] || [], ctx); + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: expandLossesV6(entry[3] || [], ctx) + }; + } + + function createV6Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v6FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list }; + const tarinaiRows = (worldRef.tarinai || []).filter(Boolean).map(t => packV6Tarinai(worldRef, t, ctx)); + const tarinaiDataRows = (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V6_SNAPSHOT_VERSION, + packV6World(worldRef), + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV6(entry, ctx)), + ]; + } + + function expandV6Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V6_SNAPSHOT_VERSION) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + world.frozenTarinai = (snapshot[5] || []).map((entry, index) => expandFrozenEntryV6(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: Array.isArray(snapshot[2]) ? snapshot[2].length : 0, items: Array.isArray(snapshot[3]?.[0]) ? Math.floor(snapshot[3][0].length / 3) : 0 }, + world, + tarinai: (snapshot[2] || []).map(row => unpackV6Tarinai(row, ctx)), + items: unpackV6Items(snapshot[3] || []), + ants: (snapshot[4] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + + + // v7 hash format: alpha-only. It keeps individual names and gameplay state, + // drops dead individuals from the family graph/save rows, and relies on the save + // layer's printable-ASCII base91 transport for a shorter hash string. + const V7_SNAPSHOT_VERSION = 7; + const V7_CORE_TARINAI_KEYS = ["id", "familyKey", "x", "y", "age", "hunger", "loneliness", "energy", "stress", "affection", "need", "currentPersonality", "genetics", "adultScale", "scale", "lifeSpan", "state", "reproductionTimer", "digest"]; + const V7_EXTRA_TARINAI_KEYS = ["type", "liveToken", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDisease", "zunchiDiseaseSeverity", "sleepDisease", "explosionDisease", "explosionDiseaseTimer", "fightDisease", "totalFightLosses", "totalFightWins", "isZunchiSlave", "zunchiSlaveLocked", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V7_NUMERIC_BASE = { + adultScale: 1, + scale: 1, + age: 10, + lifeSpan: 1700, + hunger: 30, + loneliness: 40, + energy: 75, + stress: 10, + affection: 15, + reproductionTimer: 30, + digest: 0, + }; + + function v7LiveContext(worldRef, frozen = []) { + const liveIds = new Set(); + const liveFamilyKeys = new Set(); + const frozenIds = new Set(); + const frozenFamilyKeys = new Set(); + for (const t of worldRef?.tarinai || []) { + if (!t || t.dead) continue; + if (t.id) liveIds.add(t.id); + if (t.familyKey) liveFamilyKeys.add(String(t.familyKey)); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + if (data.id || entry.liveId) frozenIds.add(data.id || entry.liveId); + if (data.familyKey || entry.familyKey) frozenFamilyKeys.add(String(data.familyKey || entry.familyKey)); + } + return { liveIds, liveFamilyKeys, frozenIds, frozenFamilyKeys }; + } + + function v7FamilyMaps(worldRef, frozen = []) { + const live = v7LiveContext(worldRef, frozen); + const map = new Map(); + const list = []; + const add = (value) => { + if (!value) return null; + const key = String(value); + if (!map.has(key)) { + map.set(key, map.size); + list.push(key); + } + return map.get(key); + }; + for (const t of worldRef?.tarinai || []) { + if (!t || t.dead) continue; + add(t.familyKey || t.archiveKey || t.id); + for (const id of t.parents || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + for (const id of t.children || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + } + for (const entry of frozen || []) { + const data = entry?.data || entry; + if (!data) continue; + add(entry.familyKey || data.familyKey || data.id); + for (const id of data.parents || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + for (const id of data.children || []) if (live.liveFamilyKeys.has(String(id)) || live.frozenFamilyKeys.has(String(id))) add(id); + // Pending death losses are gameplay notifications for frozen individuals, not family-chart nodes. + for (const loss of entry.pendingLosses || []) add(loss?.familyKey); + } + return { map, list, live }; + } + + function v7IsKnownFamily(value, ctx) { + if (!value) return false; + const key = String(value); + return Boolean(ctx?.live?.liveFamilyKeys?.has?.(key) || ctx?.live?.frozenFamilyKeys?.has?.(key)); + } + + function v7EncodeRelativeNumber(key, value) { + if (!Number.isFinite(value)) return undefined; + if (!Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return compactValue(value, 0, key); + return compactNumber(value - V7_NUMERIC_BASE[key]); + } + + function v7DecodeRelativeNumber(key, value) { + if (!Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return clonePlain(value); + return compactNumber(Number(value || 0) + V7_NUMERIC_BASE[key]); + } + + function compactRelationshipsV7(rels, ctx) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + const allowed = ctx?.live?.liveIds || null; + for (const [id, r] of Object.entries(rels)) { + if (!id || !r) continue; + if (allowed && !allowed.has(id)) continue; + const row = [v6RelationKey(id), compactMetric(r.affinity || 0), compactMetric(r.fear || 0), compactNumber(r.fightsWon || 0), compactNumber(r.fightsLost || 0)]; + while (row.length > 1 && (row[row.length - 1] === 0 || row[row.length - 1] === "" || row[row.length - 1] === null || row[row.length - 1] === undefined)) row.pop(); + if (row.length > 1) rows.push(row); + } + rows.sort((a, b) => { + const score = row => Math.abs(row[1] || 0) + Math.abs(row[2] || 0) + Math.abs(row[3] || 0) * 2 + Math.abs(row[4] || 0) * 2; + return score(b) - score(a); + }); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function encodeV7TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyCode(value, ctx); + if (key === "id") return v6IdToCode(value); + if (key === "name") return value || "tarinai"; + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "need") return codeOrValue(value, V5_NEEDS); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "currentPersonality") return packAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return packAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) && value.length ? value.filter(v => v7IsKnownFamily(v, ctx)).map(v => v6FamilyCode(v, ctx)).filter(v => v !== "") : undefined; + if (key === "relationships") return compactRelationshipsV7(value, ctx); + if (key === "itemEffectTimers") return compactItemEffectTimers(value); + if (Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return v7EncodeRelativeNumber(key, Number(value)); + return compactValue(value, 0, key); + } + + function decodeV7TarinaiValue(key, value, ctx) { + if (key === "familyKey") return v6FamilyValue(value, ctx); + if (key === "id") return v6CodeToId(value); + if (key === "name") return value || "tarinai"; + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "need") return valueOrCode(value, V5_NEEDS); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "currentPersonality") return unpackAxisObject(value, PERSONALITY_ORDER); + if (key === "genetics") return unpackAxisObject(value, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v6FamilyValue(v, ctx)).filter(Boolean) : []; + if (key === "relationships") return expandRelationshipsV6(value); + if (key === "itemEffectTimers") return expandItemEffectTimers(value); + if (Object.prototype.hasOwnProperty.call(V7_NUMERIC_BASE, key)) return v7DecodeRelativeNumber(key, value); + return clonePlain(value); + } + + function v7DefaultTarinaiData() { + const out = v6DefaultTarinaiData({}); + out.name = "tarinai"; + return out; + } + + function packV7CoreTarinai(data, ctx) { + const row = V7_CORE_TARINAI_KEYS.map(key => { + const encoded = encodeV7TarinaiValue(key, data?.[key], ctx); + return isEmptyCompactValue(encoded) ? null : encoded; + }); + while (row.length && (row[row.length - 1] === null || row[row.length - 1] === undefined || isEmptyCompactValue(row[row.length - 1]))) row.pop(); + return row; + } + + function unpackV7CoreTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < Math.min(row.length, V7_CORE_TARINAI_KEYS.length); i++) { + const value = row[i]; + if (value === null || value === undefined) continue; + const key = V7_CORE_TARINAI_KEYS[i]; + out[key] = decodeV7TarinaiValue(key, value, ctx); + } + return out; + } + + function packV7ExtrasTarinai(data, defaults, ctx) { + const out = []; + for (let i = 0; i < V7_EXTRA_TARINAI_KEYS.length; i++) { + const key = V7_EXTRA_TARINAI_KEYS[i]; + const encoded = encodeV7TarinaiValue(key, data?.[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + if (defaults && approxSame(encoded, encodeV7TarinaiValue(key, defaults[key], ctx), key)) continue; + out.push(i, encoded); + } + return out; + } + + function unpackV7ExtrasTarinai(row, ctx) { + const out = {}; + if (!Array.isArray(row)) return out; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V7_EXTRA_TARINAI_KEYS[row[i]]; + if (!key) continue; + out[key] = decodeV7TarinaiValue(key, row[i + 1], ctx); + } + return out; + } + + function packV7Tarinai(worldRef, tarinai, ctx) { + const data = snapshotEntity(worldRef, tarinai); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v7IsKnownFamily(v, ctx)) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v7IsKnownFamily(v, ctx)) : []; + delete data.parentNames; + delete data.formerName; + delete data.birthPersonality; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.recentChangeCauses; + delete data.records; + delete data.deathReason; + delete data.favorite; + delete data.dead; + delete data.zunchiSlaveSpriteVariant; + delete data.zunchiStain; + delete data.sunbathSpriteVariant; + delete data.goodMode; + delete data.facing; + delete data.visualFacing; + delete data.fallDir; + delete data.hurtTimer; + const defaults = v7DefaultTarinaiData(); + const core = packV7CoreTarinai(data, ctx); + const extras = packV7ExtrasTarinai(data, defaults, ctx); + return extras.length ? [core, extras] : core; + } + + function unpackV7Tarinai(row, ctx) { + const split = Array.isArray(row) && Array.isArray(row[0]) && Array.isArray(row[1]); + const core = split ? row[0] : row; + const extras = split ? row[1] : []; + const data = unpackV7CoreTarinai(core, ctx); + Object.assign(data, unpackV7ExtrasTarinai(extras, ctx)); + if (!data.generation) data.generation = 1; + if (!data.name) data.name = data.isZunchiSlave || data.zunchiSlaveLocked ? "zunchi_slave" : "tarinai"; + if (!data.birthPersonality && data.currentPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + data.dead = false; + return data; + } + + function compactLossesV7(losses, ctx) { + return compactLossesV6(losses, ctx); + } + + function compactFrozenEntryV7(entry = {}, ctx) { + const data = entry.data || entry; + const row = packV7Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const name = data.name || entry.name || ""; + const losses = compactLossesV7(entry.pendingLosses || [], ctx); + if (name && name !== "tarinai") out.push(name); + else if (losses.length) out.push(null); + if (losses.length) out.push(losses); + return out; + } + + function expandFrozenEntryV7(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV6(entry, ctx, index); + const data = unpackV7Tarinai(entry[2] || [], ctx); + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: entry[3] || data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: expandLossesV6(entry[4] || [], ctx) + }; + } + + function createV7Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v7FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list, live: maps.live }; + const aliveTarinai = (worldRef.tarinai || []).filter(t => t && !t.dead); + const tarinaiRows = aliveTarinai.map(t => packV7Tarinai(worldRef, t, ctx)); + const names = aliveTarinai.map(t => t?.name || "tarinai").join("\n"); + const tarinaiDataRows = aliveTarinai.map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V7_SNAPSHOT_VERSION, + packV6World(worldRef), + names, + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV7(entry, ctx)), + ]; + } + + function expandV7Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V7_SNAPSHOT_VERSION) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + const names = typeof snapshot[2] === "string" ? snapshot[2].split("\n") : (Array.isArray(snapshot[2]) ? snapshot[2] : []); + const tarinaiRows = Array.isArray(snapshot[3]) ? snapshot[3] : []; + world.frozenTarinai = (snapshot[6] || []).map((entry, index) => expandFrozenEntryV7(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[4]?.[0]) ? Math.floor(snapshot[4][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => { + const data = unpackV7Tarinai(row, ctx); + if (names[index]) data.name = names[index]; + return data; + }), + items: unpackV6Items(snapshot[4] || []), + ants: (snapshot[5] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + + // v8 hash format: alpha-only and intentionally drops legacy import support. + // It keeps gameplay state and individual names, removes frozen memories, saves no dead family nodes, + // and uses a tiny reset-sentinel for untouched initial colonies. + const V8_SNAPSHOT_VERSION = 8; + + function v8Names(worldRef) { + return (worldRef?.tarinai || []).filter(t => t && !t.dead).map(t => t.name || "tarinai").join("\n"); + } + + function v8InitialCandidate(worldRef) { + if (!worldRef) return false; + if ((worldRef.day || 1) !== 1 || Math.abs(worldRef.time || 0) > 0.08) return false; + if ((worldRef.deadCount || 0) !== 0) return false; + if ((worldRef.ants || []).some(a => a && !a.dead)) return false; + if ((worldRef.effects || []).length) return false; + if ((worldRef.frozenTarinai || []).length) return false; + const field = (typeof FIELD_TYPES !== "undefined" ? FIELD_TYPES?.[worldRef.fieldType || "garden"] : null) || null; + const expectedPop = field?.population ?? (typeof CONFIG !== "undefined" ? CONFIG.initialPopulation : null); + const expectedGrass = field?.grass ?? 18; + const living = (worldRef.tarinai || []).filter(t => t && !t.dead); + if (Number.isFinite(expectedPop) && living.length !== expectedPop) return false; + for (const t of living) { + if ((t.parents || []).length || (t.children || []).length || Object.keys(t.relationships || {}).length) return false; + if (t.state && t.state !== "idle") return false; + if (t.isZunchiSlave || t.zunchiSlaveLocked || t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) return false; + if (t.stuckPushpinId || t.insideNestBoxId || (t.oshiriByoZunchiStock || 0) > 0) return false; + } + const counts = {}; + for (const it of worldRef.items || []) { + if (!it || it.dead || ["trace", "splat"].includes(it.type)) continue; + counts[it.type] = (counts[it.type] || 0) + 1; + if (!["grass", "stone", "bed"].includes(it.type)) return false; + } + return (counts.grass || 0) === expectedGrass && (counts.stone || 0) === 1 && (counts.bed || 0) === 2; + } + + function compactFrozenEntryV8(entry = {}, ctx) { + const data = clonePlain(entry.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + const row = packV7Tarinai(null, data, ctx); + const out = [compactNumber(entry.frozenAt || 0), compactNumber(entry.worldTime || 0), row]; + const name = data.name || entry.name || ""; + if (name && name !== "tarinai") out.push(name); + return out; + } + + function expandFrozenEntryV8(entry = [], ctx, index = 0) { + if (!Array.isArray(entry)) return expandFrozenEntryV7(entry, ctx, index); + const data = unpackV7Tarinai(entry[2] || [], ctx); + if (entry[3]) data.name = entry[3]; + const freezeId = `fz${index + 1}`; + return { + freezeId, + frozenAt: entry[0] || 0, + worldTime: entry[1] || 0, + name: data.name || "tarinai", + familyKey: data.familyKey || "", + liveId: data.id || "", + liveToken: data.liveToken || 0, + type: data.type || "smile", + data, + pendingLosses: [] + }; + } + + function createV8Snapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const fieldCode = codeOrValue(worldRef.fieldType || "garden", V6_FIELD_TYPES); + if (v8InitialCandidate(worldRef)) { + const names = v8Names(worldRef); + return [V8_SNAPSHOT_VERSION, 0, fieldCode, names || ""]; + } + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const maps = v7FamilyMaps(worldRef, frozen); + const ctx = { familyMap: maps.map, familyList: maps.list, live: maps.live }; + const aliveTarinai = (worldRef.tarinai || []).filter(t => t && !t.dead); + const tarinaiRows = aliveTarinai.map(t => packV7Tarinai(worldRef, t, ctx)); + const names = aliveTarinai.map(t => t?.name || "tarinai").join("\n"); + const tarinaiDataRows = aliveTarinai.map(t => snapshotEntity(worldRef, t)); + const referencedIds = v5ReferencedItemIds(worldRef, tarinaiDataRows); + return [ + V8_SNAPSHOT_VERSION, + packV6World(worldRef), + names, + tarinaiRows, + packV6Items(worldRef, referencedIds), + (worldRef.ants || []).filter(a => a && !a.dead).map(a => packV5Ant(worldRef, a)), + frozen.map(entry => compactFrozenEntryV8(entry, ctx)), + ]; + } + + function expandV8Snapshot(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V8_SNAPSHOT_VERSION || snapshot[1] === 0) return snapshot; + const ctx = {}; + const world = unpackV6World(snapshot[1] || []); + const names = typeof snapshot[2] === "string" ? snapshot[2].split("\n") : (Array.isArray(snapshot[2]) ? snapshot[2] : []); + const tarinaiRows = Array.isArray(snapshot[3]) ? snapshot[3] : []; + world.frozenTarinai = (snapshot[6] || []).map((entry, index) => expandFrozenEntryV8(entry, ctx, index)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: world.day || 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[4]?.[0]) ? Math.floor(snapshot[4][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => { + const data = unpackV7Tarinai(row, ctx); + if (names[index]) data.name = names[index]; + return data; + }), + items: unpackV6Items(snapshot[4] || []), + ants: (snapshot[5] || []).map(unpackV5Ant), + effects: [], + logs: [], + selectedRef: null, + }; + } + + function restoreV8InitialSnapshot(snapshot, worldRef = global.world) { + if (!Array.isArray(snapshot) || snapshot[0] !== V8_SNAPSHOT_VERSION || snapshot[1] !== 0) return false; + const fieldType = valueOrCode(snapshot[2], V6_FIELD_TYPES) || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(null, fieldType); + const names = typeof snapshot[3] === "string" ? snapshot[3].split("\n") : []; + const living = (worldRef.tarinai || []).filter(t => t && !t.dead); + for (let i = 0; i < Math.min(names.length, living.length); i++) if (names[i]) living[i].name = names[i]; + worldRef.logs = []; + worldRef.selected = null; + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof renderLog === "function") renderLog([]); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + + // v9 hash format: alpha-only and current-only. + // This removes the V8 initial-state sentinel and uses one binary packet for every state. + // Game-play state is kept; observation history, dead family nodes, and frozen memories are omitted. + const V9_SNAPSHOT_VERSION = 9; + const V9_NAME_CHARS = "\u306f\u3046\u3045\u3063"; + const V9_BOOLEAN_FLAGS = ["hasPaired", "zunchiDisease", "sleepDisease", "explosionDisease", "fightDisease", "isZunchiSlave", "zunchiSlaveLocked"]; + const V9_CORE_NUMERIC_KEYS = ["age", "hunger", "loneliness", "energy", "stress", "affection", "adultScale", "scale", "lifeSpan", "reproductionTimer", "digest"]; + const V9_EXTRA_TARINAI_KEYS = ["type", "state", "vx", "vy", "impulseVx", "impulseVy", "generation", "birthTime", "parents", "children", "relationships", "hasPaired", "loveMochiTimer", "fightMochiTimer", "zunchiDiseaseSeverity", "explosionDiseaseTimer", "totalFightLosses", "totalFightWins", "poopCount", "oshiriByoZunchiStock", "stuckPushpinId", "itemEffectTimers", "laxativePoopTimer", "powerItemMode", "sizeItemMode", "lifeItemMode", "mercuryLifeSpanBase", "mercuryLifeMultiplier", "insideNestBoxId", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]; + const V9_TIMER_KEYS = new Set(["loveMochiTimer", "fightMochiTimer", "explosionDiseaseTimer", "laxativePoopTimer", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer"]); + const V9_ITEM_EXTRA_KEYS = ["r", "amount", "age", "toolSize", "foodServingScale", "foodServingsMax", "foodServingsRemaining", "growth", "health", "seedTimer", "eatenAmount", "fertilityBoost", "lifeSpan", "wither", "comfort", "wear", "text", "vx", "vy", "spin", "spinVelocity", "stage", "stageTimer", "fertility", "freshness", "zunchiVariant", "antCount", "antSpawnTimer", "antSpawnCooldown", "antWorkers", "queenSpawnAt", "workerSprite", "fuseTimer", "fuseMax", "dropTimer", "dropMax", "pinState", "pinTargetId", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "pinFlipX", "machineSlotType", "machineSlotSize", "machineSpawnTimer", "machineLastSlotType", "machineProduced", "machineProducerId", "sourceMachineType", "infiniteServing", "noDecay", "cannonAngle", "cannonFireTimer", "cannonProjectile", "projectileSourceId", "projectileTimer"]; + const V9_ANT_KEYS = ["kind", "homeId", "targetId", "targetToken", "x", "y", "vx", "vy", "r", "maxHp", "hp", "state", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingX", "foundingY", "foundingDelayUntil", "foundingAttempts"]; + const V9_WORLD_KEYS = ["w", "h", "time", "fieldType", "weather", "nextWeatherChange", "deadCount", "liveIdSerial", "lastBirthAt", "maxGeneration", "colonyMood", "lastColonyMoodDay"]; + + function v9Coord(value) { return Math.round((Number(value) || 0) * 10); } + function v9Uncoord(value) { return compactCoordinate((Number(value) || 0) / 10); } + function v9Num1(value) { return Math.round((Number(value) || 0) * 10); } + function v9Unnum1(value) { return compactNumber((Number(value) || 0) / 10); } + function v9Diff1(key, value) { return v9Num1((Number(value) || 0) - (V7_NUMERIC_BASE[key] || 0)); } + function v9Undiff1(key, value) { return compactNumber((V7_NUMERIC_BASE[key] || 0) + ((Number(value) || 0) / 10)); } + + function v9PackName(name) { + const s = String(name || "tarinai"); + if (s === "zunchi_slave") return -1; + if (s === "tarinai") return -2; + if (s.length > 0 && s.length <= 15 && Array.from(s).every(ch => V9_NAME_CHARS.includes(ch))) { + let code = s.length & 15; + let shift = 4; + for (const ch of s) { + code |= (V9_NAME_CHARS.indexOf(ch) & 3) << shift; + shift += 2; + } + return code; + } + return s; + } + + function v9UnpackName(value) { + if (value === -1) return "zunchi_slave"; + if (value === -2 || value === undefined || value === null || value === "") return "tarinai"; + if (typeof value !== "number") return String(value || "tarinai"); + const len = value & 15; + let out = ""; + for (let i = 0, shift = 4; i < len; i++, shift += 2) out += V9_NAME_CHARS[(value >> shift) & 3] || "\\u306f"; + return out || "tarinai"; + } + + function v9LiveLists(worldRef, frozen = []) { + const alive = (worldRef?.tarinai || []).filter(t => t && !t.dead); + const frozenRows = (frozen || []).map(entry => entry?.data || entry).filter(Boolean); + const all = [...alive, ...frozenRows]; + const idMap = new Map(); + const familyMap = new Map(); + all.forEach((t, index) => { + if (t?.id) idMap.set(String(t.id), index); + if (t?.familyKey) familyMap.set(String(t.familyKey), index); + if (t?.archiveKey) familyMap.set(String(t.archiveKey), index); + }); + return { alive, frozenRows, all, idMap, familyMap, liveCount: alive.length }; + } + + function v9ItemList(worldRef) { + const items = (worldRef?.items || []).filter(it => it && !it.dead && !["trace", "splat"].includes(it.type)); + const itemMap = new Map(); + items.forEach((it, index) => { if (it?.id) itemMap.set(String(it.id), index); }); + return { items, itemMap }; + } + + function v9PackTarinaiRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.idMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9PackFamilyRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.familyMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9PackItemRef(value, ctx) { + if (!value) return undefined; + const idx = ctx?.itemMap?.get?.(String(value)); + return idx === undefined ? undefined : idx + 1; + } + + function v9TarinaiId(index) { return `t${index + 1}`; } + function v9FamilyKey(index) { return `f${index + 1}`; } + function v9ItemId(index) { return `i${index + 1}`; } + function v9AntId(index) { return `a${index + 1}`; } + + function v9PackAxisDelta(obj, keys, defaults = null, scale = 100) { + if (!obj || typeof obj !== "object") return undefined; + let mask = 0; + const vals = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const base = defaults?.[key] ?? 0; + const diff = Math.round(((Number(obj[key]) || 0) - base) * scale); + if (!diff) continue; + mask |= (1 << i); + vals.push(diff); + } + return mask ? [mask, ...vals] : undefined; + } + + function v9UnpackAxisDelta(row, keys, defaults = null, scale = 100) { + const out = defaults && typeof defaults === "object" ? clonePlain(defaults) || {} : {}; + if (!Array.isArray(row) || !row.length) return out; + const mask = Number(row[0]) || 0; + let p = 1; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const base = defaults?.[key] ?? 0; + out[key] = mask & (1 << i) ? compactNumber(base + ((Number(row[p++]) || 0) / scale)) : base; + } + return out; + } + + function v9PackRelationships(rels, ctx) { + if (!rels || typeof rels !== "object") return undefined; + const rows = []; + for (const [id, r] of Object.entries(rels)) { + const idx = v9PackTarinaiRef(id, ctx); + if (idx === undefined || !r) continue; + const aff = v9Num1(r.affinity || 0); + const fear = v9Num1(r.fear || 0); + const wins = Math.round(Number(r.fightsWon || 0)); + const losses = Math.round(Number(r.fightsLost || 0)); + if (!aff && !fear && !wins && !losses) continue; + const row = [idx, aff, fear, wins, losses]; + while (row.length > 1 && !row[row.length - 1]) row.pop(); + rows.push(row); + } + rows.sort((a, b) => (Math.abs(b[1] || 0) + Math.abs(b[2] || 0) + (b[3] || 0) * 20 + (b[4] || 0) * 20) - (Math.abs(a[1] || 0) + Math.abs(a[2] || 0) + (a[3] || 0) * 20 + (a[4] || 0) * 20)); + return rows.length ? rows.slice(0, 20) : undefined; + } + + function v9UnpackRelationships(rows) { + if (!Array.isArray(rows)) return {}; + const out = {}; + for (const row of rows) { + if (!Array.isArray(row) || !Number.isFinite(row[0])) continue; + out[v9TarinaiId(row[0] - 1)] = { affinity: v9Unnum1(row[1] || 0), fear: v9Unnum1(row[2] || 0), fightsWon: Number(row[3] || 0), fightsLost: Number(row[4] || 0), lastEvent: "", lastTime: 0 }; + } + return out; + } + + function v9PackItemEffectTimers(value) { + if (!value || typeof value !== "object") return undefined; + const row = [v9Num1(value.laxative || 0), v9Num1(value.ammo || 0)]; + while (row.length && !row[row.length - 1]) row.pop(); + return row.length ? row : undefined; + } + + function v9UnpackItemEffectTimers(row) { + return { laxative: Array.isArray(row) ? v9Unnum1(row[0] || 0) : 0, ammo: Array.isArray(row) ? v9Unnum1(row[1] || 0) : 0 }; + } + + function v9EncodeTarinaiExtra(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "type") return codeOrValue(value, V4_SPRITE_TYPES); + if (key === "state") return codeOrValue(value, V5_STATES); + if (key === "parents" || key === "children") { + const arr = Array.isArray(value) ? value.map(v => v9PackFamilyRef(v, ctx)).filter(v => v !== undefined) : []; + return arr.length ? arr : undefined; + } + if (key === "relationships") return v9PackRelationships(value, ctx); + if (key === "stuckPushpinId" || key === "insideNestBoxId") return v9PackItemRef(value, ctx); + if (key === "itemEffectTimers") return v9PackItemEffectTimers(value); + if (key === "powerItemMode") return codeOrValue(value, ["", "protein", "niteropu"]); + if (key === "sizeItemMode") return codeOrValue(value, ["", "giant_drug", "dwarf_drug"]); + if (key === "lifeItemMode") return codeOrValue(value, ["", "mercury"]); + if (V9_TIMER_KEYS.has(key)) return v9Num1(value || 0); + if (["vx", "vy", "impulseVx", "impulseVy"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeTarinaiExtra(key, value) { + if (key === "type") return valueOrCode(value, V4_SPRITE_TYPES); + if (key === "state") return valueOrCode(value, V5_STATES); + if (key === "parents" || key === "children") return Array.isArray(value) ? value.map(v => v9FamilyKey(v - 1)).filter(Boolean) : []; + if (key === "relationships") return v9UnpackRelationships(value); + if (key === "stuckPushpinId" || key === "insideNestBoxId") return Number.isFinite(value) ? v9ItemId(value - 1) : null; + if (key === "itemEffectTimers") return v9UnpackItemEffectTimers(value); + if (key === "powerItemMode") return valueOrCode(value, ["", "protein", "niteropu"]); + if (key === "sizeItemMode") return valueOrCode(value, ["", "giant_drug", "dwarf_drug"]); + if (key === "lifeItemMode") return valueOrCode(value, ["", "mercury"]); + if (V9_TIMER_KEYS.has(key)) return v9Unnum1(value || 0); + if (["vx", "vy", "impulseVx", "impulseVy"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackFlags(data) { + let flags = 0; + for (let i = 0; i < V9_BOOLEAN_FLAGS.length; i++) if (data?.[V9_BOOLEAN_FLAGS[i]]) flags |= (1 << i); + return flags; + } + + function v9ApplyFlags(data, flags) { + for (let i = 0; i < V9_BOOLEAN_FLAGS.length; i++) data[V9_BOOLEAN_FLAGS[i]] = Boolean(flags & (1 << i)); + } + + function v9PackTarinai(worldRef, t, ctx) { + const data = snapshotEntity(worldRef, t); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + const row = [v9PackName(data.name), v9Coord(data.x), v9Coord(data.y), codeOrValue(data.need || "", V5_NEEDS)]; + const numeric = []; + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + const key = V9_CORE_NUMERIC_KEYS[i]; + const diff = v9Diff1(key, Number(data[key] || 0)); + if (diff) numeric.push(i, diff); + } + if (numeric.length) row.push(numeric); else row.push(null); + const personality = v9PackAxisDelta(data.currentPersonality, PERSONALITY_ORDER, null, 100); + const genetics = v9PackAxisDelta(data.genetics, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + const axes = []; + if (personality) axes.push(0, personality); + if (genetics) axes.push(1, genetics); + row.push(axes.length ? axes : null); + const flags = v9PackFlags(data); + row.push(flags || null); + const defaults = v7DefaultTarinaiData(); + const extras = []; + for (let i = 0; i < V9_EXTRA_TARINAI_KEYS.length; i++) { + const key = V9_EXTRA_TARINAI_KEYS[i]; + if (V9_BOOLEAN_FLAGS.includes(key)) continue; + const encoded = v9EncodeTarinaiExtra(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeTarinaiExtra(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + extras.push(i, encoded); + } + row.push(extras.length ? extras : null); + while (row.length > 4 && isEmptyCompactValue(row[row.length - 1])) row.pop(); + return row; + } + + function v9UnpackTarinai(row, index = 0) { + const data = { id: v9TarinaiId(index), familyKey: v9FamilyKey(index), liveToken: index + 1, generation: 1, state: "idle", type: "smile", parents: [], children: [], relationships: {}, itemEffectTimers: { laxative: 0, ammo: 0 } }; + if (!Array.isArray(row)) return data; + data.name = v9UnpackName(row[0]); + data.x = v9Uncoord(row[1] || 0); + data.y = v9Uncoord(row[2] || 0); + data.need = valueOrCode(row[3], V5_NEEDS) || ""; + for (const key of V9_CORE_NUMERIC_KEYS) data[key] = V7_NUMERIC_BASE[key] ?? 0; + const numeric = Array.isArray(row[4]) ? row[4] : []; + for (let i = 0; i < numeric.length - 1; i += 2) { + const key = V9_CORE_NUMERIC_KEYS[numeric[i]]; + if (key) data[key] = v9Undiff1(key, numeric[i + 1]); + } + const axes = Array.isArray(row[5]) ? row[5] : []; + for (let i = 0; i < axes.length - 1; i += 2) { + if (axes[i] === 0) data.currentPersonality = v9UnpackAxisDelta(axes[i + 1], PERSONALITY_ORDER, null, 100); + if (axes[i] === 1) data.genetics = v9UnpackAxisDelta(axes[i + 1], GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + } + if (!data.currentPersonality) data.currentPersonality = {}; + if (!data.birthPersonality) data.birthPersonality = clonePlain(data.currentPersonality); + if (!data.genetics) data.genetics = { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }; + v9ApplyFlags(data, Number(row[6] || 0)); + const extras = Array.isArray(row[7]) ? row[7] : []; + for (let i = 0; i < extras.length - 1; i += 2) { + const key = V9_EXTRA_TARINAI_KEYS[extras[i]]; + if (!key) continue; + data[key] = v9DecodeTarinaiExtra(key, extras[i + 1]); + } + if (data.isZunchiSlave || data.zunchiSlaveLocked) { + data.isZunchiSlave = true; + data.zunchiSlaveLocked = true; + data.name = "zunchi_slave"; + } + data.dead = false; + return data; + } + + function v9EncodeItemValue(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "toolSize" || key === "machineSlotSize") return codeOrValue(value, V5_SIZES); + if (key === "pinState") return codeOrValue(value, V5_PIN_STATES); + if (key === "stage") return codeOrValue(value, V5_ZUNCHI_STAGES); + if (["machineSlotType", "machineLastSlotType", "sourceMachineType"].includes(key)) return codeOrValue(value, V4_ITEM_TYPES); + if (key === "pinTargetId") return v9PackTarinaiRef(value, ctx); + if (["machineProducerId", "projectileSourceId"].includes(key)) return v9PackItemRef(value, ctx); + if (["vx", "vy", "spinVelocity", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "cannonAngle"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeItemValue(key, value) { + if (key === "toolSize" || key === "machineSlotSize") return valueOrCode(value, V5_SIZES); + if (key === "pinState") return valueOrCode(value, V5_PIN_STATES); + if (key === "stage") return valueOrCode(value, V5_ZUNCHI_STAGES); + if (["machineSlotType", "machineLastSlotType", "sourceMachineType"].includes(key)) return valueOrCode(value, V4_ITEM_TYPES); + if (key === "pinTargetId") return Number.isFinite(value) ? v9TarinaiId(value - 1) : ""; + if (["machineProducerId", "projectileSourceId"].includes(key)) return Number.isFinite(value) ? v9ItemId(value - 1) : ""; + if (["vx", "vy", "spinVelocity", "pinAttachAngle", "pinAttachDistance", "pinOffsetY", "cannonAngle"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackItems(worldRef, ctx) { + const base = []; + const extras = []; + for (let index = 0; index < ctx.items.length; index++) { + const item = ctx.items[index]; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + base.push(codeFromList(type, V4_ITEM_TYPES), v9Coord(data.x), v9Coord(data.y)); + const defaults = itemDefaultsFor(type); + const pairs = []; + for (let i = 0; i < V9_ITEM_EXTRA_KEYS.length; i++) { + const key = V9_ITEM_EXTRA_KEYS[i]; + const encoded = v9EncodeItemValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeItemValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + pairs.push(i, encoded); + } + if (pairs.length) extras.push(index, pairs); + } + return extras.length ? [base, extras] : [base]; + } + + function v9UnpackItems(payload) { + const base = Array.isArray(payload?.[0]) ? payload[0] : []; + const extraPairs = Array.isArray(payload?.[1]) ? payload[1] : []; + const extraByIndex = new Map(); + for (let i = 0; i < extraPairs.length - 1; i += 2) extraByIndex.set(Number(extraPairs[i]) || 0, extraPairs[i + 1]); + const out = []; + for (let i = 0, index = 0; i < base.length - 2; i += 3, index++) { + const type = valueFromList(base[i], V4_ITEM_TYPES); + if (!type) continue; + const data = { id: v9ItemId(index), type, x: v9Uncoord(base[i + 1] || 0), y: v9Uncoord(base[i + 2] || 0) }; + const extras = extraByIndex.get(index) || []; + for (let p = 0; p < extras.length - 1; p += 2) { + const key = V9_ITEM_EXTRA_KEYS[extras[p]]; + if (!key) continue; + data[key] = v9DecodeItemValue(key, extras[p + 1]); + } + out.push(data); + } + return out; + } + + function v9EncodeAntValue(key, value, ctx) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "homeId") return v9PackItemRef(value, ctx); + if (key === "targetId") { + const item = v9PackItemRef(value, ctx); + if (item !== undefined) return item; + const tarinai = v9PackTarinaiRef(value, ctx); + return tarinai !== undefined ? -tarinai : undefined; + } + if (["x", "y", "foundingX", "foundingY"].includes(key)) return v9Coord(value || 0); + if (["vx", "vy", "r", "hp", "maxHp", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingDelayUntil"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeAntValue(key, value) { + if (key === "homeId") return Number.isFinite(value) ? v9ItemId(value - 1) : ""; + if (key === "targetId") { + if (!Number.isFinite(value)) return ""; + return value < 0 ? v9TarinaiId((-value) - 1) : v9ItemId(value - 1); + } + if (["x", "y", "foundingX", "foundingY"].includes(key)) return v9Uncoord(value || 0); + if (["vx", "vy", "r", "hp", "maxHp", "age", "seed", "wanderAngle", "nextTurn", "returnTimer", "foundingDelayUntil"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackAnts(worldRef, ctx) { + const rows = []; + for (const ant of worldRef.ants || []) { + if (!ant || ant.dead) continue; + const data = snapshotEntity(worldRef, ant); + const pairs = []; + const defaults = { kind: "worker", x: 0, y: 0, vx: 0, vy: 0, r: 4, maxHp: 1, hp: 1, state: "wander", age: 0, seed: 0, wanderAngle: 0, nextTurn: 0, returnTimer: 0, foundingAttempts: 0 }; + for (let i = 0; i < V9_ANT_KEYS.length; i++) { + const key = V9_ANT_KEYS[i]; + const encoded = v9EncodeAntValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeAntValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + pairs.push(i, encoded); + } + rows.push(pairs); + } + return rows; + } + + function v9UnpackAnts(rows) { + if (!Array.isArray(rows)) return []; + return rows.map((row, index) => { + const data = { id: v9AntId(index) }; + for (let i = 0; i < row.length - 1; i += 2) { + const key = V9_ANT_KEYS[row[i]]; + if (!key) continue; + data[key] = v9DecodeAntValue(key, row[i + 1]); + } + return data; + }); + } + + function v9EncodeWorldValue(key, value) { + if (value === undefined || value === null || value === "") return undefined; + if (key === "fieldType") return codeOrValue(value, V6_FIELD_TYPES); + if (key === "weather") return codeOrValue(value, V6_WEATHERS); + if (key === "colonyMood") return codeOrValue(value, V5_MOODS); + if (["w", "h", "time", "nextWeatherChange", "lastBirthAt", "lastColonyMoodDay"].includes(key)) return v9Num1(value || 0); + return compactValue(value, 0, key); + } + + function v9DecodeWorldValue(key, value) { + if (key === "fieldType") return valueOrCode(value, V6_FIELD_TYPES); + if (key === "weather") return valueOrCode(value, V6_WEATHERS); + if (key === "colonyMood") return { id: valueOrCode(value, V5_MOODS) || "relaxed" }; + if (["w", "h", "time", "nextWeatherChange", "lastBirthAt", "lastColonyMoodDay"].includes(key)) return v9Unnum1(value || 0); + return clonePlain(value); + } + + function v9PackWorld(worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.day; + const defaults = { w: 1000, h: 720, time: 0, fieldType: "garden", weather: "sunny", deadCount: 0, liveIdSerial: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed", lastColonyMoodDay: 0 }; + return packSparseV5(world, V9_WORLD_KEYS, defaults, v9EncodeWorldValue); + } + + function v9UnpackWorld(row) { + const world = unpackSparseV5(row, V9_WORLD_KEYS, v9DecodeWorldValue); + world.day = 1; + if (!world.fieldType) world.fieldType = "garden"; + if (!world.colonyMood) world.colonyMood = { id: "relaxed" }; + return world; + } + + function v9PackFrozen(entry = {}, ctx) { + const data = clonePlain(entry.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + const row = v9PackTarinai(null, data, ctx); + const out = [v9Num1(entry.frozenAt || 0), v9Num1(entry.worldTime || 0), row]; + return out; + } + + function v9UnpackFrozen(entry = [], index = 0, liveCount = 0) { + const data = v9UnpackTarinai(entry[2] || [], liveCount + index); + data.id = `ft${index + 1}`; + data.familyKey = `ff${index + 1}`; + const freezeId = `fz${index + 1}`; + return { freezeId, frozenAt: v9Unnum1(entry[0] || 0), worldTime: v9Unnum1(entry[1] || 0), name: data.name || "tarinai", familyKey: data.familyKey || "", liveId: data.id || "", liveToken: data.liveToken || 0, type: data.type || "smile", data, pendingLosses: [] }; + } + + function createV9SnapshotArray(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const live = v9LiveLists(worldRef, frozen); + const itemInfo = v9ItemList(worldRef); + const ctx = { ...live, ...itemInfo }; + return [ + V9_SNAPSHOT_VERSION, + v9PackWorld(worldRef), + live.alive.map(t => v9PackTarinai(worldRef, t, ctx)), + v9PackItems(worldRef, ctx), + v9PackAnts(worldRef, ctx), + frozen.map(entry => v9PackFrozen(entry, ctx)), + ]; + } + + function expandV9SnapshotArray(snapshot) { + if (!Array.isArray(snapshot) || snapshot[0] !== V9_SNAPSHOT_VERSION) return snapshot; + const world = v9UnpackWorld(snapshot[1] || []); + const tarinaiRows = Array.isArray(snapshot[2]) ? snapshot[2] : []; + world.frozenTarinai = (snapshot[5] || []).map((entry, index) => v9UnpackFrozen(entry, index, tarinaiRows.length)); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: 1, time: "", fieldType: world.fieldType || "garden", population: tarinaiRows.length, items: Array.isArray(snapshot[3]?.[0]) ? Math.floor(snapshot[3][0].length / 3) : 0 }, + world, + tarinai: tarinaiRows.map((row, index) => v9UnpackTarinai(row, index)), + items: v9UnpackItems(snapshot[3] || []), + ants: v9UnpackAnts(snapshot[4] || []), + effects: [], + logs: [], + selectedRef: null, + }; + } + + class V9BinaryWriter { + constructor() { this.bytes = []; } + byte(v) { this.bytes.push(v & 255); } + varUint(v) { + let n = Math.max(0, Math.floor(Number(v) || 0)); + while (n >= 128) { this.byte((n & 127) | 128); n = Math.floor(n / 128); } + this.byte(n); + } + sint(v) { + const n = Math.trunc(Number(v) || 0); + this.varUint(n < 0 ? ((-n) * 2 - 1) : n * 2); + } + bytesRaw(bytes) { for (const b of bytes) this.byte(b); } + value(value) { + if (value === undefined || value === null) { this.byte(0); return; } + if (value === false) { this.byte(1); return; } + if (value === true) { this.byte(2); return; } + if (typeof value === "number") { + if (Number.isInteger(value)) { this.byte(value >= 0 ? 3 : 4); value >= 0 ? this.varUint(value) : this.sint(value); return; } + this.byte(5); this.sint(Math.round(value * 1000)); return; + } + if (typeof value === "string") { + const bytes = new TextEncoder().encode(value); + this.byte(6); this.varUint(bytes.length); this.bytesRaw(bytes); return; + } + if (Array.isArray(value)) { + this.byte(7); this.varUint(value.length); for (const v of value) this.value(v); return; + } + this.value(JSON.stringify(value)); + } + finish() { return new Uint8Array(this.bytes); } + } + + class V9BinaryReader { + constructor(bytes) { this.bytes = bytes || new Uint8Array(); this.i = 0; } + byte() { return this.i < this.bytes.length ? this.bytes[this.i++] : 0; } + varUint() { + let shift = 0, out = 0, mul = 1; + while (this.i < this.bytes.length) { + const b = this.byte(); + out += (b & 127) * mul; + if (!(b & 128)) break; + mul *= 128; + shift += 7; + if (shift > 42) break; + } + return out; + } + sint() { const u = this.varUint(); return u & 1 ? -((u + 1) / 2) : u / 2; } + value() { + const tag = this.byte(); + if (tag === 0) return null; + if (tag === 1) return false; + if (tag === 2) return true; + if (tag === 3) return this.varUint(); + if (tag === 4) return this.sint(); + if (tag === 5) return this.sint() / 1000; + if (tag === 6) { + const len = this.varUint(); + const slice = this.bytes.slice(this.i, this.i + len); + this.i += len; + return new TextDecoder().decode(slice); + } + if (tag === 7) { + const len = this.varUint(); + const arr = []; + for (let i = 0; i < len; i++) arr.push(this.value()); + return arr; + } + return null; + } + } + + + // v10 hash format: current-only compact packet. + // It removes the V8 initial-state special case and does not use generic JSON/value arrays for common live data. + // Game-play state is kept; observation history, elapsed day count, dead family nodes, and frozen memories are omitted. + const V10_SNAPSHOT_VERSION = 10; + const V10_NAME_CHARS = V9_NAME_CHARS; + const V10_WORLD_KEYS = ["w", "h", "time", "fieldType", "weather", "nextWeatherChange", "deadCount", "lastBirthAt", "maxGeneration", "colonyMood"]; + const V10_WORLD_DEFAULTS = { w: 1000, h: 720, time: 0, fieldType: "garden", weather: "sunny", nextWeatherChange: 0, deadCount: 0, lastBirthAt: -999, maxGeneration: 1, colonyMood: "relaxed" }; + + class V10Writer { + constructor() { this.bytes = []; } + byte(v) { this.bytes.push((Number(v) || 0) & 255); } + u(v) { + let n = Math.max(0, Math.floor(Number(v) || 0)); + while (n >= 128) { this.byte((n % 128) | 128); n = Math.floor(n / 128); } + this.byte(n); + } + z(v) { + const n = Math.trunc(Number(v) || 0); + this.u(n < 0 ? ((-n) * 2 - 1) : n * 2); + } + raw(bytes) { for (const b of bytes || []) this.byte(b); } + str(value) { + const bytes = new TextEncoder().encode(String(value ?? "")); + this.u(bytes.length); + this.raw(bytes); + } + val(value) { + if (value === undefined || value === null) { this.byte(0); return; } + if (value === false) { this.byte(1); return; } + if (value === true) { this.byte(2); return; } + if (typeof value === "number") { this.byte(3); this.z(Math.round(value)); return; } + if (typeof value === "string") { this.byte(4); this.str(value); return; } + if (Array.isArray(value)) { this.byte(5); this.u(value.length); for (const v of value) this.val(v); return; } + this.byte(6); this.str(JSON.stringify(value)); + } + finish() { return new Uint8Array(this.bytes); } + } + + class V10Reader { + constructor(bytes) { this.bytes = bytes || new Uint8Array(); this.i = 0; } + byte() { return this.i < this.bytes.length ? this.bytes[this.i++] : 0; } + u() { + let out = 0, mul = 1, guard = 0; + while (this.i < this.bytes.length && guard++ < 8) { + const b = this.byte(); + out += (b & 127) * mul; + if (!(b & 128)) break; + mul *= 128; + } + return out; + } + z() { const u = this.u(); return u & 1 ? -((u + 1) / 2) : u / 2; } + str() { + const len = this.u(); + const slice = this.bytes.slice(this.i, this.i + len); + this.i += len; + return new TextDecoder().decode(slice); + } + val() { + const tag = this.byte(); + if (tag === 0) return null; + if (tag === 1) return false; + if (tag === 2) return true; + if (tag === 3) return this.z(); + if (tag === 4) return this.str(); + if (tag === 5) { const len = this.u(); const out = []; for (let i = 0; i < len; i++) out.push(this.val()); return out; } + if (tag === 6) { try { return JSON.parse(this.str()); } catch (_) { return {}; } } + return null; + } + } + + function v10PackNameInfo(name) { + const s = String(name || "tarinai"); + if (s === "tarinai") return [0]; + if (s === "zunchi_slave") return [1]; + const chars = Array.from(s); + if (chars.length > 0 && chars.length <= 24 && chars.every(ch => V10_NAME_CHARS.includes(ch))) { + let code = 0; + let mul = 1; + for (const ch of chars) { + code += (V10_NAME_CHARS.indexOf(ch) & 3) * mul; + mul *= 4; + } + return [2, chars.length, code]; + } + return [3, s]; + } + + function v10WriteName(w, name) { + const info = v10PackNameInfo(name); + w.u(info[0]); + if (info[0] === 2) { w.u(info[1]); w.u(info[2]); } + else if (info[0] === 3) w.str(info[1]); + } + + function v10ReadName(r) { + const mode = r.u(); + if (mode === 0) return "tarinai"; + if (mode === 1) return "zunchi_slave"; + if (mode === 2) { + const len = r.u(); + let code = r.u(); + let out = ""; + for (let i = 0; i < len; i++) { + out += V10_NAME_CHARS[code % 4] || "\\u306f"; + code = Math.floor(code / 4); + } + return out || "tarinai"; + } + if (mode === 3) return r.str() || "tarinai"; + return "tarinai"; + } + + function v10WriteAxis(w, obj, keys, defaults = null, scale = 100) { + const packed = v9PackAxisDelta(obj, keys, defaults, scale); + if (!packed) { w.u(0); return; } + const mask = packed[0] || 0; + w.u(mask); + for (let i = 1; i < packed.length; i++) w.z(packed[i]); + } + + function v10ReadAxis(r, keys, defaults = null, scale = 100) { + const mask = r.u(); + if (!mask) return defaults && typeof defaults === "object" ? clonePlain(defaults) || {} : {}; + const row = [mask]; + for (let i = 0; i < keys.length; i++) if (mask & (1 << i)) row.push(r.z()); + return v9UnpackAxisDelta(row, keys, defaults, scale); + } + + function v10WriteTarinai(w, t, worldRef, ctx) { + const data = snapshotEntity(worldRef, t); + data.parents = Array.isArray(data.parents) ? data.parents.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + data.children = Array.isArray(data.children) ? data.children.filter(v => v9PackFamilyRef(v, ctx) !== undefined) : []; + v10WriteName(w, data.name); + w.u(v9Coord(data.x)); + w.u(v9Coord(data.y)); + w.u((codeFromList(data.need || "", V5_NEEDS) + 1) || 0); + let numericMask = 0; + const numericValues = []; + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + const key = V9_CORE_NUMERIC_KEYS[i]; + const diff = v9Diff1(key, Number(data[key] || 0)); + if (!diff) continue; + numericMask |= (1 << i); + numericValues.push(diff); + } + w.u(numericMask); + for (const v of numericValues) w.z(v); + v10WriteAxis(w, data.currentPersonality, PERSONALITY_ORDER, null, 100); + v10WriteAxis(w, data.genetics, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + w.u(v9PackFlags(data)); + const defaults = v7DefaultTarinaiData(); + for (let i = 0; i < V9_EXTRA_TARINAI_KEYS.length; i++) { + const key = V9_EXTRA_TARINAI_KEYS[i]; + if (V9_BOOLEAN_FLAGS.includes(key)) continue; + const encoded = v9EncodeTarinaiExtra(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeTarinaiExtra(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + + function v10ReadTarinai(r, index = 0) { + const data = { id: v9TarinaiId(index), familyKey: v9FamilyKey(index), liveToken: index + 1, generation: 1, state: "idle", type: "smile", parents: [], children: [], relationships: {}, itemEffectTimers: { laxative: 0, ammo: 0 } }; + data.name = v10ReadName(r); + data.x = v9Uncoord(r.u()); + data.y = v9Uncoord(r.u()); + const need = r.u(); + data.need = need ? (valueFromList(need - 1, V5_NEEDS) || "") : ""; + for (const key of V9_CORE_NUMERIC_KEYS) data[key] = V7_NUMERIC_BASE[key] ?? 0; + const numericMask = r.u(); + for (let i = 0; i < V9_CORE_NUMERIC_KEYS.length; i++) { + if (!(numericMask & (1 << i))) continue; + const key = V9_CORE_NUMERIC_KEYS[i]; + data[key] = v9Undiff1(key, r.z()); + } + data.currentPersonality = v10ReadAxis(r, PERSONALITY_ORDER, null, 100); + data.birthPersonality = clonePlain(data.currentPersonality); + data.genetics = v10ReadAxis(r, GENETIC_ORDER, { lifeSpanMul: 1, attackMul: 1, speedMul: 1, sizeMul: 1 }, 100); + v9ApplyFlags(data, r.u()); + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_EXTRA_TARINAI_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeTarinaiExtra(key, value); + } + if (data.isZunchiSlave || data.zunchiSlaveLocked) { + data.isZunchiSlave = true; + data.zunchiSlaveLocked = true; + data.name = "zunchi_slave"; + } + data.dead = false; + return data; + } + + function v10WriteWorld(w, worldRef) { + const world = worldData(worldRef); + world.colonyMood = worldRef?.colonyMood?.id || world.colonyMood?.id || "relaxed"; + delete world.day; + let mask = 0; + const values = []; + for (let i = 0; i < V10_WORLD_KEYS.length; i++) { + const key = V10_WORLD_KEYS[i]; + const raw = key === "colonyMood" ? world.colonyMood : world[key]; + const def = V10_WORLD_DEFAULTS[key]; + let encoded; + if (key === "fieldType") encoded = codeOrValue(raw, V6_FIELD_TYPES); + else if (key === "weather") encoded = codeOrValue(raw, V6_WEATHERS); + else if (key === "colonyMood") encoded = codeOrValue(raw, V5_MOODS); + else if (["w", "h", "time", "nextWeatherChange", "lastBirthAt"].includes(key)) encoded = v9Num1((Number(raw) || 0) - (Number(def) || 0)); + else encoded = Math.round(Number(raw ?? def) || 0) - Math.round(Number(def) || 0); + if (!encoded) continue; + mask |= (1 << i); + values.push(encoded); + } + w.u(mask); + for (const v of values) w.z(v); + } + + function v10ReadWorld(r) { + const out = { day: 1, fieldType: "garden", colonyMood: { id: "relaxed" } }; + const mask = r.u(); + for (let i = 0; i < V10_WORLD_KEYS.length; i++) { + const key = V10_WORLD_KEYS[i]; + const def = V10_WORLD_DEFAULTS[key]; + if (!(mask & (1 << i))) { if (key !== "colonyMood") out[key] = def; continue; } + const v = r.z(); + if (key === "fieldType") out[key] = valueOrCode(v, V6_FIELD_TYPES) || "garden"; + else if (key === "weather") out[key] = valueOrCode(v, V6_WEATHERS) || "sunny"; + else if (key === "colonyMood") out[key] = { id: valueOrCode(v, V5_MOODS) || "relaxed" }; + else if (["w", "h", "time", "nextWeatherChange", "lastBirthAt"].includes(key)) out[key] = v9Unnum1(v9Num1(def) + v); + else out[key] = Math.round(Number(def) || 0) + v; + } + if (!out.colonyMood) out.colonyMood = { id: "relaxed" }; + return out; + } + + function v10WriteItems(w, worldRef, ctx) { + w.u(ctx.items.length); + for (let index = 0; index < ctx.items.length; index++) { + const item = ctx.items[index]; + const data = snapshotEntity(worldRef, item); + const type = data.type || item.type || ""; + w.u((codeFromList(type, V4_ITEM_TYPES) + 1) || 0); + w.u(v9Coord(data.x)); + w.u(v9Coord(data.y)); + const defaults = itemDefaultsFor(type); + for (let i = 0; i < V9_ITEM_EXTRA_KEYS.length; i++) { + const key = V9_ITEM_EXTRA_KEYS[i]; + if (key === "antWorkers" || key === "workerSprite") continue; + const encoded = v9EncodeItemValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeItemValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + } + + function v10ReadItems(r) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const type = valueFromList(r.u() - 1, V4_ITEM_TYPES); + const data = { id: v9ItemId(index), type, x: v9Uncoord(r.u()), y: v9Uncoord(r.u()) }; + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_ITEM_EXTRA_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeItemValue(key, value); + } + if (type) out.push(data); + } + return out; + } + + function v10WriteAnts(w, worldRef, ctx) { + const ants = (worldRef.ants || []).filter(a => a && !a.dead); + w.u(ants.length); + for (const ant of ants) { + const data = snapshotEntity(worldRef, ant); + const defaults = { kind: "worker", x: 0, y: 0, vx: 0, vy: 0, r: 4, maxHp: 1, hp: 1, state: "wander", age: 0, seed: 0, wanderAngle: 0, nextTurn: 0, returnTimer: 0, foundingAttempts: 0 }; + for (let i = 0; i < V9_ANT_KEYS.length; i++) { + const key = V9_ANT_KEYS[i]; + const encoded = v9EncodeAntValue(key, data[key], ctx); + if (isEmptyCompactValue(encoded)) continue; + const def = v9EncodeAntValue(key, defaults[key], ctx); + if (approxSame(encoded, def, key)) continue; + w.u(i + 1); + w.val(encoded); + } + w.u(0); + } + } + + function v10ReadAnts(r) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const data = { id: v9AntId(index) }; + while (true) { + const tag = r.u(); + if (!tag) break; + const key = V9_ANT_KEYS[tag - 1]; + const value = r.val(); + if (!key) continue; + data[key] = v9DecodeAntValue(key, value); + } + out.push(data); + } + return out; + } + + function v10WriteFrozen(w, frozen, ctx) { + w.u((frozen || []).length); + for (const entry of frozen || []) { + w.z(v9Num1(entry?.frozenAt || 0)); + w.z(v9Num1(entry?.worldTime || 0)); + const data = clonePlain(entry?.data || entry) || {}; + delete data.relationships; + delete data.records; + delete data.recentChangeCauses; + delete data.personalityDaily; + delete data.mysteryDrugHistory; + delete data.parentNames; + v10WriteTarinai(w, data, null, ctx); + } + } + + function v10ReadFrozen(r, liveCount = 0) { + const count = r.u(); + const out = []; + for (let index = 0; index < count; index++) { + const frozenAt = v9Unnum1(r.z()); + const worldTime = v9Unnum1(r.z()); + const data = v10ReadTarinai(r, liveCount + index); + data.id = `ft${index + 1}`; + data.familyKey = `ff${index + 1}`; + out.push({ freezeId: `fz${index + 1}`, frozenAt, worldTime, name: data.name || "tarinai", familyKey: data.familyKey || "", liveId: data.id || "", liveToken: data.liveToken || 0, type: data.type || "smile", data, pendingLosses: [] }); + } + return out; + } + + function createV10SnapshotBytes(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const frozen = Array.isArray(worldRef.frozenTarinai) ? worldRef.frozenTarinai : []; + const live = v9LiveLists(worldRef, frozen); + const itemInfo = v9ItemList(worldRef); + const ctx = { ...live, ...itemInfo }; + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0a); + v10WriteWorld(w, worldRef); + w.u(live.alive.length); + for (const t of live.alive) v10WriteTarinai(w, t, worldRef, ctx); + v10WriteItems(w, worldRef, ctx); + v10WriteAnts(w, worldRef, ctx); + v10WriteFrozen(w, frozen, ctx); + return w.finish(); + } + + function decodeV10SnapshotBytes(bytes) { + const r = new V10Reader(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || [])); + if (r.byte() !== 0x54 || r.byte() !== 0x0a) throw new Error("invalid V10 save packet"); + const world = v10ReadWorld(r); + const liveCount = r.u(); + const tarinai = []; + for (let i = 0; i < liveCount; i++) tarinai.push(v10ReadTarinai(r, i)); + const items = v10ReadItems(r); + const ants = v10ReadAnts(r); + world.frozenTarinai = v10ReadFrozen(r, liveCount); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { day: 1, time: "", fieldType: world.fieldType || "garden", population: tarinai.length, items: items.length }, + world, + tarinai, + items, + ants, + effects: [], + logs: [], + selectedRef: null, + }; + } + + // v11 hash format: current-only wrapper. Mode 0 is a semantic reset/delta + // packet for initial and modest early-game saves; mode 1 embeds a V10 packet + // so complex worlds still preserve the full compact simulation state. + const V11_SNAPSHOT_VERSION = 11; + const V11_SEMANTIC_ITEM_TYPES = [ + "sweet", "love_mochi", "fight_mochi", "water_bowl", "sleep_drug", "laxative", "protein", "niteropu", "ammo", + "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "zunchi", "genkotsu", "nest_box", + "ant_nest", "ball", "signboard", "pushpin", "oshibyo", "duplicator", "cannon", "fence_v", "fence_h", "water" + ]; + const V11_BASELINE_ITEM_TYPES = new Set(["grass", "stone", "bed", "trace", "splat", "ant_corpse"]); + const V11_HAZARD_ITEM_TYPES = new Set(["firecracker"]); + const V11_TIMER_TARINAI_KEYS = ["loveMochiTimer", "fightMochiTimer", "explosionDiseaseTimer", "sunbathTimer", "sunbathCooldown", "fallTimer", "fearTimer", "birthRitualTimer", "intimidateTimer", "intimidatedTimer"]; + const V11_COMPLEX_STATES = new Set(["fight", "panic", "birth_ritual", "intimidate", "hurt", "defeated", "stuck"]); + const V11_MAX_SEMANTIC_OBJECTS = 16; + const V11_MAX_SIGN_TEXT = 40; + + function v11ExpectedPopulation(fieldType) { + const field = (typeof FIELD_TYPES !== "undefined" ? FIELD_TYPES?.[fieldType || "garden"] : null) || null; + return field?.population ?? (typeof CONFIG !== "undefined" ? CONFIG.initialPopulation : 18); + } + + function v11SemanticTarinaiBlocked(t) { + if (!t || t.dead) return false; + if (V11_COMPLEX_STATES.has(t.state)) return true; + if (t.isZunchiSlave || t.zunchiSlaveLocked || t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) return true; + if (t.stuckPushpinId || t.insideNestBoxId || (t.oshiriByoZunchiStock || 0) > 0) return true; + if (Object.keys(t.relationships || {}).length) return true; + if (t.itemEffectTimers && Object.values(t.itemEffectTimers).some(v => (Number(v) || 0) > 0.2)) return true; + return V11_TIMER_TARINAI_KEYS.some(key => (Number(t[key]) || 0) > 0.2); + } + + function v11SemanticItemData(worldRef) { + const out = []; + for (const it of worldRef?.items || []) { + if (!it || it.dead || V11_BASELINE_ITEM_TYPES.has(it.type)) continue; + if (V11_HAZARD_ITEM_TYPES.has(it.type)) return null; + if (!V11_SEMANTIC_ITEM_TYPES.includes(it.type)) return null; + if ((it.fuseTimer || 0) > 0 || (it.dropTimer || 0) > 0 || it.cannonProjectile || it.projectileSourceId) return null; + const text = it.type === "signboard" ? String(it.text || "") : ""; + if (text.length > V11_MAX_SIGN_TEXT) return null; + out.push({ type: it.type, x: Number(it.x) || 0, y: Number(it.y) || 0, text }); + if (out.length > V11_MAX_SEMANTIC_OBJECTS) return null; + } + return out; + } + + function v11SemanticData(worldRef) { + if (!worldRef) return null; + const alive = (worldRef.tarinai || []).filter(t => t && !t.dead); + if ((worldRef.effects || []).some(ef => ef && !ef.dead)) return null; + if ((worldRef.ants || []).some(a => a && !a.dead)) return null; + if (alive.some(v11SemanticTarinaiBlocked)) return null; + const objects = v11SemanticItemData(worldRef); + if (!objects) return null; + const indexByFamily = new Map(); + alive.forEach((t, index) => indexByFamily.set(t.familyKey || t.id || String(index), index)); + const people = []; + for (const t of alive) { + const parents = []; + for (const ref of t.parents || []) { + if (!indexByFamily.has(ref)) return null; + parents.push(indexByFamily.get(ref)); + } + const name = t.name || "tarinai"; + people.push({ name, type: t.type || "smile", generation: Math.max(1, Math.round(t.generation || 1)), parents }); + } + const frozen = []; + for (const entry of worldRef.frozenTarinai || []) { + const data = entry?.data || entry || {}; + if (data.isZunchiSlave || data.zunchiSlaveLocked || data.zunchiDisease || data.sleepDisease || data.explosionDisease || data.fightDisease) return null; + const name = data.name || entry?.name || "tarinai"; + frozen.push({ name, type: data.type || entry?.type || "smile", generation: Math.max(1, Math.round(data.generation || 1)) }); + } + return { + fieldType: worldRef.fieldType || "garden", + deadCount: Math.max(0, Math.round(worldRef.deadCount || 0)), + people, + objects, + frozen, + }; + } + + function v11WriteSemantic(w, data) { + w.u((codeFromList(data.fieldType || "garden", V6_FIELD_TYPES) + 1) || 0); + w.u(data.deadCount || 0); + w.u(data.people.length); + for (const p of data.people) { + v10WriteName(w, p.name); + w.u((codeFromList(p.type || "smile", V4_SPRITE_TYPES) + 1) || 0); + w.u(Math.max(1, p.generation || 1)); + w.u(p.parents.length); + for (const parent of p.parents) w.u(parent + 1); + } + w.u(data.objects.length); + for (const obj of data.objects) { + w.u((codeFromList(obj.type, V4_ITEM_TYPES) + 1) || 0); + w.u(v9Coord(obj.x)); + w.u(v9Coord(obj.y)); + if (obj.type === "signboard") w.str(obj.text || ""); + } + w.u(data.frozen.length); + for (const f of data.frozen) { + v10WriteName(w, f.name); + w.u((codeFromList(f.type || "smile", V4_SPRITE_TYPES) + 1) || 0); + w.u(Math.max(1, f.generation || 1)); + } + } + + function v11ReadSemantic(r) { + const fieldType = valueFromList(r.u() - 1, V6_FIELD_TYPES) || "garden"; + const deadCount = r.u(); + const people = []; + const peopleCount = r.u(); + for (let i = 0; i < peopleCount; i++) { + const name = v10ReadName(r); + const type = valueFromList(r.u() - 1, V4_SPRITE_TYPES) || "smile"; + const generation = Math.max(1, r.u() || 1); + const parents = []; + const parentCount = r.u(); + for (let p = 0; p < parentCount; p++) parents.push(r.u() - 1); + people.push({ name, type, generation, parents }); + } + const objects = []; + const objectCount = r.u(); + for (let i = 0; i < objectCount; i++) { + const type = valueFromList(r.u() - 1, V4_ITEM_TYPES) || ""; + const x = v9Uncoord(r.u()); + const y = v9Uncoord(r.u()); + const text = type === "signboard" ? r.str() : ""; + if (type) objects.push({ type, x, y, text }); + } + const frozen = []; + const frozenCount = r.u(); + for (let i = 0; i < frozenCount; i++) { + frozen.push({ + name: v10ReadName(r), + type: valueFromList(r.u() - 1, V4_SPRITE_TYPES) || "smile", + generation: Math.max(1, r.u() || 1), + }); + } + return { __tarinaiV11Semantic: true, version: V11_SNAPSHOT_VERSION, fieldType, deadCount, people, objects, frozen }; + } + + function createV11SnapshotBytes(worldRef = global.world) { + const full = createV10SnapshotBytes(worldRef); + const semantic = v11SemanticData(worldRef); + if (semantic) { + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0b); + w.u(0); + v11WriteSemantic(w, semantic); + const semanticBytes = w.finish(); + if (semanticBytes.length < full.length) return semanticBytes; + } + const w = new V10Writer(); + w.byte(0x54); + w.byte(0x0b); + w.u(1); + w.u(full.length); + w.raw(full); + return w.finish(); + } + + function decodeV11SnapshotBytes(bytes) { + const r = new V10Reader(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || [])); + if (r.byte() !== 0x54 || r.byte() !== 0x0b) throw new Error("invalid V11 save packet"); + const mode = r.u(); + if (mode === 0) return v11ReadSemantic(r); + if (mode === 1) { + const len = r.u(); + return decodeV10SnapshotBytes(r.bytes.slice(r.i, r.i + len)); + } + throw new Error("unsupported V11 save mode"); + } + + function restoreV11SemanticSnapshot(snapshot, worldRef = global.world) { + if (!snapshot?.__tarinaiV11Semantic) return false; + const fieldType = snapshot.fieldType || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(null, fieldType); + const people = Array.isArray(snapshot.people) ? snapshot.people : []; + const baseline = (worldRef.tarinai || []).filter(t => t && !t.dead); + while (baseline.length > people.length) { + const t = baseline.pop(); + if (typeof worldRef.releaseTarinaiLiveId === "function") worldRef.releaseTarinaiLiveId(t); + const index = worldRef.tarinai.indexOf(t); + if (index >= 0) worldRef.tarinai.splice(index, 1); + } + while (baseline.length < people.length) { + const p = people[baseline.length] || {}; + baseline.push(worldRef.addTarinai({ + name: p.name || "tarinai", + type: p.type || "smile", + generation: Math.max(1, p.generation || 1), + x: rand(80, worldRef.w - 80), + y: rand(80, worldRef.h - 80), + })); + } + for (let i = 0; i < Math.min(people.length, baseline.length); i++) { + const p = people[i] || {}; + const t = baseline[i]; + t.name = p.name || t.name; + t.type = p.type || t.type || "smile"; + t.generation = Math.max(1, p.generation || 1); + t.parents = []; + t.children = []; + t.relationships = {}; + t.hasPaired = false; + t.dead = false; + } + for (let i = 0; i < Math.min(people.length, baseline.length); i++) { + const t = baseline[i]; + const refs = []; + for (const parentIndex of people[i]?.parents || []) { + const parent = baseline[parentIndex]; + if (!parent) continue; + refs.push(parent.familyKey); + parent.children = Array.from(new Set([...(parent.children || []), t.familyKey])); + } + t.parents = refs; + if (refs.length) t.hasPaired = true; + } + worldRef.deadCount = Math.max(0, Math.round(snapshot.deadCount || 0)); + worldRef.items = (worldRef.items || []).filter(it => it && !it.dead); + for (const obj of snapshot.objects || []) { + if (!obj?.type) continue; + const item = new Item(obj.type, Number(obj.x) || 0, Number(obj.y) || 0); + if (obj.type === "signboard") item.text = String(obj.text || ""); + worldRef.items.push(item); + } + worldRef.frozenTarinai = (snapshot.frozen || []).map((f, index) => { + const data = { + id: `ft${index + 1}`, + familyKey: `ff${index + 1}`, + liveToken: baseline.length + index + 1, + name: f.name || "tarinai", + type: f.type || "smile", + generation: Math.max(1, f.generation || 1), + parents: [], + children: [], + relationships: {}, + itemEffectTimers: { laxative: 0, ammo: 0 }, + }; + return { freezeId: `fz${index + 1}`, frozenAt: 0, worldTime: 0, name: data.name, familyKey: data.familyKey, liveId: data.id, liveToken: data.liveToken, type: data.type, data, pendingLosses: [] }; + }); + worldRef.logs = []; + worldRef.selected = null; + worldRef.family = {}; + for (const t of baseline) if ((t.parents || []).length || (t.children || []).length) worldRef.recordFamily?.(t); + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); + if (typeof renderLog === "function") renderLog([]); + if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + function createV9SnapshotBytes(worldRef = global.world) { + const writer = new V9BinaryWriter(); + writer.byte(0x54); // T + writer.byte(0x09); + writer.value(createV9SnapshotArray(worldRef)); + return writer.finish(); + } + + function decodeV9SnapshotBytes(bytes) { + const reader = new V9BinaryReader(bytes); + if (reader.byte() !== 0x54 || reader.byte() !== 0x09) throw new Error("invalid V9 save packet"); + const snapshot = reader.value(); + if (!Array.isArray(snapshot) || snapshot[0] !== V9_SNAPSHOT_VERSION) throw new Error("invalid V9 save packet"); + return expandV9SnapshotArray(snapshot); + } + + function createCompactSnapshot(worldRef = global.world) { + return { __tarinaiCompactBytes: true, version: V11_SNAPSHOT_VERSION, bytes: createV11SnapshotBytes(worldRef) }; + } + + function expandCompactSnapshot(snapshot) { + if (snapshot && snapshot.__tarinaiCompactBytes && snapshot.bytes) return decodeV11SnapshotBytes(snapshot.bytes); + if (snapshot instanceof Uint8Array) return decodeV11SnapshotBytes(snapshot); + return snapshot; + } + + function createSnapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const selectedRef = refFor(worldRef, worldRef.selected); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { + day: worldRef.day || 1, + time: worldRef.clockString ? worldRef.clockString() : "", + fieldType: worldRef.fieldType || "garden", + population: (worldRef.tarinai || []).filter(t => t && !t.dead).length, + items: (worldRef.items || []).filter(it => it && !it.dead).length, + }, + world: worldData(worldRef), + tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)), + items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)), + ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)), + effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)), + logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [], + selectedRef, + }; + } + + function restoreTarinaiData(worldRef, data, opts = {}) { + const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); + if (!TarinaiClass) throw new Error("Tarinai is not available"); + const t = new TarinaiClass(worldRef, data || {}); + applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"])); + t.world = worldRef; + if (opts.clearTransient !== false) { + t.target = null; + t.panicTarget = null; + t.sleeping = false; + t.insideNestBoxId = ""; + } + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + return t; + } + + function restoreSnapshot(snapshot, worldRef = global.world) { + if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data"); + if (!worldRef) throw new Error("world is not ready"); + if (snapshot && snapshot.__tarinaiCompactBytes && snapshot.bytes) return restoreSnapshot(expandCompactSnapshot(snapshot), worldRef); + if (snapshot instanceof Uint8Array) return restoreSnapshot(expandCompactSnapshot(snapshot), worldRef); + if (snapshot.__tarinaiV11Semantic) return restoreV11SemanticSnapshot(snapshot, worldRef); + + const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot)); + const fieldType = data.world?.fieldType || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType); + + worldRef.tarinai = []; + worldRef.items = []; + worldRef.ants = []; + worldRef.effects = []; + worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : []; + worldRef.selected = null; + + applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"])); + worldRef.events = global.TarinaiEvents || null; + worldRef.liveTarinai = new Map(); + worldRef.liveIdFree = []; + + const items = Array.isArray(data.items) ? data.items : []; + for (const saved of items) { + if (!saved || !saved.type) continue; + const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0); + applyOwnData(item, saved, new Set(["target", "targetRef", "world"])); + if (item.isStructure && global.TarinaiNeedSystem?.hydrateStructure) global.TarinaiNeedSystem.hydrateStructure(item); + worldRef.items.push(item); + } + + const tarinaiList = Array.isArray(data.tarinai) ? data.tarinai : []; + for (const saved of tarinaiList) { + if (!saved) continue; + const t = restoreTarinaiData(worldRef, saved, { clearTransient: false }); + if (!t.id && typeof worldRef.assignTarinaiLiveId === "function") worldRef.assignTarinaiLiveId(t); + worldRef.tarinai.push(t); + if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t }); + } + + const ants = Array.isArray(data.ants) ? data.ants : []; + for (const saved of ants) { + if (!saved) continue; + const ant = new AntActor(worldRef, saved); + applyOwnData(ant, saved, new Set(["world", "targetRef"])); + ant.world = worldRef; + worldRef.ants.push(ant); + } + + const effects = Array.isArray(data.effects) ? data.effects : []; + for (const saved of effects) { + if (!saved || !saved.type) continue; + const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved); + applyOwnData(ef, saved, new Set(["world", "target", "targetRef"])); + worldRef.effects.push(ef); + } + + const itemById = new Map(worldRef.items.map(it => [it.id, it])); + const antById = new Map(worldRef.ants.map(a => [a.id, a])); + const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t])); + const resolve = (ref) => { + if (!ref) return null; + if (ref.kind === "item") return itemById.get(ref.id) || null; + if (ref.kind === "ant") return antById.get(ref.id) || null; + if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null; + return resolveRef(worldRef, ref); + }; + for (const [i, saved] of tarinaiList.entries()) { + const t = worldRef.tarinai[i]; + if (!t) continue; + t.target = resolve(saved.__targetRef); + t.panicTarget = resolve(saved.__panicTargetRef); + } + for (const [i, saved] of ants.entries()) { + const ant = worldRef.ants[i]; + if (!ant) continue; + ant.targetRef = resolve(saved.__targetRefObject); + } + + worldRef.selected = resolve(data.selectedRef); + worldRef.family = clonePlain(data.world?.family) || worldRef.family || {}; + worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {}; + worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {}; + worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {}; + const savedMood = data.world?.colonyMood; + worldRef.colonyMood = savedMood?.id && typeof worldRef.colonyMoodDefinition === "function" ? worldRef.colonyMoodDefinition(savedMood.id) : (clonePlain(savedMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "relaxed", effects: { personality: {} } }); + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); + if (typeof worldRef.clampCamera === "function") worldRef.clampCamera(); + if (typeof renderLog === "function") renderLog(worldRef.logs || []); + if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + global.TarinaiSnapshot = { + version: SNAPSHOT_VERSION, + clonePlain, + copyOwnData, + applyOwnData, + refFor, + resolveRef, + worldData, + snapshotEntity, + createSnapshot, + createCompactSnapshot, + expandCompactSnapshot, + createV4Snapshot, + expandV4Snapshot, + createV5Snapshot, + expandV5Snapshot, + createV6Snapshot, + expandV6Snapshot, + createV7Snapshot, + expandV7Snapshot, + createV8Snapshot, + expandV8Snapshot, + createV10SnapshotBytes, + decodeV10SnapshotBytes, + createV11SnapshotBytes, + decodeV11SnapshotBytes, + createV9SnapshotBytes, + decodeV9SnapshotBytes, + createV9SnapshotArray, + expandV9SnapshotArray, + restoreSnapshot, + restoreTarinaiData, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sound_pack.js b/js/sound_pack.js new file mode 100644 index 0000000..b4ae83a --- /dev/null +++ b/js/sound_pack.js @@ -0,0 +1,61 @@ +"use strict"; + +(function (global) { + // External audio replacement point. Add files and edit paths here; audio.js consumes this pack at startup. + global.TARINAI_SOUND_PACK = Object.freeze({ + version: "1.1", + categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, + idGain: { + sfx_notify: 2.60, + sfx_log_push: 2.60, + sfx_ui_click: 2.35, + sfx_ui_fold: 2.20, + sfx_place: 2.10, + sfx_delete: 2.25, + sfx_poke: 1.85, + sfx_grab: 2.15, + sfx_drop: 2.10, + sfx_water_hose: 1.85, + sfx_genkotsu_fall: 1.70, + sfx_genkotsu_impact: 1.90, + sfx_ground_rumble: 1.75, + sfx_firecracker_fuse: 1.55, + sfx_firecracker_explode: 1.70, + }, + idMinGap: { + sfx_ui_click: 0.030, + sfx_place: 0.055, + sfx_grass: 0.12, + sfx_zunchi: 0.14, + sfx_ant_die: 0.14, + sfx_notify: 0.10, + sfx_log_push: 0.10, + }, + samplePaths: { + voice_hau: "assets/sounds/voice_001_hau.mp3", + voice_flee: "assets/sounds/voice_002_flee.mp3", + voice_poop: "assets/sounds/voice_003_po.mp3", + sfx_eat: "assets/sounds/voice_004_eat.mp3", + voice_stress: "assets/sounds/voice_005_stress.mp3", + sfx_sleep: "assets/sounds/voice_006_sleep.mp3", + sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3" + }, + categories: { + voice: "\u305f\u308a\u306a\u3044\u306e\u58f0", + notify: "\u901a\u77e5", + ops: "\u64cd\u4f5c" + }, + soundMap: { + voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice", + sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice", + sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice", + sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice", + sfx_notify: "notify", sfx_phase: "notify", sfx_log_push: "notify", + sfx_ui_click: "ops", sfx_ui_fold: "ops", sfx_place: "ops", sfx_delete: "ops", sfx_poke: "ops", + sfx_grab: "ops", sfx_drop: "ops", sfx_water_hose: "ops", sfx_water_clean: "ops", sfx_drink: "ops", + sfx_ball_hit: "ops", sfx_stone_impact: "ops", sfx_firecracker_fuse: "ops", sfx_firecracker_explode: "ops", + sfx_genkotsu_fall: "ops", sfx_genkotsu_impact: "ops", sfx_ground_rumble: "ops", sfx_grass: "ops", + sfx_zunchi: "ops", sfx_ant_nest: "ops" + } + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index 65532f1..c2241ab 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -14,8 +14,7 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) { ctx.quadraticCurveTo(x, y, x + rr, y); } -class Tarinai { - constructor(world, opts = {}) { +class Tarinai { constructor(world, opts = {}) { this.world = world; this.familyKey = opts.familyKey || opts.archiveKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); this.id = opts.id || ""; @@ -31,19 +30,25 @@ class Tarinai { this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality); this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null; ensurePersonality(this); + this.genetics = normalizeGenetics(opts.genetics, this.familyKey); this.trait = makeTrait(this.type); + this.trait.speed *= this.genetics.speedMul || 1; + this.birthTime = opts.birthTime ?? this.world.time; + const generationOpt = opts.generation ?? 1; + const growthAtConstruct = generationOpt > 1 ? clamp(((this.world?.time || 0) - this.birthTime) / Math.max(1, CONFIG.childGrowTime || 120), 0, 1) : 1; this.x = opts.x ?? rand(100, world.w - 100); this.y = opts.y ?? rand(100, world.h - 100); this.vx = opts.vx ?? rand(-12, 12); this.vy = opts.vy ?? rand(-12, 12); this.impulseVx = opts.impulseVx ?? 0; this.impulseVy = opts.impulseVy ?? 0; - this.scale = opts.scale ?? rand(0.22, 0.33); + this.adultScale = Number.isFinite(opts.adultScale) ? opts.adultScale : (Number.isFinite(opts.baseAdultScale) ? opts.baseAdultScale : (Number.isFinite(opts.scale) && growthAtConstruct >= 1 ? opts.scale : adultScaleFromGenetics(this.familyKey, this.genetics))); + this.scale = Number.isFinite(opts.scale) ? opts.scale : (generationOpt > 1 ? scaleForGrowth(this.adultScale, growthAtConstruct) : this.adultScale); this.radius = 56 * this.scale; this.age = opts.age ?? rand(0, 20); - this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200); - if (this.lifeSpan < 1200) this.lifeSpan = rand(1400, 2200); - this.generation = opts.generation ?? 1; + this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200) * (this.genetics.lifeSpanMul || 1); + if (this.lifeSpan < 900) this.lifeSpan = rand(1200, 1800) * (this.genetics.lifeSpanMul || 1); + this.generation = generationOpt; this.hasPaired = opts.hasPaired ?? false; this.hunger = opts.hunger ?? rand(16, 45); this.loneliness = opts.loneliness ?? rand(18, 62); @@ -52,7 +57,23 @@ class Tarinai { this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); - this.need = opts.need || pick(NEEDS); + const defaultNeeds = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + this.needs = opts.needs && typeof opts.needs === "object" ? { ...defaultNeeds, ...opts.needs } : { ...defaultNeeds }; + this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...this.needs, ...opts.previousNeeds } : { ...this.needs }; + this.needsInitialized = Boolean(opts.needsInitialized); + this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...defaultNeeds, ...opts.needShock } : { ...defaultNeeds }; + this.need = opts.need || opts.intent?.need || "fulfill"; + this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : { + need: this.need, + tiedNeeds: [this.need], + actionId: "idle", + actionLabel: "\u5f85\u3063\u3066\u3044\u308b", + reasonText: "\u5f85\u3063\u3066\u3044\u308b", + }; + this.panicTimer = opts.panicTimer ?? 0; + this.buildTimer = opts.buildTimer ?? 0; + this.buildType = opts.buildType || ""; + this.buildMaterialId = opts.buildMaterialId || ""; this.state = opts.state || "idle"; this.dead = !!opts.dead; this.deathReason = opts.deathReason || ""; @@ -76,6 +97,8 @@ class Tarinai { this.reproductionTimer = rand(4, CONFIG.reproductionCooldown); this.lastLog = 0; this.sleeping = false; + this.awakeLockTimer = opts.awakeLockTimer ?? 0; + this.wasSleepingLastTick = false; this.blink = rand(0, 10); this.lastSocial = 0; this.goodMode = opts.goodMode ?? stableChoice(this.familyKey, "good-mode", displayNormalSprites()) ?? "smile"; @@ -87,6 +110,10 @@ class Tarinai { this.foodReactTimer = opts.foodReactTimer ?? 0; this.surpriseTimer = opts.surpriseTimer ?? 0; this.fearTimer = opts.fearTimer ?? 0; + this.sunbathTimer = opts.sunbathTimer ?? 0; + this.sunbathCooldown = opts.sunbathCooldown ?? 0; + this.sunbathFrameTimer = opts.sunbathFrameTimer ?? 0; + this.sunbathSpriteVariant = opts.sunbathSpriteVariant ?? 1; this.intimidateTimer = opts.intimidateTimer ?? 0; this.intimidateTargetId = opts.intimidateTargetId || null; this.intimidatedTimer = opts.intimidatedTimer ?? 0; @@ -103,14 +130,15 @@ class Tarinai { this.zunchiDiseaseCooldown = opts.zunchiDiseaseCooldown ?? 0; this.totalFightLosses = opts.totalFightLosses || 0; this.totalFightWins = opts.totalFightWins || 0; - this.isZunchiSlave = Boolean(opts.isZunchiSlave); + this.isZunchiSlave = Boolean(opts.isZunchiSlave || opts.zunchiSlaveLocked); + this.zunchiSlaveLocked = Boolean(opts.zunchiSlaveLocked || this.isZunchiSlave); this.formerName = opts.formerName || null; this.loveMochiTimer = opts.loveMochiTimer ?? 0; this.fightMochiTimer = opts.fightMochiTimer ?? 0; this.sleepDisease = Boolean(opts.sleepDisease); this.sleepDiseaseCooldown = opts.sleepDiseaseCooldown ?? 0; this.explosionDisease = Boolean(opts.explosionDisease); - this.explosionDiseaseTimer = opts.explosionDiseaseTimer ?? (this.explosionDisease ? (CONFIG.dayLength || 120) * 0.5 : 0); + this.explosionDiseaseTimer = opts.explosionDiseaseTimer ?? (this.explosionDisease ? 60 : 0); this.fightDisease = Boolean(opts.fightDisease); this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0; this.lastBleedAt = opts.lastBleedAt ?? -999; @@ -120,13 +148,17 @@ class Tarinai { this.nestBoxEnteredAt = opts.nestBoxEnteredAt ?? (this.insideNestBoxId ? (world?.time || 0) : -Infinity); this.nestBoxStayUntil = opts.nestBoxStayUntil ?? (this.insideNestBoxId ? ((world?.time || 0) + 10) : -Infinity); this.focusPulseTimer = opts.focusPulseTimer ?? 0; - if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; + if (this.isZunchiSlave) { + this.zunchiSlaveLocked = true; + this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; + applyZunchiSlavePersonality(this, { record: false, birth: false }); + this.visibleSpriteId = "zunchi_slave"; + } this.tempComfort = opts.tempComfort ?? 0.62; this.tempTimer = opts.tempTimer ?? stableUnit(this.familyKey, "temp-start") * 0.55; this.parents = opts.parents || []; this.parentNames = opts.parentNames || []; this.children = opts.children || []; - this.birthTime = opts.birthTime ?? this.world.time; this.nextEatSound = opts.nextEatSound ?? 0; this.fightTimer = opts.fightTimer ?? 0; this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown); @@ -135,10 +167,12 @@ class Tarinai { this.nextHeadbutt = opts.nextHeadbutt ?? 0; this.digest = opts.digest ?? rand(0, 2.4); this.poopCount = opts.poopCount ?? 0; + this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0; this.stretchTimer = opts.stretchTimer ?? 0; this.lastMealColor = opts.lastMealColor || "#f7cf67"; this.grassEatTimer = opts.grassEatTimer ?? 0; this.hurtTimer = opts.hurtTimer ?? 0; + this.stuckPushpinId = opts.stuckPushpinId || null; this.defeatedTimer = opts.defeatedTimer ?? 0; this.defeatedById = opts.defeatedById || null; this.fightWinnerId = opts.fightWinnerId || null; @@ -160,2381 +194,29 @@ class Tarinai { this.aiTimer = opts.aiTimer ?? stableUnit(this.familyKey, "ai-start") * 0.22; this.envTimer = opts.envTimer ?? stableUnit(this.familyKey, "env-start") * 0.55; this.visibleSpriteId = opts.visibleSpriteId || null; + this.zunchiSlaveSpriteVariant = opts.zunchiSlaveSpriteVariant || (opts.isZunchiSlave ? (Math.random() < 0.5 ? "zunchi_slave" : "zunchi_slave_alt") : ""); this.spriteLockUntil = opts.spriteLockUntil ?? 0; this.entryTimer = opts.entryTimer ?? 0; - } - - get lack() { - return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100); - } - - get mood() { - return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100); - } - - get ageSinceBirth() { - return Math.max(0, this.world.time - this.birthTime); - } - - get growth() { - return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1); - } - - get juvenile() { - return this.generation > 1 && this.growth < 1; - } - - parentToFollow() { - if (!this.juvenile || !this.parents?.length) return null; - let best = null, bestD = Infinity; - for (const id of this.parents) { - const p = this.world.tarinai.find(t => (t.familyKey || t.id) === id && !t.dead); - if (!p) continue; - const d = dist(this, p); - if (d < bestD) { best = p; bestD = d; } - } - return best; - } - - label() { - const sp = SPRITES.find(s => s.id === this.type); - return sp ? sp.label : this.type; - } - - - addRecord(text, kind = "note", options = {}) { - if (!text) return; - if (options.hiddenFromObservation) return; - if (!Array.isArray(this.records)) this.records = []; - const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" }; - const head = this.records[0]; - if (head && head.text === entry.text && Math.abs((head.time || 0) - entry.time) < 0.05) return; - this.records.unshift(entry); - if (this.records.length > 42) this.records.length = 42; - } - - personalityProfile() { - const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; - const p = ensurePersonality(this).currentPersonality; - const e = { - neuroticism: personalityEffectValue(p.neuroticism), - aggression: personalityEffectValue(p.aggression), - sociability: personalityEffectValue(p.sociability), - openness: personalityEffectValue(p.openness), + const effectTimers = opts.itemEffectTimers && typeof opts.itemEffectTimers === "object" ? opts.itemEffectTimers : {}; + this.itemEffectTimers = { + laxative: Math.max(0, Number(effectTimers.laxative || opts.laxativeTimer || 0) || 0), + ammo: Math.max(0, Number(effectTimers.ammo || opts.ammoTimer || 0) || 0), }; - return { - ...base, - fear: clamp((base.fear || 1) * (1 + e.neuroticism * 0.38), 0.55, 1.85), - fight: clamp((base.fight || 1) * (1 + e.aggression * 0.55), 0.35, 2.35), - social: clamp((base.social || 1) * (1 + e.sociability * 0.24), 0.62, 1.52), - relation: clamp((base.relation || 1) * (1 + e.sociability * 0.24), 0.62, 1.60), - play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10), - }; - } - - personalityLabel() { - const tags = getPersonalityTraitTags(this); - return tags.length ? tags.join(" / ") : "\u4e2d\u7acb"; - } - - personalityTags() { return getPersonalityTraitTags(this); } - adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); } - shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); } - - fightWinCount() { - return this.totalFightWins || 0; - } - - fightLossCount() { - return this.totalFightLosses || 0; - } - - maybeBecomeTimidAfterDamage(cause = "") { - if (this.dead) return false; - const losses = this.totalFightLosses || 0; - const wins = this.totalFightWins || 0; - if (losses <= wins) return false; - if (Math.random() >= 0.05) return false; - const changedA = this.adjustPersonality?.("neuroticism", 0.025, "after being hurt") || 0; - const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; - this.fearTimer = Math.max(this.fearTimer || 0, 1.2); - this.stress = clamp((this.stress || 0) + 5, 0, 130); - return Boolean(changedA || changedB); - } - - relationTo(id) { - if (!id) return relationDefaults(); - if (!this.relationships) this.relationships = {}; - if (!this.relationships[id]) this.relationships[id] = relationDefaults(); - return this.relationships[id]; - } - - adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { - if (!other || !other.id || other === this) return; - const rel = this.relationTo(other.id); - rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); - rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); - rel.fightsWon = rel.fightsWon || 0; - rel.fightsLost = rel.fightsLost || 0; - if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } - } - - strongestRelation(kind = "friend", liveOnly = true) { - let bestId = null; - let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; - const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null; - for (const [id, rel] of Object.entries(this.relationships || {})) { - if (liveIds && !liveIds.has(id)) continue; - const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); - if (score > bestScore) { bestId = id; bestScore = score; } - } - return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null; - } - - relationSummary() { - const friend = this.strongestRelation("friend"); - const fear = this.strongestRelation("fear"); - return { friend, fear }; - } - - currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) { - let count = 0; - const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null; - for (const [id, rel] of Object.entries(this.relationships || {})) { - if ((rel.affinity || 0) <= minScore) continue; - if (liveIds && !liveIds.has(id)) continue; - count += 1; - } - return count; - } - - sociabilityFriendScale() { - const friends = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; - return clamp(0.82 + Math.min(6, friends) * 0.16, 0.82, 1.78); - } - - bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) { - let best = null, bestScore = minScore; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const score = rel.affinity || 0; - if (score <= bestScore) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; - best = t; - bestScore = score; - } - return best; - } - - recentFightRival(maxAge = 52, maxDist = Infinity) { - let best = null, bestT = -Infinity; - for (const [id, rel] of Object.entries(this.relationships || {})) { - const event = String(rel.lastEvent || ""); - const when = rel.lastTime || -Infinity; - if (this.world.time - when > maxAge) continue; - if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; - if (when > bestT) { best = t; bestT = when; } - } - return best; - } - - poke() { - audio.poke(); - this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e"); - this.addStress(22, { threshold: 8 }); - this.surpriseTimer = 0.55; - this.fearTimer = Math.max(this.fearTimer, 1.2); - this.pokeFlashTimer = 0.55; - this.hurtTimer = Math.max(this.hurtTimer, 2.4); - this.vx += rand(-100, 100); - this.vy += rand(-100, 100); - if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e"); - } - - - showHpBar(duration = 4.8) { - this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration); - } - - showStressBar(duration = 4.2) { - this.stressBarTimer = Math.max(this.stressBarTimer || 0, duration); - } - - addStress(amount, opts = {}) { - const delta = Math.max(0, amount || 0); - if (delta <= 0) return 0; - const before = this.stress || 0; - this.stress = clamp(before + delta, 0, 130); - const gained = Math.max(0, this.stress - before); - const threshold = opts.threshold ?? 8; - if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2); - return gained; - } - - damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); } - recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); } - rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); } - weakenedDeathReasonFor(cause = "") { return HEALTH.weakenedDeathReasonFor(cause); } - dominantDeathCause(reason = "", opts = {}) { return HEALTH.dominantDeathCause(this, reason, opts); } - normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); } - damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); } - - wakeFromDamage(reason = "") { - if (this.dead) return false; - const wasSleeping = this.state === "sleep" || this.sleeping || this.insideNestBoxId; - if (!wasSleeping) return false; - const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; - this.sleeping = false; - this.state = "panic"; - this.target = null; - this.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f"; - this.fearTimer = Math.max(this.fearTimer || 0, 0.85); - this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); - this.nestBoxStayUntil = -Infinity; - if (this.insideNestBoxId) this.leaveNestBox(box); - this.vx += rand(-34, 34); - this.vy += rand(-26, 8); - return true; - } - - recoverHealth(amount) { - if (this.dead) return; - this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); - } - - familyJoined() { - return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length); - } - - nearestCoParent(maxRange = 220) { - let best = null; - let bestD = maxRange; - for (const o of this.world.tarinai || []) { - if (!o || o === this || o.dead) continue; - if (!(this.world.areCoParents?.(this, o))) continue; - const d = dist(this, o); - if (d < bestD) { - best = o; - bestD = d; - } - } - return best; - } - - displayGeneration() { - return this.familyJoined() ? this.generation : null; - } - - targetIdentity(target = this.target) { - if (!target) return ""; - if (target.id) return `${target.type || "tarinai"}:${target.id}`; - if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`; - return ""; - } - - targetIgnoresFence(target = this.target) { - return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); - } - - shouldAvoidTarget(target) { - if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false; - if (this.targetIgnoresFence(target)) return false; - const key = this.targetIdentity(target); - if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true; - return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false; - } - - maintainTargetProgress(dt) { - const target = this.target; - if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { - this.targetKey = ""; - this.targetSince = this.world.time; - this.targetBestDist = Infinity; - return; - } - if (this.targetIgnoresFence(target)) return; - const key = this.targetIdentity(target); - const d = distXY(this.x, this.y, target.x, target.y); - if (key !== this.targetKey) { - this.targetKey = key; - this.targetSince = this.world.time; - this.targetBestDist = d; - } else if (d < (this.targetBestDist || Infinity) - 8) { - this.targetBestDist = d; - this.targetSince = this.world.time; - } - if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) { - this.targetGiveupKey = key; - this.targetGiveupUntil = this.world.time + 8; - this.target = null; - this.state = "idle"; - this.wanderAngle += rand(-1.4, 1.4); - return; - } - if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) { - this.targetGiveupKey = key; - this.targetGiveupUntil = this.world.time + 12; - this.target = null; - this.state = "idle"; - this.wanderAngle += rand(-2.2, 2.2); - this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f"; - } - } - - panicDestination(threat = null, force = false) { - const now = this.world.time || 0; - const current = this.panicTarget; - if (!force && current && now < (this.panicTargetUntil || 0) && distXY(this.x, this.y, current.x, current.y) > 34) return current; - let ax = Math.cos(this.wanderAngle || 0); - let ay = Math.sin(this.wanderAngle || 0); - if (threat && Number.isFinite(threat.x) && Number.isFinite(threat.y)) { - const dx = this.x - threat.x; - const dy = this.y - threat.y; - const d = Math.hypot(dx, dy) || 1; - ax = dx / d; - ay = dy / d; - } - const turn = rand(-0.75, 0.75); - const cos = Math.cos(turn); - const sin = Math.sin(turn); - const dirX = ax * cos - ay * sin; - const dirY = ax * sin + ay * cos; - const distance = rand(150, 250); - const p = CONFIG.worldPadding + this.radius; - let x = clamp(this.x + dirX * distance, p, this.world.w - p); - let y = clamp(this.y + dirY * distance, p, this.world.h - p); - if (distXY(this.x, this.y, x, y) < 70) { - x = clamp(this.x - dirY * distance, p, this.world.w - p); - y = clamp(this.y + dirX * distance, p, this.world.h - p); - } - this.panicTarget = { x, y, dead: false, detour: true, panic: true }; - this.panicTargetUntil = now + rand(1.6, 3.0); - this.panicStuckTimer = 0; - this.wanderAngle = Math.atan2(y - this.y, x - this.x); - return this.panicTarget; - } - - lowHealthSprite() { - // This is physical weakness only. Stress is handled by the stress sprite branch - // below so high-stress individuals do not incorrectly render as low-health. - const critical = this.hunger > 112 || this.energy < 7; - if (critical) return this.stretchTimer > 0 ? "stretch" : "weak"; - const weak = this.hunger > 102 || this.energy < 18; - if (weak) return "weak"; - return null; - } - - briefDeathReason(reason = this.deathReason) { return HEALTH.briefDeathReason(this, reason); } - - variantSprite(category) { - const pools = { - hurt: ["hurt", "hurt2"], - fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"], - flee: ["flee", "flee_fear2"], - sleep: ["sleep", "sleep2"], - stress: ["stress_dizzy", "stress_sweat"], - low_stress: lowStressSprites(), - intimidate: ["intimidate"], - normal: displayNormalSprites(), - }; - const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id)); - return stableChoice(this.familyKey || this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; - } - - infectZunchiDisease(source = null, force = false) { - if (this.dead || this.zunchiDisease) return false; - if (!force && (this.zunchiDiseaseCooldown || 0) > 0) return false; - this.zunchiDisease = true; - this.zunchiDiseaseSeverity = Math.max(this.zunchiDiseaseSeverity || 0, source ? 0.72 : 0.86); - this.zunchiDiseaseCooldown = 12; - this.fearTimer = Math.max(this.fearTimer || 0, 0.35); - this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.45); - this.thought = "\u305a\u3093\u3061\u75c5\u306b\u304b\u304b\u3063\u305f"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "\u3046\u3045\u2026", "rgba(38,82,38,0.82)"); - return true; - } - - recoverZunchiDisease(reason = "") { - if (!this.zunchiDisease) return false; - this.zunchiDisease = false; - this.zunchiDiseaseSeverity = 0; - this.zunchiDiseaseCooldown = 18; - this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100); - this.stress = clamp(this.stress - 8, 0, 130); - this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)"); - return true; - } - - emitZunchiDiseaseEffect(dt) { - if (!this.zunchiDisease || !this.world?.effects) return; - if (Math.random() < dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4)) { - this.world.effects.push(new Effect("zunchi_miasma", this.x + rand(-this.radius * 0.45, this.radius * 0.45), this.y - this.radius * rand(0.05, 0.72), { - vx: rand(-5, 5), - vy: rand(-10, -3), - life: rand(1.4, 2.4), - size: rand(8, 18) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45), - color: "rgba(16,78,28,0.70)", - })); - } - } - - updateZunchiDisease(dt) { - this.zunchiStain = clamp(this.zunchiStain || 0, 0, 100); - this.zunchiDiseaseCooldown = Math.max(0, (this.zunchiDiseaseCooldown || 0) - dt); - if (this.world?.weather === "light_rain") { - this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100); - if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006); - } - if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) { - this.infectZunchiDisease(null, true); - this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); - } - if (this.zunchiDisease) { - this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); - this.stress = clamp(this.stress + dt * (0.28 + 0.18 * this.zunchiDiseaseSeverity), 0, 130); - this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); - if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); - this.emitZunchiDiseaseEffect(dt); - if (this.zunchiDiseaseSeverity <= 0.04) { - if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); - } - } - } - - hasWaterCurableDisease() { - return Boolean(this.zunchiDisease || this.explosionDisease); - } - - hasZundaCurableDisease() { - return Boolean(this.explosionDisease || this.fightDisease); - } - - infectSleepDisease(source = null) { - if (this.dead || this.sleepDisease) return false; - this.sleepDisease = true; - this.sleepDiseaseCooldown = 8; - this.sleepDiseaseAnchorX = this.x; - this.sleepDiseaseAnchorY = this.y; - this.state = "sleep"; - this.target = null; - this.sleeping = true; - this.fightTimer = 0; - this.intimidateTimer = 0; - this.fightTargetIds = []; - this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(72,76,120,0.76)"); - this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident", { participants: [this] }); - return true; - } - - recoverSleepDisease(reason = "") { - if (!this.sleepDisease) return false; - this.sleepDisease = false; - this.sleepDiseaseCooldown = 14; - this.sleepDiseaseAnchorX = null; - this.sleepDiseaseAnchorY = null; - this.state = "idle"; - this.sleeping = false; - this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"; - this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); - this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); - return true; - } - - infectExplosionDisease(source = null) { - if (this.dead || this.explosionDisease) return false; - this.explosionDisease = true; - this.explosionDiseaseTimer = Math.max(this.explosionDiseaseTimer || 0, (CONFIG.dayLength || 120) * 0.5); - this.fearTimer = Math.max(this.fearTimer || 0, 0.55); - this.thought = "\u7206\u767a\u75c5\u3067\u843d\u3061\u7740\u304b\u306a\u3044"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.2, "!", "rgba(178,78,42,0.82)"); - this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); - return true; - } - - recoverExplosionDisease(reason = "") { - if (!this.explosionDisease) return false; - this.explosionDisease = false; - this.explosionDiseaseTimer = 0; - this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; - this.stress = clamp(this.stress - 10, 0, 130); - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); - this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); - return true; - } - - recordBleedExposure() { - this.lastBleedAt = this.world?.time || 0; - } - - infectFightDisease(source = null) { - if (this.dead || this.fightDisease) return false; - this.fightDisease = true; - this.fightDiseaseCooldown = 18; - this.fearTimer = Math.max(this.fearTimer || 0, 0.35); - this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)"); - this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight", { participants: [this] }); - return true; - } - - recoverFightDisease(reason = "") { - if (!this.fightDisease) return false; - this.fightDisease = false; - this.fightDiseaseCooldown = 20; - this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; - this.stress = clamp(this.stress - 12, 0, 130); - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); - this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); - return true; - } - - freezeSleepDiseaseMotion(dt = 0) { - if (!this.sleepDisease) return false; - if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; - if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; - this.state = "sleep"; - this.sleeping = true; - this.target = null; - this.vx = 0; - this.vy = 0; - this.x = this.sleepDiseaseAnchorX; - this.y = this.sleepDiseaseAnchorY; - this.wanderAngle = this.wanderAngle || 0; - this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; - if (this.world && this.world.time > (this.nextSleepBubbleAt || 0)) { - this.nextSleepBubbleAt = this.world.time + 5.0; - this.world.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - return true; - } - - nestBoxById(id) { - if (!id) return null; - return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; - } - - isInsideNestBox() { - return Boolean(this.insideNestBoxId); - } - - wantsNestBox(box = null) { - if (!box || box.dead || box.type !== "nest_box") return false; - if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true; - if (!(this.state === "sleep" || this.state === "seek_bed")) return false; - return this.target === box || this.insideNestBoxId === box.id; - } - - enterNestBox(box, dt = 0.016) { - if (!box || box.dead || box.type !== "nest_box" || this.dead) return false; - const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; - const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; - const alreadyInside = this.insideNestBoxId === box.id; - if (!alreadyInside && occupants.length >= capacity) return false; - if (!alreadyInside) { - this.nestBoxEnteredAt = this.world?.time || 0; - this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14); - } - this.insideNestBoxId = box.id; - this.target = box; - if (this.state === "seek_bed") this.state = "sleep"; - if (this.state === "sleep") this.sleeping = true; - const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; - const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); - this.x = lerp(this.x, inner.x, pull); - this.y = lerp(this.y, inner.y, pull); - if (this.nestFade >= 0.88 || distXY(this.x, this.y, inner.x, inner.y) < 3.5) { - this.x = inner.x; - this.y = inner.y; - } - this.nestFade = clamp((this.nestFade || 0) + dt * 5.8, 0, 1); - this.vx = 0; - this.vy = 0; - this.impulseVx = 0; - this.impulseVy = 0; - return true; - } - - leaveNestBox(box = null) { - const b = box || this.nestBoxById(this.insideNestBoxId); - if (b && this.world?.nestBoxEntryPoint) { - const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); - this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); - this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); - this.vx = rand(-8, 8); - this.vy = rand(6, 18); - } - this.insideNestBoxId = null; - this.nestFade = 0; - this.nestBoxEnteredAt = -Infinity; - this.nestBoxStayUntil = -Infinity; - return true; - } - - updateNestBoxPresence(dt = 0) { - const currentBox = this.nestBoxById(this.insideNestBoxId); - const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; - const box = currentBox || targetBox; - if (!box) { - this.insideNestBoxId = null; - this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); - return null; - } - - if (currentBox && !this.wantsNestBox(currentBox)) { - this.leaveNestBox(currentBox); - return null; - } - - if (currentBox) { - if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { - this.state = "sleep"; - this.sleeping = true; - this.target = currentBox; - } - this.enterNestBox(currentBox, dt); - return currentBox; - } - - if (!this.wantsNestBox(targetBox)) { - this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); - return null; - } - - const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; - const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null; - const r = targetBox.r || 42; - const entryD = distXY(this.x, this.y, entry.x, entry.y); - const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y); - const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48; - const captureRange = Math.max(118, r * 2.05, this.radius + 82); - const nearEnough = entryD <= captureRange || centerD <= Math.max(104, r * 1.90) || inDoorColumn; - if (nearEnough) { - this.enterNestBox(targetBox, dt || 0.016); - return targetBox; - } - - this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); - return null; - } - - applyWaterEffect(dt, source = "water") { - const power = Math.max(0, dt || 0); - this.stress = clamp(this.stress - power * 2.4, 0, 130); - this.energy = clamp(this.energy + power * 0.5, 0, 100); - this.hunger = clamp(this.hunger - power * 0.2, 0, 115); - this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); - if (this.zunchiDisease) { - this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - power * (this.energy > 70 ? 0.050 : 0.028)); - if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { - this.world?.log?.(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); - } - } - if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); - } - - updateSpecialDiseases(dt) { - this.sleepDiseaseCooldown = Math.max(0, (this.sleepDiseaseCooldown || 0) - dt); - this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt); - if (this.explosionDisease) { - this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); - this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130)); - if (Math.random() < dt * 2.1) { - const a = rand(0, Math.PI * 2); - this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { - vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)" - })); - } - if (this.explosionDiseaseTimer <= 0.01) { - this.world?.explodeDiseaseTarinai?.(this); - return; - } - if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); - } - const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999); - if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) { - if (Math.random() < dt * 0.000375) this.infectFightDisease(); - } - if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { - if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); - } - if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { - this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); - } - if (this.fightDisease) { - this.stress = clamp(this.stress + dt * 0.16, 0, 130); - if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); - if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); - } - } - - canEatItemType(type) { - return this.isZunchiSlave ? type === "zunchi" : true; - } - - hasLoveMochiEffect() { - return (this.loveMochiTimer || 0) > 0.04; - } - - hasFightMochiEffect() { - return (this.fightMochiTimer || 0) > 0.04; - } - - becomeZunchiSlave() { - if (this.isZunchiSlave) return false; - this.formerName = this.formerName || this.name; - this.isZunchiSlave = true; - this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; - this.birthRitualTimer = 0; - this.birthPartnerId = null; - this.birthRitualLeader = false; - this.birthRitualRole = 0; - this.target = null; - this.reproductionTimer = Math.max(this.reproductionTimer || 0, CONFIG.reproductionCooldown + rand(8, 18)); - this.loveMochiTimer = 0; - this.addStress(14, { threshold: 8 }); - this.hunger = Math.max(this.hunger, 40); - this.visibleSpriteId = "zunchi_slave"; - this.spriteLockUntil = 0; - return true; - } - - rawSpriteId() { - if (this.dead) return this.variantSprite("fear"); - if (this.sleepDisease) return this.variantSprite("sleep"); - if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan"; - if (this.fightDisease) return "stress_dizzy"; - if (this.isZunchiSlave) return "zunchi_slave"; - if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; - if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; - if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat"; - if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt"); - if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual"; - const low = this.lowHealthSprite(); - if (low === "stretch") return low; - if (this.defeatedTimer > 0.04) return this.variantSprite("flee"); - if (this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); - if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear"); - if (this.state === "fight" || this.fightTimer > 0.04) return "angry"; - if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); - if (low) return low; - if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); - if (this.surpriseTimer > 0.08) return "pokan"; - if (this.state === "cursor_friend") return "smile"; - if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; - if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back"; - if (this.state === "seek_food") return "drool"; - if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); - if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; - if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; - return this.goodMode || this.variantSprite("normal"); - } - - - spriteId() { - const next = this.rawSpriteId(); - const now = this.world?.time || 0; - if (!this.visibleSpriteId) { - this.visibleSpriteId = next; - this.spriteLockUntil = now + 1.0; - return next; - } - if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) { - this.visibleSpriteId = next; - this.spriteLockUntil = now + 1.0; - } - return this.visibleSpriteId; - } - - - isAwakeCursorFriendly() { - if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false; - if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false; - if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false; - if (this.mood < 46 || this.stress > 64) return false; - return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); - } - - applyCursorContactCare(dt) { - const pointer = this.world?.pointer; - if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; - const d = distXY(this.x, this.y, pointer.x, pointer.y); - const touch = this.radius * 1.35; - if (d > touch) return; - const p = clamp(1 - d / touch, 0, 1); - const relief = dt * (1.35 + 2.65 * p); - this.stress = clamp((this.stress || 0) - relief, 0, 130); - this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); - this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); - this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); - this.goodMode = "smile"; - if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { - this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); - for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { - this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { - vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" - })); - } - } - } - - update(dt) { - if (this.dead) return; - this.prevX = this.x; - this.prevY = this.y; - - const minute = dt / 60; - this.age += dt; - this.reproductionTimer -= dt; - this.blink += dt; - this.eatTimer = Math.max(0, this.eatTimer - dt); - this.eatCooldown = Math.max(0, this.eatCooldown - dt); - this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); - this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); - this.fearTimer = Math.max(0, this.fearTimer - dt); - this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); - this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); - this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); - this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); - this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); - this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); - this.updateZunchiDisease(dt); - this.updateSpecialDiseases(dt); - if (this.dead) return; - this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); - this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); - if (this.fightMochiTimer > 0.04) { - this.stress = clamp(this.stress + dt * 0.11, 0, 130); - if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); - } - if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; - if (this.birthRitualTimer > 0) { - const beforeBirthTimer = this.birthRitualTimer; - this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); - if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - if (partner) this.world.finishBirthRitual(this, partner); - this.birthRitualLeader = false; - } - } - this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); - this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); - this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - this.applyCursorContactCare(dt); - const wasFighting = this.fightTimer > 0.04; - this.fightTimer = Math.max(0, this.fightTimer - dt); - this.fightCooldown = Math.max(0, this.fightCooldown - dt); - this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); - if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { - const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null; - this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); - this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); - this.hurtTimer = Math.max(this.hurtTimer, 1.8); - this.fallTimer = Math.max(this.fallTimer, 1.15); - this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); - this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; - this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); - if (winner) this.world.resolveFightOutcome(winner, this); - this.fightTargetIds = []; - this.fightTargetId = null; - } - this.stretchTimer = Math.max(0, this.stretchTimer - dt); - this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); - this.hurtTimer = Math.max(0, this.hurtTimer - dt); - this.fallTimer = Math.max(0, this.fallTimer - dt); - if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { - this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); - } - if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { - this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); - } - if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { - const side = this.facingDir(); - this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); - } - - this.tempTimer += dt; - if (this.tempTimer >= 0.55) { - this.tempComfort = this.world.temperatureAt(this.x, this.y); - this.tempTimer = 0; - } - const tempComfort = this.tempComfort ?? 0.62; - const tooCold = Math.max(0, 0.48 - tempComfort); - this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); - this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); - this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; - this.stress += tooCold * dt * 0.25; - - const phase = this.world.lightLevel(); - if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) this.stress += dt * 0.04; - if (this.world.weather === "cloudy") { - this.stress -= dt * 0.018; - this.vx *= Math.pow(0.996, dt * 60); - this.vy *= Math.pow(0.996, dt * 60); - } else if (this.world.weather === "light_rain") { - this.energy += dt * 0.018; - this.stress -= dt * 0.025; - } else if (this.world.weather === "sunny") { - this.vx *= Math.pow(1.001, dt * 60); - this.vy *= Math.pow(1.001, dt * 60); - } - - if (this.juvenile) { - const adultScale = 0.25 + (makeTrait(this.type).speed % 0.08); - this.scale = lerp(this.scale, adultScale, dt * 0.018 + 0.002); - this.radius = 56 * this.scale; - } - - this.hunger = clamp(this.hunger, 0, 115); - this.loneliness = clamp(this.loneliness, 0, 110); - this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); - - if (this.freezeSleepDiseaseMotion(dt)) { - this.checkLife(dt); - return; - } - - this.aiTimer += dt; - const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; - const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; - const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055); - if (this.aiTimer >= aiInterval) { - const aiDt = Math.min(this.aiTimer, 0.48); - this.aiTimer = 0; - this.resolveNeeds(aiDt); - const sleepingNow = this.state === "sleep"; - if (!sleepingNow) { - this.reactToFreshZunchi(aiDt); - this.updateFacing(); - this.interactWithItems(aiDt); - this.interactWithOthers(aiDt); - } else { - this.updateFacing(); - } - } else { - this.updateFacing(); - } - this.envTimer += dt; - const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48; - if (this.envTimer >= envInterval) { - const envDt = Math.min(this.envTimer, 1.2); - this.envTimer = 0; - this.applyEnvironment(envDt); - } - this.maintainTargetProgress(dt); - this.move(dt); - this.updateNestBoxPresence(dt); - if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); - this.checkLife(dt); - } - - applyEnvironment(dt) { - const parent = this.parentToFollow ? this.parentToFollow() : null; - if (parent && dist(this, parent) < 96) { - this.stress = clamp(this.stress - dt * 0.42, 0, 130); - this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); - this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); - } - const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; - const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; - if (friend) { - this.stress = clamp(this.stress - dt * 0.36, 0, 130); - this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); - } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { - this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); - } - let grassComfort = 0; - for (const it of this.world.nearbyItems(this.x, this.y, 105)) { - if (it.type !== "grass" || it.dead) continue; - const d = distXY(this.x, this.y, it.x, it.y); - const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); - grassComfort += clamp(1 - d / 105, 0, 1) * lush; - } - let nestComfort = 0; - for (const it of this.world.nearbyItems(this.x, this.y, 120)) { - if (!it || it.dead || it.type !== "nest_box") continue; - nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); - } - if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130); - if (grassComfort > 0) { - this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); - if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); - if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); - } - } - - bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") { - if (this.world.time < this.nextBubbleAt) return; - this.nextBubbleAt = this.world.time + cooldown; - this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); - } - - mouthPosition(target = null) { - const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir(); - return { - x: this.x + dir * this.radius * 0.40 - this.radius * 0.34, - y: this.y - this.radius * 0.28, - }; - } - - isSleepFurniture(target) { - return Boolean(target && (target.type === "bed" || target.type === "nest_box")); - } - - sleepFurnitureLabel(target) { - return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; - } - - isServingFoodItem(it) { - return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); - } - - foodItemHasServingLeft(it) { - if (!it || it.dead) return false; - if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; - this.ensureFoodServings(it); - return (it.foodServingsRemaining || 0) > 0; - } - - ensureFoodServings(it) { - if (!this.isServingFoodItem(it)) return; - const max = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize || "medium") : 5; - if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max; - if (!Number.isFinite(it.foodServingsRemaining)) { - const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58 }[it.type] || 62; - const ratio = clamp((it.amount ?? original) / original, 0.05, 1); - it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio)); - } - it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax); - it.amount = it.foodServingsRemaining; - } - - consumeFoodServing(it, nutrition) { - this.ensureFoodServings(it); - if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; - it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); - it.amount = it.foodServingsRemaining; - return nutrition; - } - - sleepSpotFor(bed) { - if (!bed) return { x: this.x, y: this.y }; - if (bed.type === "nest_box") { - if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this); - if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); - return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; - } - const angle = stableUnit(this.familyKey || this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; - const baseRing = bed.r * 0.52; - const ring = baseRing + stableUnit(this.familyKey || this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95; - return { - x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), - y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding), - }; - } - - desiredFacingDir() { - if (this.state === "sleep") return this.sleepFacing || this.facing || 1; - if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1; - if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx); - return this.facing || 1; - } - - facingDir() { - return this.visualFacing || this.facing || 1; - } - - updateFacing() { - const desired = this.desiredFacingDir(); - const now = this.world?.time || 0; - this.facing = desired; - if (!this.visualFacing) { - this.visualFacing = desired; - this.facingLockUntil = now + 1.0; - return; - } - if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) { - this.visualFacing = Math.sign(desired) || this.visualFacing || 1; - this.facingLockUntil = now + 1.0; - } - } - - reactToFreshZunchi(dt) { - if (this.isZunchiSlave) return; - if (this.state === "panic" || this.fearTimer > 0.2) return; - const z = this.world.nearest(this, ["zunchi"], 90); - if (!z || z.stage !== "fresh") return; - const d = dist(this, z); - const push = clamp(1 - d / 90, 0, 1); - this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); - if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); - this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; - this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; - } - - resolveNeeds(dt) { - const wasSleeping = this.state === "sleep"; - const previousTarget = this.target && !this.target.dead ? this.target : null; - this.target = null; - this.sleeping = false; - - if (this.birthRitualTimer > 0.04) { - this.state = "birth_ritual"; - this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; - this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; - return; - } - - if (this.intimidateTimer > 0.04) { - this.state = "intimidate"; - this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; - this.bubble("!", 1.2); - this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; - return; - } - - if (this.fightTimer > 0.04) { - this.state = "fight"; - this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); - this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; - let best = null, bestD = Infinity; - for (const id of this.fightTargetIds) { - const t = this.world.tarinai.find(o => o.id === id && !o.dead); - if (!t) continue; - const d = dist(this, t); - if (d < bestD) { best = t; bestD = d; } - } - this.target = best; - this.bubble("!", 1.6); - this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b"; - return; - } - - if (this.defeatedTimer > 0.04) { - const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); - this.state = "panic"; - this.target = this.panicDestination(opponent); - this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b"; - if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; - return; - } else if (this.defeatedById && this.fightTimer <= 0.04) { - this.defeatedById = null; - this.fightWinnerId = null; - } - - const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; - if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { - this.state = "panic"; - this.target = this.panicDestination(friendNearby.target); - this.fearTimer = Math.max(this.fearTimer, 0.32); - this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; - return; - } - - const nearbyAnts = (this.world.ants || []).filter(a => a && !a.dead && distXY(this.x, this.y, a.x, a.y) <= (a.kind === "queen" ? 112 : 82)); - let nearestAnt = null; - let nearestAntDist = Infinity; - for (const ant of nearbyAnts) { - const ad = distXY(this.x, this.y, ant.x, ant.y); - if (ad < nearestAntDist) { - nearestAnt = ant; - nearestAntDist = ad; - } - } - if (nearestAnt) { - const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1); - if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { - if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { - // Intimidation has no effect on ants. This is only a visible reaction. - this.state = "ant_intimidate"; - this.target = nearestAnt; - this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); - this.thought = "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; - return; - } - this.state = "ant_attack"; - this.target = nearestAnt; - this.goodMode = "angry"; - this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; - return; - } - this.state = "panic"; - this.target = this.panicDestination(nearestAnt); - this.fearTimer = Math.max(this.fearTimer, 0.34); - this.thought = "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b"; - return; - } - - const dayP = this.world.dayProgress(); - const personalSleep = this.sleepStart < this.sleepEnd - ? dayP >= this.sleepStart && dayP < this.sleepEnd - : dayP >= this.sleepStart || dayP < this.sleepEnd; - - if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o)); - if (opponent) { - const d = dist(this, opponent); - if (d <= Math.max(48, this.radius + opponent.radius + 18)) { - if (this.world.startForcedFight?.(this, opponent)) return; - } - this.state = "seek_friend"; - this.target = opponent; - this.goodMode = "angry"; - this.thought = "\u3051\u3093\u304b\u9905\u3067\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b"; - if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); - return; - } - } - - const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; - const feared = this.strongestRelation("fear"); - if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { - const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); - if (rival && dist(this, rival) < 150) { - this.state = "panic"; - this.target = this.panicDestination(rival); - this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear); - this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`; - this.world.relationNotice(this.id, rival.id, "avoid", 22); - return; - } - } - - if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const water = this.world.nearest(this, ["water", "water_bowl"], 760); - if (water && !water.dead) { - this.state = "seek_water"; - this.target = water; - this.thought = this.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - if (this.zunchiDisease) { - this.state = "zunchi_sick"; - this.target = null; - this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); - return; - } - } - - if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const cureFood = this.world.nearest(this, ["sweet"], 700); - if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { - this.state = "seek_food"; - this.target = cureFood; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); - this.thought = this.fightDisease ? "\u304d\u305a\u3064\u304d\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - this.state = "fight_sick"; - this.target = null; - this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); - return; - } - - if (!this.isZunchiSlave) { - const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390); - if (specialMochi && this.foodItemHasServingLeft(specialMochi) && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) { - this.state = "seek_food"; - this.target = specialMochi; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.34); - this.thought = specialMochi.type === "love_mochi" ? "\u3078\u3053\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : (specialMochi.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u306d\u3080\u308a\u85ac\u3092\u63a2\u3057\u3066\u3044\u308b"); - return; - } - } else if (this.hunger > 20) { - const zOnly = this.world.nearest(this, ["zunchi"], 520); - if (zOnly && zOnly.amount > 1.2) { - this.state = "seek_food"; - this.target = zOnly; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "\u305a\u3093\u3061\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; - const zunda = this.world.nearest(this, ["sweet"], zundaRange); - const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); - const zundaReward = this.affection > 8 && this.hunger > 7; - if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { - this.state = "seek_food"; - this.target = zunda; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); - this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - - if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { - this.state = "panic"; - this.target = this.panicDestination(); - this.thought = "\u9003\u3052\u3066\u3044\u308b"; - return; - } - - const sleepBias = this.personalityProfile().sleep; - const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); - if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) { - let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; - if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; - if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); - if (bed && !wasSleeping && this.energy > 18) { - const spot = this.sleepSpotFor(bed); - if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { - this.state = "seek_bed"; - this.target = bed; - this.thought = `${this.sleepFurnitureLabel(bed)}\u3078\u5411\u304b\u3063\u3066\u3044\u308b`; - return; - } - } - this.state = "sleep"; - this.target = bed; - this.sleeping = true; - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - this.thought = bed ? (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b") : "\u7720\u3063\u3066\u3044\u308b"; - return; - } - - const pointer = this.world.pointer; - if (pointer?.inside) { - const pd = distXY(this.x, this.y, pointer.x, pointer.y); - if (pd < 170) { - const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; - const friendly = !hostile && this.isAwakeCursorFriendly(); - if (hostile) { - this.state = "cursor_enemy"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; - this.fearTimer = Math.max(this.fearTimer, 0.18); - if (this.world.time > this.nextFearBubbleAt) { - this.nextFearBubbleAt = this.world.time + 2.4; - this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); - } - this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; - return; - } - if (friendly) { - this.state = "cursor_friend"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; - this.goodMode = "smile"; - this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - } - - const playTrait = this.personalityProfile().play ?? 1; - const openPlay = this.shouldApplyPersonalityBehavior("openness", 1); - const closedPlay = this.shouldApplyPersonalityBehavior("openness", -1); - const ballRange = openPlay ? 540 : (closedPlay ? 170 : 240 + 60 * playTrait); - const ball = this.world.nearest(this, ["ball"], ballRange); - if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { - const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0; - const baseChance = openPlay ? 0.74 : (closedPlay ? 0.025 : 0.08); - const movingBonus = clamp(ballSpeed / 120, 0, 0.42); - const socialBonus = this.loneliness > 48 ? 0.10 : 0; - if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { - this.state = "play_ball"; - this.target = ball; - this.goodMode = "smile"; - this.thought = openPlay ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; - return; - } - } - - const parent = this.parentToFollow(); - if (parent && !this.shouldAvoidTarget(parent)) { - const d = dist(this, parent); - if (d > 34 || (this.hunger < 78 && this.energy > 24)) { - this.state = "follow_parent"; - this.target = parent; - this.bubble("?", 4.0); - this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - - if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { - this.state = "seek_food"; - this.target = friendNearby.target; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; - return; - } - - if (this.hunger > 62) { - const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "ant_corpse"]; - const item = this.world.nearest(this, itemTypes, 420); - if (item) { - this.state = "seek_food"; - this.target = item; - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1); - const lonerSeek = this.shouldApplyPersonalityBehavior("sociability", -1); - if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { - const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); - if (other) { - this.state = "seek_friend"; - this.target = other; - this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b"; - return; - } - } - - if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { - this.state = "panic"; - this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - return; - } - - if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); - this.state = "idle"; - this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`; - } - - makePoop(force = 1) { - this.digest += force; - if (this.digest < 12.4) return; - this.digest = rand(0.4, 1.6); - this.poopCount += 1; - const side = this.facingDir(); - const backX = this.x - side * this.radius * rand(0.72, 0.96); - const backY = this.y + this.radius * rand(0.25, 0.48); - this.world.spawnZunchi(backX, backY); - this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)"); - if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] }); - } - - interactWithItems(dt) { - const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18; - const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7; - const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104); - for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { - const d = dist(this, it); - if (d > this.radius + it.r + 12) continue; - - if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it, it.type === "sweet" ? 7.2 : 8.5); - if (eating > 0) { - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.25, dt * 60); - this.vy *= Math.pow(0.25, dt * 60); - this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95); - this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); - this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; - if (it.type === "sweet") { - this.stress = clamp(this.stress - eating * 0.42, 0, 130); - this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); - if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); - if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); - } - this.eatTimer = Math.max(this.eatTimer, 0.32); - this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); - this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); - if (Math.random() < dt * 2.2) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7"); - } - this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7"; - this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22)); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } - if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { - this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); - this.lastLog = this.world.time; - } - break; - } - } - - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it, 6.8); - if (eating <= 0) continue; - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.25, dt * 60); - this.vy *= Math.pow(0.25, dt * 60); - this.hunger -= eating * 0.82; - this.recoverHealth(eating * 0.24); - this.affection += eating * 0.04; - if (it.type === "love_mochi") { - this.stress = clamp(this.stress - eating * 0.25, 0, 130); - this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); - this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); - this.lastMealColor = "#ff7bab"; - } else if (it.type === "fight_mochi") { - this.stress = clamp(this.stress + eating * 0.22, 0, 130); - this.fearTimer = Math.max(this.fearTimer || 0, 0.32); - this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); - this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); - this.lastMealColor = "#e07c43"; - } else { - this.stress = clamp(this.stress - eating * 0.10, 0, 130); - this.energy = clamp(this.energy - eating * 0.20, 0, 100); - this.fearTimer = Math.max(0, (this.fearTimer || 0) - 0.18); - this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - 2.5); - this.bubble("Zzz...", 2.3, "rgba(104,84,168,0.78)"); - this.lastMealColor = "#b89cff"; - if (!this.sleepDisease && Math.random() < Math.min(0.98, 0.35 + eating * 0.075)) this.infectSleepDisease(it); - } - this.eatTimer = Math.max(this.eatTimer, 0.30); - this.eatCooldown = rand(0.38, 0.68); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); - if (Math.random() < dt * 2.2) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : (it.type === "fight_mochi" ? "#e07c43" : "#b89cff")); - } - this.makePoop(eating * 0.20); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } - if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { - this.world.log(`${this.name}\u306f${it.type === "love_mochi" ? "\u3078\u3053\u9905" : (it.type === "fight_mochi" ? "\u3051\u3093\u304b\u9905" : "\u306d\u3080\u308a\u85ac")}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); - this.lastLog = this.world.time; - } - break; - } - - if (canBite && it.type === "grass") { - const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); - this.state = "eat"; - this.target = it; - this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); - this.vx *= Math.pow(0.20, dt * 60); - this.vy *= Math.pow(0.20, dt * 60); - it.amount -= bite; - it.eatenAmount = (it.eatenAmount || 0) + bite; - it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); - this.hunger -= bite * 0.46; - this.recoverHealth(bite * 0.18); - this.stress -= dt * 0.5; - this.eatTimer = Math.max(this.eatTimer, 0.22); - this.eatCooldown = rand(0.68, 1.15); - this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); - if (Math.random() < dt * 2.0) { - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); - } - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } - this.makePoop(bite * 0.10); - if (this.type === "jito") this.affection += dt * 0.25; - break; - } - - if (canBite && it.type === "ant_corpse") { - const bite = Math.min(it.amount, rand(3.2, 5.4)); - if (bite > 0) { - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.18, dt * 60); - this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; - this.hunger -= bite * 0.62; - this.recoverHealth(bite * 0.12); - this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); - this.eatTimer = Math.max(this.eatTimer, 0.26); - this.eatCooldown = rand(0.75, 1.25); - this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); - this.lastMealColor = "#4a3f36"; - this.makePoop(bite * 0.08); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; } - break; - } - } - - if (it.type === "zunchi") { - const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (!this.isZunchiSlave) { - this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); - if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); - } - this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); - if (canZunchiBite) { - const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - this.state = "eat"; - this.target = it; - this.vx *= Math.pow(0.18, dt * 60); - this.vy *= Math.pow(0.18, dt * 60); - it.amount -= bite; - this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18); - this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); - this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); - if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); - this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); - this.lastMealColor = "#6b8d3f"; - this.eatTimer = Math.max(this.eatTimer, 0.24); - this.eatCooldown = rand(0.9, 1.4); - this.bubble(this.isZunchiSlave ? "\u3082\u3050" : "...", 2.2, "rgba(74,91,50,0.78)"); - const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); - if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } - break; - } - } - - if (it.type === "water" || it.type === "water_bowl") { - this.applyWaterEffect(dt, it.type); - if (it.type === "water") it.amount -= dt * 2.4; - if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; - } - - if (it.type === "stone") { - this.stress -= dt * 0.85; - this.energy += dt * 0.18; - if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; - } - - if (this.isSleepFurniture(it)) { - if (this.energy < 74 + 6 * this.personalityProfile().sleep) { - const occ = this.world.bedOccupancy(it); - const comfort = this.world.bedComfort(it); - this.state = "sleep"; - this.target = it; - this.sleeping = true; - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - const crowded = occ > 5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, crowded ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)"); - } - this.energy += dt * (1.9 + comfort * 1.4); - this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); - it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); - } - } - - if (it.type === "ball") { - const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { - this.goodMode = "smile"; - this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); - this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); - if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10); - } - } - - if (it.type === "splat") { - const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); - if (touch > 0) { - this.stress = clamp(this.stress + touch * dt * 2.8 * this.personalityProfile().fear, 0, 130); - if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); - } - } - - if (it.type === "trace") { - this.loneliness -= dt * 0.25; - this.stress += dt * 0.06 * this.personalityProfile().fear; - } - } - this.hunger = clamp(this.hunger, 0, 115); - this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); - this.loneliness = clamp(this.loneliness, 0, 110); - } - - interactWithOthers(dt) { - let closeCount = 0; - for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) { - if (o === this || o.dead) continue; - const dx = o.x - this.x, dy = o.y - this.y; - const d = Math.hypot(dx, dy); - if (d < 0.01 || d > 74) continue; - closeCount += 1; - - const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect()); - if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) { - if (this.world.startForcedFight?.(this, o)) continue; - } - - if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - 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] }); - } - if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) { - 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 overlap = this.radius + o.radius - d; - if (overlap > -2) { - const push = (overlap + 6) * (pairBond ? 0.08 : 0.17); - this.vx -= (dx / d) * push; - this.vy -= (dy / d) * push; - if (!pairBond && Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); - } - if (pairBond && d > 26 && d < 120) { - this.vx += (dx / d) * dt * 4.8; - this.vy += (dy / d) * dt * 4.8; - } - - if (d < 60) { - const rel = this.relationTo(o.id); - const pdef = this.personalityProfile(); - this.loneliness -= dt * 1.65 * 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"); - if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { - this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); - this.affection = clamp(this.affection + dt * 0.18, 0, 80); - if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation", { participants: [o, this] }); - } - if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && this.world.relationNotice(this.id, o.id, "friend", 32)) { - this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation", { participants: [o, this] }); - } - if (this.type === "angry") this.stress += dt * 0.08; - if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; - } - - const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); - if (fightingThis && d < 36 && this.id < o.id) { - const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; - if (pulse && this.world.time > this.nextHeadbutt) { - const nx = dx / d, ny = dy / d; - this.vx -= nx * rand(24, 44); - this.vy -= ny * rand(24, 44); - o.vx += nx * rand(34, 64); - o.vy += ny * rand(34, 64); - this.stress = clamp(this.stress + 1.6, 0, 130); - o.stress = clamp(o.stress + 2.8, 0, 130); - this.damage(1.2, "\u55a7\u5629"); - o.damage(1.6, "\u55a7\u5629"); - this.hurtTimer = Math.max(this.hurtTimer, 1.4); - o.hurtTimer = Math.max(o.hurtTimer, 1.8); - this.adjustRelation(o, -0.16, 0.12, "fight"); - o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); - if (Math.random() < 0.22 || o.energy < 24) { - o.fallTimer = Math.max(o.fallTimer, 0.58); - o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); - o.fallDir = nx > 0 ? 1 : -1; - this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); - } - if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { - o.defeatedById = this.id; - o.fightWinnerId = this.id; - } - this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); - this.nextHeadbutt = this.world.time + rand(0.34, 0.55); - } - } - - const relA = this.relationTo(o.id); - const relB = o.relationTo(this.id); - const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect(); - const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect(); - const aggressiveThis = this.shouldApplyPersonalityBehavior("aggression", 1); - const aggressiveOther = o.shouldApplyPersonalityBehavior?.("aggression", 1); - const calmThis = this.shouldApplyPersonalityBehavior("aggression", -1); - const calmOther = o.shouldApplyPersonalityBehavior?.("aggression", -1); - const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); - const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); - const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); - const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); - const conflictBias = (battleDrug ? 2.9 : 1) * (o.isZunchiSlave ? 2.2 : 1) * (this.isZunchiSlave ? 1.15 : 1); - const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; - const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; - const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; - if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { - this.world.startConflict(this, o); - } - - if (!this.isZunchiSlave && !o.isZunchiSlave && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { - const enough = loveDrug ? (this.hunger < 90 && o.hunger < 92 && this.energy > 18 && o.energy > 18) : (this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44); - const lonelyBond = loveDrug ? (this.loneliness > 4 || o.loneliness > 4 || this.affection > 0 || o.affection > 0) : (this.loneliness > 24 || o.loneliness > 24); - if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) { - this.world.startBirthRitual(this, o); - } - } - } - - if (closeCount > 8) { - this.stress += dt * 0.9 * this.personalityProfile().fear; - this.energy -= dt * 0.25; - this.fearTimer = Math.max(this.fearTimer, 0.18); - } - - if (this.loneliness < 12 && Math.random() < dt * 0.002) { - 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] }); - } - } - - move(dt) { - if (this.freezeSleepDiseaseMotion(dt)) return; - let ax = 0, ay = 0; - const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); - - if (this.state === "sleep") { - const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72; - const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; - const crowdedSleep = bedCrowd > 5; - this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); - this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, bedCrowd - 5) : -0.12 * bedComfort), 0, 130); - this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); - if (this.target && !this.target.dead && this.target.type === "nest_box") { - this.updateNestBoxPresence(dt); - this.vx = 0; - this.vy = 0; - return; - } - if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { - const spot = this.sleepSpotFor(this.target); - const dx = spot.x - this.x; - const dy = spot.y - this.y; - const d = Math.hypot(dx, dy); - if (d > 2.2) { - const step = Math.min(d, dt * (12 + bedComfort * 14)); - this.x += dx / d * step; - this.y += dy / d * step; - } else { - this.x = spot.x; - this.y = spot.y; - } - } - this.updateNestBoxPresence(dt); - this.vx *= Math.pow(0.55, dt * 60); - this.vy *= Math.pow(0.55, dt * 60); - return; - } - - if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - if (partner) { - const midX = (this.x + partner.x) / 2; - const midY = (this.y + partner.y) / 2; - const pdx = partner.x - this.x; - const pdy = partner.y - this.y; - const plen = Math.max(0.001, Math.hypot(pdx, pdy)); - const nx = -pdy / plen; - const ny = pdx / plen; - const desiredGap = this.radius * 0.36; - const side = this.birthRitualRole || 1; - const tx = midX + nx * desiredGap * side; - const ty = midY + ny * desiredGap * side; - this.vx += (tx - this.x) * dt * 3.8; - this.vy += (ty - this.y) * dt * 3.8; - } - this.vx *= Math.pow(0.48, dt * 60); - this.vy *= Math.pow(0.48, dt * 60); - this.x += Math.sin(this.world.time * 26 + this.age) * 0.20; - return; - } - - if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") { - this.vx *= Math.pow(0.22, dt * 60); - this.vy *= Math.pow(0.22, dt * 60); - return; - } - - if (this.eatTimer > 0.04 || this.state === "eat") { - this.vx = 0; - this.vy = 0; - return; - } - - if (this.state === "panic") { - const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity; - const speed = Math.hypot(this.vx, this.vy); - this.panicStuckTimer = speed < 8 ? (this.panicStuckTimer || 0) + dt : 0; - if (!this.target || this.target.dead || targetDist < 28 || this.panicStuckTimer > 0.75) { - this.target = this.panicDestination(null, true); - } - } - - if (this.target && !this.target.dead) { - const moveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target; - const dx = moveTarget.x - this.x; - const dy = moveTarget.y - this.y; - const d = Math.hypot(dx, dy) || 1; - if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) { - const bedDx = this.target.x - this.x; - const bedDy = this.target.y - this.y; - const bedDist = Math.hypot(bedDx, bedDy); - const reachedSleepFurniture = this.target.type === "nest_box" - ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) - : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); - if (reachedSleepFurniture) { - this.state = "sleep"; - this.sleeping = true; - if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); - return; - } - } - let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1; - if (this.target?.detour) sign = 1; - let strength = 1; - if (this.state === "follow_parent") { - strength = d < 42 ? 0.18 : 1.22; - sign = 1; - } else if (this.state === "cursor_friend") { - strength = d < 30 ? 0.12 : 0.95; - sign = 1; - } else if (this.state === "cursor_enemy") { - strength = d < 150 ? 1.35 : 0.35; - sign = -1; - } else if (this.state === "play_ball") { - strength = d < this.radius + 18 ? 1.75 : 1.28; - sign = 1; - } else if (this.state === "fight") { - const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37); - if (d < 18) { - strength = 1.05; - sign = -1; - } else { - strength = headbutt > -0.15 ? 2.65 : 0.65; - sign = headbutt > -0.15 ? 1 : -1; - } - } else if (this.state === "ant_attack") { - strength = d < this.radius + ((this.target && this.target.r) || 4) + 6 ? 0.18 : 1.32; - sign = 1; - if (d < this.radius + ((this.target && this.target.r) || 4) + 10) { - const aggression = Math.max(0, this.currentPersonality?.aggression || 0); - if ((this.world.time || 0) >= (this.nextAntAttackAt || 0)) { - this.nextAntAttackAt = (this.world.time || 0) + 0.34; - if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (4.6 + aggression * 3.4)); - this.fearTimer = Math.max(this.fearTimer, 0.12); - this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; - if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); - } - } - } - ax += (dx / d) * baseSpeed * sign * strength; - ay += (dy / d) * baseSpeed * sign * strength; - } else if (this.state === "panic") { - this.wanderAngle += rand(-3.8, 3.8) * dt; - ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; - ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; - } else if (this.state === "zunchi_sick") { - this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; - ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72; - ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72; - } else if (this.state === "fight_sick") { - this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; - ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88; - ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88; - } else { - ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34; - ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34; - } - - this.vx += ax * dt; - this.vy += ay * dt; - this.vx *= Math.pow(0.84, dt * 8); - this.vy *= Math.pow(0.84, dt * 8); - - const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76)); - const v = Math.hypot(this.vx, this.vy); - if (v > maxV) { - this.vx = this.vx / v * maxV; - this.vy = this.vy / v * maxV; - } - - const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; - const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; - this.x += (this.vx + impulseVx) * dt; - this.y += (this.vy + impulseVy) * dt; - const impulseDrag = Math.pow(0.58, dt * 3.2); - this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; - this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; - - if (this.entryTimer > 0) { - this.entryTimer = Math.max(0, this.entryTimer - dt); - if (this.x >= CONFIG.worldPadding && this.y >= CONFIG.worldPadding && this.x <= this.world.w - CONFIG.worldPadding && this.y <= this.world.h - CONFIG.worldPadding) { - this.entryTimer = 0; - } - } - const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding; - const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding; - const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding; - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } - if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } - if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } - } - - checkLife(dt = 0.016) { - let reason = ""; - if (this.energy <= 0) { - reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; - } - else if (this.hunger >= 108) reason = "\u98e2\u9913"; - else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; - else if (this.stress >= 124) { - reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - } - else if (this.mood <= 20) { - const lowMood = clamp((20 - this.mood) / 20, 0, 1); - const bodyLoad = clamp((this.hunger - 72) / 42, 0, 1) * 0.018 + clamp((28 - this.energy) / 28, 0, 1) * 0.020 + clamp((this.stress - 86) / 44, 0, 1) * 0.022; - const deathRisk = 0.010 + lowMood * 0.060 + bodyLoad; - if (Math.random() < Math.max(0, dt) * deathRisk) { - reason = this.dominantDeathCause("", { lowMood: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - } - } - if (reason) this.die(reason); - } - - die(reason) { - if (this.dead) return; - const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason) : (reason || "\u4e8b\u6545"); - this.dead = true; - this.deathReason = finalReason; - this.world.deadCount += 1; - this.world.items.push(new Item("trace", this.x, this.y)); - this.world.items.push(new Item("splat", this.x, this.y)); - if (Math.random() < 0.55) { - const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); - if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); - } - this.world.markDead(this, finalReason); - this.releasedLiveToken = this.liveToken || 0; - this.world.releaseTarinaiLiveId?.(this); - audio.death(); - 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; - } - - draw(ctx, t, lighting = null) { - if (this.dead) return; - const lightState = lighting || getLightingState(this.world); - const sprite = this.spriteId(); - const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type)); - const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); - const w = metrics?.w || 512; - const h = metrics?.h || 512; - const moveSpeed = Math.hypot(this.vx, this.vy); - const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8); - const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18); - const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0; - const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0; - const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0; - const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20; - const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0; - const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0; - const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0; - const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25; - const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; - const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); - const diseaseSpin = this.fightDisease ? (t * 3.6 + this.age) : 0; - const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin + diseaseSpin; - const insideCardboard = false; - let drawW = w * this.scale * 0.30; - let drawH = h * this.scale * 0.30; - if (insideCardboard) { - drawW *= 0.72; - drawH *= 0.72; - } - if (sprite === "stretch") { - drawW *= 1.16; - drawH *= 1.24; - } - if (sprite === "intimidate") { - drawW *= 1.32; - drawH *= 1.32; - } - if (sprite === "birth_ritual") { - drawW *= 0.64; - drawH *= 0.64; - } - if (fallPose > 0) { - drawW *= 1 + fallPose * 0.08; - drawH *= 1 - fallPose * 0.07; - } - const squish = this.state === "sleep" - ? 1 + breathing * 0.018 - : 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03; - const faceRight = this.state === "sleep" - ? this.sleepFacing > 0 - : this.facingDir() > 0; - const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt; - const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1; - if (nestHideAlpha <= 0.01) return; - - const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8); - const shadowContactY = this.y + bob + (drawH / squish) * 0.30; - if (img) { - drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { - faceRight, - alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha, - widthScale: this.state === "sleep" ? 1.12 : 1, - heightScale: this.state === "sleep" ? 0.88 : 1, - }); - } else { - drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha }); - } - drawCreatureGlow(ctx, this, drawW, drawH, lightState); - if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); - const hx = partner ? (this.x + partner.x) / 2 : this.x; - const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60; - const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10; - const size = Math.max(10, this.radius * 0.62) * pulse; - drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" }); - } - - ctx.save(); - ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY); - const observeKind = this.world.observeHighlightKind ? this.world.observeHighlightKind(this) : ""; - if (observeKind && this.world.selected !== this) { - const style = { - parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" }, - child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" }, - friend: { color: "rgba(240,91,135,0.90)", label: "\u53cb\u9054" }, - enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" }, - }[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" }; - ctx.save(); - ctx.globalAlpha = 0.96 * nestHideAlpha; - ctx.shadowColor = style.color; - ctx.shadowBlur = 16; - ctx.strokeStyle = style.color; - ctx.lineWidth = 5.2; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.78, drawH * 0.60, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.shadowBlur = 0; - ctx.strokeStyle = "rgba(255,255,255,0.92)"; - ctx.lineWidth = 2.0; - ctx.setLineDash([8, 5]); - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.86, drawH * 0.66, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - if (style.label) { - const labelY = -drawH * 0.62; - ctx.font = `${Math.max(11, this.radius * 0.52)}px Yomogi, ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - const labelW = ctx.measureText(style.label).width + 16; - ctx.fillStyle = "rgba(255,252,238,0.92)"; - ctx.strokeStyle = style.color; - ctx.lineWidth = 2.2; - tarinaiRoundRectPath(ctx, -labelW / 2, labelY - 10, labelW, 20, 10); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = style.color; - ctx.fillText(style.label, 0, labelY); - } - ctx.restore(); - } - - if (this.world.selected === this) { - ctx.save(); - const pulse = 0.5 + 0.5 * Math.sin(this.world.time * 7.5); - const jumpPulse = clamp(this.focusPulseTimer || 0, 0, 1); - ctx.globalAlpha = (0.82 + pulse * 0.12) * nestHideAlpha; - ctx.shadowColor = "rgba(124, 210, 76, 0.92)"; - ctx.shadowBlur = 14 + 18 * Math.max(pulse, jumpPulse); - ctx.strokeStyle = "rgba(112, 198, 70, 0.98)"; - ctx.lineWidth = 4.2 + jumpPulse * 2.0; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * (0.74 + jumpPulse * 0.10), drawH * (0.56 + jumpPulse * 0.08), 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.shadowBlur = 0; - ctx.setLineDash([9, 5]); - ctx.lineWidth = 2.0; - ctx.strokeStyle = "rgba(255,255,255,0.96)"; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.88, drawH * 0.68, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = "rgba(255,252,232,0.94)"; - ctx.strokeStyle = "rgba(112,198,70,0.88)"; - ctx.lineWidth = 2; - const focusLabel = "\u6ce8\u76ee"; - ctx.font = `${Math.max(11, this.radius * 0.54)}px Yomogi, ui-rounded, sans-serif`; - const lw = ctx.measureText(focusLabel).width + 18; - const ly = -drawH * 0.64; - tarinaiRoundRectPath(ctx, -lw / 2, ly - 11, lw, 22, 11); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "rgba(72,126,42,0.96)"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(focusLabel, 0, ly); - ctx.restore(); - } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { - ctx.save(); - ctx.globalAlpha = 0.28; - ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - - ctx.rotate(angle); - ctx.globalAlpha *= nestHideAlpha; - if (insideCardboard) ctx.globalAlpha *= 0.58; - - - if (this.eatTimer > 0.02) { - ctx.save(); - ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); - ctx.strokeStyle = this.lastMealColor || "#f7cf67"; - ctx.lineWidth = 1.6; - ctx.beginPath(); - ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - - if (this.pokeFlashTimer > 0.03) { - ctx.save(); - ctx.globalAlpha = this.pokeFlashTimer * 0.9 * nestHideAlpha; - ctx.fillStyle = "rgba(255, 120, 120, 0.22)"; - ctx.beginPath(); - ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { - ctx.save(); - const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0; - ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; - ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); - const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2; - for (let i = 0; i < count; i++) { - const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05; - const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit; - const py = -drawH * (0.22 + (i % 2) * 0.06) + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * (0.08 + cursorLove * 0.02); - if (this.state === "cursor_friend") { - const s = Math.max(5, drawW * (0.070 + (i % 3) * 0.012 + cursorLove * 0.01)); - drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 }); - } else { - ctx.beginPath(); - ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.restore(); - } - - if (faceRight) ctx.scale(-1, 1); - const dw = drawW * squish; - const dh = drawH / squish; - const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17); - const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14); - const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0; - const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); - const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); - if (img) { - const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; - ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); - } else { - ctx.save(); - ctx.fillStyle = this.stress > 62 ? "#f1c3a8" : "#f4ead6"; - ctx.strokeStyle = "rgba(92,71,49,0.48)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(0, 0, dw * 0.42, dh * 0.36, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.restore(); - } - if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { - const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); - if (stainCanvas) { - ctx.save(); - ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88); - ctx.drawImage(stainCanvas, -dw / 2, -dh / 2, dw, dh); - ctx.restore(); - } - } - drawDawnRimLight(ctx, dw, dh, lightState); - if (faceRight) ctx.scale(-1, 1); - - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) { - ctx.save(); - ctx.globalAlpha = 0.80; - ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(42,36,29,0.72)"; - const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?"); - ctx.fillText(mark, 0, -drawH * 0.72); - ctx.restore(); - } - - ctx.restore(); - - if (this.favorite) { - ctx.save(); - ctx.globalAlpha = nestHideAlpha; - const starSize = Math.max(18, this.radius * 0.88); - const sx = this.x; - const sy = this.y - drawH * 0.64 - 20; - ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.shadowColor = "rgba(255, 198, 28, 0.82)"; - ctx.shadowBlur = 14; - ctx.lineWidth = Math.max(2, starSize * 0.12); - ctx.strokeStyle = "rgba(92, 62, 0, 0.86)"; - ctx.fillStyle = "rgba(255, 218, 46, 1)"; - ctx.strokeText("\u2605", sx, sy); - ctx.fillText("\u2605", sx, sy); - ctx.restore(); - } - - const hpActive = (this.hpBarTimer || 0) > 0; - const stressActive = (this.stressBarTimer || 0) > 0; - if (hpActive || stressActive) { - const bw = Math.max(28, this.radius * 2.35); - const bh = 5; - const x = this.x - bw / 2; - const baseY = this.y - drawH * 0.55 - 16; - const drawMeter = (y, pct, fill, alpha) => { - ctx.save(); - ctx.globalAlpha = alpha; - ctx.fillStyle = "rgba(255,252,242,0.86)"; - roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); - ctx.fill(); - ctx.fillStyle = "rgba(72,58,45,0.22)"; - roundedRect(ctx, x, y, bw, bh, 4); - ctx.fill(); - ctx.fillStyle = fill; - roundedRect(ctx, x, y, bw * pct, bh, 4); - ctx.fill(); - ctx.restore(); - }; - if (stressActive) { - const pct = clamp((this.stress || 0) / 130, 0, 1); - drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); - } - if (hpActive) { - const pct = clamp(this.energy / 100, 0, 1); - drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); - } - } - } - - contains(x, y) { - if (this.insideNestBoxId || (this.nestFade || 0) >= 0.92) return false; - const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; - return distXY(this.x, this.y, x, y) < hit; + this.laxativePoopTimer = Number.isFinite(opts.laxativePoopTimer) ? opts.laxativePoopTimer : 3; + this.ammoBumpCooldown = opts.ammoBumpCooldown || 0; + this.powerItemMode = opts.powerItemMode === "protein" || opts.powerItemMode === "niteropu" ? opts.powerItemMode : ""; + this.sizeItemMode = opts.sizeItemMode === "giant_drug" || opts.sizeItemMode === "dwarf_drug" ? opts.sizeItemMode : ""; + this.lifeItemMode = opts.lifeItemMode === "mercury" ? opts.lifeItemMode : ""; + this.mercuryLifeSpanBase = Number.isFinite(opts.mercuryLifeSpanBase) ? opts.mercuryLifeSpanBase : null; + this.mercuryLifeMultiplier = Number.isFinite(opts.mercuryLifeMultiplier) ? Math.max(1, opts.mercuryLifeMultiplier) : (this.lifeItemMode === "mercury" ? 1.3 : 1); + this.itemDamageTimer = Number.isFinite(opts.itemDamageTimer) ? opts.itemDamageTimer : 1; + this.powerEffectPulse = opts.powerEffectPulse || 0; + this.powerEffectKind = opts.powerEffectKind || this.powerItemMode || this.sizeItemMode || this.lifeItemMode || ""; + this.mysteryDrugHistory = Array.isArray(opts.mysteryDrugHistory) ? opts.mysteryDrugHistory.slice(-8) : []; + this.recentChangeCauses = Array.isArray(opts.recentChangeCauses) ? opts.recentChangeCauses.slice(-8) : []; + if (this.refreshEffectiveSize) this.refreshEffectiveSize(); } } + +if (typeof window !== "undefined") window.Tarinai = Tarinai; +if (typeof globalThis !== "undefined") globalThis.Tarinai = Tarinai; diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js new file mode 100644 index 0000000..0492f3e --- /dev/null +++ b/js/tarinai_action_state.js @@ -0,0 +1,90 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_action_state.js"); + + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + setActionState(state, opts = {}) { + if (this.dead) return false; + this.state = state || "idle"; + if ("target" in opts) this.target = opts.target; + if (opts.reason != null) this.thought = String(opts.reason || ""); + if (opts.clearTarget) this.target = null; + if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping); + if (opts.wake) { + this.sleeping = false; + if (this.insideNestBoxId && this.leaveNestBox) this.leaveNestBox(this.world?.nestBoxById?.(this.insideNestBoxId)); + this.insideNestBoxId = ""; + } + return true; + }, + + beginEating(item = null, reason = "") { + return this.setActionState("eat", { + target: item || null, + reason: reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている"), + sleeping: false, + }); + }, + + currentLodgedPin() { + return typeof lodgedPinFor === "function" ? lodgedPinFor(this, this.world) : null; + }, + + currentLodgedPinBehavior() { + const pin = this.currentLodgedPin ? this.currentLodgedPin() : null; + return typeof pinBehaviorFor === "function" ? pinBehaviorFor(pin?.type) : null; + }, + + hasLodgedPinEffect(effectKey = "") { + return Boolean(this.currentLodgedPinBehavior?.()?.[effectKey]); + }, + + goIdle(reason = "") { + return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false }); + }, + + startSleeping(target = null, reason = "") { + return this.setActionState("sleep", { target, reason: reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている"), sleeping: true }); + }, + + seekTarget(state, target = null, reason = "") { + return this.setActionState(state, { target, reason, sleeping: false }); + }, + + enterPanic(opts = {}) { + if (this.dead) return false; + const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 }; + const fearMul = opts.fearMultiplier ?? profile.fear ?? 1; + const threat = opts.threat ?? opts.target ?? null; + let target = null; + if ("target" in opts) target = opts.target; + else if (opts.destination) target = opts.destination; + else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); + this.setActionState("panic", { + target, + reason: opts.reason || this.thought || "パニックになっている", + wake: opts.wake !== false, + sleeping: false, + }); + if (Number.isFinite(opts.fearTimer)) this.fearTimer = Math.max(this.fearTimer || 0, opts.fearTimer * fearMul); + else this.fearTimer = Math.max(this.fearTimer || 0, (opts.fear ?? 0.55) * fearMul); + if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); + if (Number.isFinite(opts.hurtTimer)) this.hurtTimer = Math.max(this.hurtTimer || 0, opts.hurtTimer); + if (Number.isFinite(opts.awakeLockTimer)) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, opts.awakeLockTimer); + this.panicTimer = Math.max(this.panicTimer || 0, Number.isFinite(opts.duration) ? opts.duration : 3.0); + if (Number.isFinite(opts.fallTimer)) { + this.fallTimer = Math.max(this.fallTimer || 0, opts.fallTimer); + this.fallMax = Math.max(this.fallMax || 0, this.fallTimer); + } + if (Number.isFinite(opts.stress) && opts.stress > 0) { + global.TarinaiNeedSystem?.addNeedShock?.(this, { safety: opts.stress }, threat); + } + if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)"); + this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic"; + this.lastPanicAt = this.world?.time || 0; + return true; + }, + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js new file mode 100644 index 0000000..5bbec40 --- /dev/null +++ b/js/tarinai_disease_nest.js @@ -0,0 +1,362 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_disease_nest.js"); + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + infectZunchiDisease(source = null, force = false) { + if (this.dead || this.zunchiDisease) return false; + if (!force && (this.zunchiDiseaseCooldown || 0) > 0) return false; + this.zunchiDisease = true; + this.zunchiDiseaseSeverity = Math.max(this.zunchiDiseaseSeverity || 0, source ? 0.72 : 0.86); + this.zunchiDiseaseCooldown = 12; + this.fearTimer = Math.max(this.fearTimer || 0, 0.35); + this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.45); + this.thought = "\u305a\u3093\u3061\u75c5\u306b\u304b\u304b\u3063\u305f"; + audio.disease?.(); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "\u3046\u3045\u2026", "rgba(38,82,38,0.82)"); + this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_zunchi", source }); + this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u305a\u3093\u3061\u75c5"); + return true; + }, + + recoverZunchiDisease(reason = "") { + if (!this.zunchiDisease) return false; + this.zunchiDisease = false; + this.zunchiDiseaseSeverity = 0; + this.zunchiDiseaseCooldown = 18; + this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100); + this.stress = clamp(this.stress - 8, 0, 130); + this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f"; + audio.heal?.(); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)"); + this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_zunchi", reason }); + this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u305a\u3093\u3061\u75c5"); + return true; + }, + + emitZunchiDiseaseEffect(dt) { + if (!this.zunchiDisease || !this.world?.effects) return; + if (Math.random() < dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4)) { + this.world.effects.push(new Effect("zunchi_miasma", this.x + rand(-this.radius * 0.45, this.radius * 0.45), this.y - this.radius * rand(0.05, 0.72), { + vx: rand(-5, 5), + vy: rand(-10, -3), + life: rand(1.4, 2.4), + size: rand(8, 18) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45), + color: "rgba(16,78,28,0.70)", + })); + } + }, + + updateZunchiDisease(dt) { + this.zunchiStain = clamp(this.zunchiStain || 0, 0, 100); + this.zunchiDiseaseCooldown = Math.max(0, (this.zunchiDiseaseCooldown || 0) - dt); + if (this.world?.weather === "light_rain") { + this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100); + if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006); + } + if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045)) { + this.infectZunchiDisease(null, true); + this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); + } + if (this.zunchiDisease) { + this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); + this.stress = clamp(this.stress + dt * (0.28 + 0.18 * this.zunchiDiseaseSeverity), 0, 130); + this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); + if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); + this.emitZunchiDiseaseEffect(dt); + if (this.zunchiDiseaseSeverity <= 0.04) { + if (this.recoverZunchiDisease("\u4f53\u529b\u304c\u623b\u308a\u3001\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); + } + } + }, + + hasWaterCurableDisease() { + return Boolean(this.zunchiDisease || this.explosionDisease); + }, + + hasZundaCurableDisease() { + return Boolean(this.explosionDisease || this.fightDisease); + }, + + infectSleepDisease(source = null) { + if (this.dead || this.sleepDisease) return false; + this.sleepDisease = true; + this.sleepDiseaseCooldown = 8; + this.sleepDiseaseAnchorX = this.x; + this.sleepDiseaseAnchorY = this.y; + this.startSleeping(null, "ねむり病で眠り続けている"); + this.fightTimer = 0; + this.intimidateTimer = 0; + this.fightTargetIds = []; + this.thought = "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; + audio.disease?.(); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(72,76,120,0.76)"); + this.world?.log?.(`${this.name}\u306f\u306d\u3080\u308a\u75c5\u306b\u304b\u304b\u3063\u305f\u3002`, "accident", { participants: [this] }); + this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_sleep", source }); + this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u306d\u3080\u308a\u75c5"); + return true; + }, + + recoverSleepDisease(reason = "") { + if (!this.sleepDisease) return false; + this.sleepDisease = false; + this.sleepDiseaseCooldown = 14; + this.sleepDiseaseAnchorX = null; + this.sleepDiseaseAnchorY = null; + this.goIdle(reason || "ねむり病が治った"); + this.sleeping = false; + this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); + audio.heal?.(); + this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); + this.world?.log?.(`${this.name}\u306e\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_sleep", reason }); + this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u306d\u3080\u308a\u75c5"); + return true; + }, + + infectExplosionDisease(source = null) { + if (this.dead || this.explosionDisease) return false; + this.explosionDisease = true; + this.explosionDiseaseTimer = Math.max(this.explosionDiseaseTimer || 0, 60); + this.fearTimer = Math.max(this.fearTimer || 0, 0.55); + this.thought = "\u7206\u767a\u75c5\u3067\u843d\u3061\u7740\u304b\u306a\u3044"; + audio.disease?.(); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.2, "!", "rgba(178,78,42,0.82)"); + this.world?.log?.(`${this.name}\u306f\u7206\u767a\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); + this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_explosion", source }); + this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u7206\u767a\u75c5"); + return true; + }, + + recoverExplosionDisease(reason = "") { + if (!this.explosionDisease) return false; + this.explosionDisease = false; + this.explosionDiseaseTimer = 0; + this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; + audio.heal?.(); + this.stress = clamp(this.stress - 10, 0, 130); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); + this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_explosion", reason }); + this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u7206\u767a\u75c5"); + return true; + }, + + recordBleedExposure() { + this.lastBleedAt = this.world?.time || 0; + }, + + infectFightDisease(source = null) { + if (this.dead || this.fightDisease) return false; + this.fightDisease = true; + this.fightDiseaseCooldown = 18; + this.fearTimer = Math.max(this.fearTimer || 0, 0.35); + this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + audio.disease?.(); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)"); + this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight", { participants: [this] }); + this.world?.emit?.("disease:infected", { tarinai: this, diseaseId: "disease_fight", source }); + this.recordChangeCause?.("\u75c5\u6c17\u767a\u75c7", "\u304d\u305a\u3064\u304d\u75c5"); + return true; + }, + + recoverFightDisease(reason = "") { + if (!this.fightDisease) return false; + this.fightDisease = false; + this.fightDiseaseCooldown = 20; + this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; + audio.heal?.(); + this.stress = clamp(this.stress - 12, 0, 130); + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); + this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); + this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_fight", reason }); + this.recordChangeCause?.("\u75c5\u6c17\u56de\u5fa9", "\u304d\u305a\u3064\u304d\u75c5"); + return true; + }, + + freezeSleepDiseaseMotion(dt = 0) { + if (!this.sleepDisease) return false; + const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy); + if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; + if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; + this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている"); + this.vx = 0; + this.vy = 0; + if (beingDraggedByAnt) { + this.sleepDiseaseAnchorX = this.x; + this.sleepDiseaseAnchorY = this.y; + } else { + this.x = this.sleepDiseaseAnchorX; + this.y = this.sleepDiseaseAnchorY; + } + this.wanderAngle = this.wanderAngle || 0; + this.thought = beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; + if (this.world && this.world.time > (this.nextSleepBubbleAt || 0)) { + this.nextSleepBubbleAt = this.world.time + 5.0; + this.world.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + return true; + }, + + nestBoxById(id) { + if (!id) return null; + return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; + }, + + isInsideNestBox() { + return Boolean(this.insideNestBoxId); + }, + + wantsNestBox(box = null) { + if (!box || box.dead || box.type !== "nest_box") return false; + if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true; + if (!(this.state === "sleep" || this.state === "seek_bed")) return false; + return this.target === box || this.insideNestBoxId === box.id; + }, + + enterNestBox(box, dt = 0.016) { + if (!box || box.dead || box.type !== "nest_box" || this.dead) return false; + const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; + const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; + const alreadyInside = this.insideNestBoxId === box.id; + if (!alreadyInside && occupants.length >= capacity) return false; + if (!alreadyInside) { + this.nestBoxEnteredAt = this.world?.time || 0; + this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14); + } + this.insideNestBoxId = box.id; + this.target = box; + if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている"); + if (this.state === "sleep") this.sleeping = true; + const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; + const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); + this.x = lerp(this.x, inner.x, pull); + this.y = lerp(this.y, inner.y, pull); + if (this.nestFade >= 0.88 || distXY(this.x, this.y, inner.x, inner.y) < 3.5) { + this.x = inner.x; + this.y = inner.y; + } + this.nestFade = clamp((this.nestFade || 0) + dt * 5.8, 0, 1); + this.vx = 0; + this.vy = 0; + this.impulseVx = 0; + this.impulseVy = 0; + return true; + }, + + leaveNestBox(box = null) { + const b = box || this.nestBoxById(this.insideNestBoxId); + if (b && this.world?.nestBoxEntryPoint) { + const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); + this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + this.vx = rand(-8, 8); + this.vy = rand(6, 18); + } + this.insideNestBoxId = null; + this.nestFade = 0; + this.nestBoxEnteredAt = -Infinity; + this.nestBoxStayUntil = -Infinity; + return true; + }, + + updateNestBoxPresence(dt = 0) { + const currentBox = this.nestBoxById(this.insideNestBoxId); + const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; + const box = currentBox || targetBox; + if (!box) { + this.insideNestBoxId = null; + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + if (currentBox && !this.wantsNestBox(currentBox)) { + this.leaveNestBox(currentBox); + return null; + } + + if (currentBox) { + if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { + this.startSleeping(box, "巣箱の中で眠っている"); + this.sleeping = true; + this.target = currentBox; + } + this.enterNestBox(currentBox, dt); + return currentBox; + } + + if (!this.wantsNestBox(targetBox)) { + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; + const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null; + const r = targetBox.r || 42; + const entryD = distXY(this.x, this.y, entry.x, entry.y); + const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y); + const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48; + const captureRange = Math.max(118, r * 2.05, this.radius + 82); + const nearEnough = entryD <= captureRange || centerD <= Math.max(104, r * 1.90) || inDoorColumn; + if (nearEnough) { + this.enterNestBox(targetBox, dt || 0.016); + return targetBox; + } + + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + }, + + applyWaterEffect(dt, source = "water") { + const power = Math.max(0, dt || 0); + this.stress = clamp(this.stress - power * 2.4, 0, 130); + this.energy = clamp(this.energy + power * 0.5, 0, 100); + this.hunger = clamp(this.hunger - power * 0.2, 0, 115); + this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); + if (this.zunchiDisease) { + this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - power * (this.energy > 70 ? 0.050 : 0.028)); + if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) { + this.world?.log?.(`${this.name}\u306f\u6c34\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f\u3002`, "accident", { participants: [this] }); + } + } + if (this.explosionDisease) this.recoverExplosionDisease("\u6c34\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + }, + + updateSpecialDiseases(dt) { + this.sleepDiseaseCooldown = Math.max(0, (this.sleepDiseaseCooldown || 0) - dt); + this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt); + if (this.explosionDisease) { + this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); + this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130)); + if (Math.random() < dt * 2.1) { + const a = rand(0, Math.PI * 2); + this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { + vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)" + })); + } + if (this.explosionDiseaseTimer <= 0.01) { + this.world?.explodeDiseaseTarinai?.(this); + return; + } + if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); + } + const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999); + if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) { + if (Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.000375) : dt * 0.000375)) this.infectFightDisease(); + } + if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { + if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); + } + if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { + this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + if (this.fightDisease) { + this.stress = clamp(this.stress + dt * 0.16, 0, 130); + if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); + if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); + } + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js new file mode 100644 index 0000000..6d0776b --- /dev/null +++ b/js/tarinai_identity_social.js @@ -0,0 +1,441 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_identity_social.js"); + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + get lack() { + return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100); + }, + + get mood() { + return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100); + }, + + get ageSinceBirth() { + return Math.max(0, this.world.time - this.birthTime); + }, + + get growth() { + return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1); + }, + + get juvenile() { + return this.generation > 1 && this.growth < 1; + }, + + syncGrowthScale() { + if (!Number.isFinite(this.adultScale)) this.adultScale = adultScaleFromGenetics(this.familyKey || this.id, this.genetics); + const nextScale = this.generation > 1 ? scaleForGrowth(this.adultScale, this.growth) : this.adultScale; + this.scale = clamp(nextScale, 0.10, 0.42); + if (this.refreshEffectiveSize) this.refreshEffectiveSize(); + else this.radius = 56 * this.scale; + const growthBucket = Math.floor(clamp(this.growth || 0, 0, 1) * 20); + if (this.generation > 1 && this.world?.recordFamily && this.familyJoined?.() && this.familyGrowthBucket !== growthBucket) { + this.familyGrowthBucket = growthBucket; + this.world.recordFamily(this); + } + return this.scale; + }, + + geneticStatRatio(key = "") { + this.genetics = normalizeGenetics(this.genetics, this.familyKey || this.id); + if (key === "life") return this.genetics.lifeSpanMul || 1; + if (key === "attack") return this.genetics.attackMul || 1; + if (key === "speed") return this.genetics.speedMul || 1; + if (key === "size") return this.genetics.sizeMul || 1; + return 1; + }, + + parentToFollow() { + if (!this.juvenile || !this.parents?.length) return null; + let best = null, bestD = Infinity; + for (const id of this.parents) { + const p = this.world.tarinai.find(t => (t.familyKey || t.id) === id && !t.dead); + if (!p) continue; + const d = dist(this, p); + if (d < bestD) { best = p; bestD = d; } + } + return best; + }, + + label() { + const sp = SPRITES.find(s => s.id === this.type); + return sp ? sp.label : this.type; + }, + + addRecord(text, kind = "note", options = {}) { + if (!text) return; + if (options.hiddenFromObservation) return; + if (!Array.isArray(this.records)) this.records = []; + const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" }; + const head = this.records[0]; + if (head && head.text === entry.text && Math.abs((head.time || 0) - entry.time) < 0.05) return; + this.records.unshift(entry); + if (this.records.length > 42) this.records.length = 42; + }, + + recordChangeCause(reason = "", target = "", options = {}) { + const text = String(reason || "").trim(); + const affected = String(target || "").trim(); + if (!text && !affected) return false; + if (!Array.isArray(this.recentChangeCauses)) this.recentChangeCauses = []; + const now = this.world?.time || 0; + const entry = { + time: now, + reason: text || "\u5909\u5316", + target: affected || "\u72b6\u614b", + value: options.value ?? "", + }; + const head = this.recentChangeCauses[0]; + if (head && head.reason === entry.reason && head.target === entry.target) { + const oldValue = Number(head.value); + const addValue = Number(entry.value); + if (Number.isFinite(oldValue) || Number.isFinite(addValue)) head.value = (Number.isFinite(oldValue) ? oldValue : 0) + (Number.isFinite(addValue) ? addValue : 0); + else head.value = entry.value || head.value || ""; + head.time = now; + return true; + } + this.recentChangeCauses.unshift(entry); + if (this.recentChangeCauses.length > 8) this.recentChangeCauses.length = 8; + return true; + }, + + personalityProfile() { + const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; + const e = { + neuroticism: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (ensurePersonality(this).currentPersonality.neuroticism || 0)), + aggression: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "aggression") : (ensurePersonality(this).currentPersonality.aggression || 0)), + sociability: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "sociability") : (ensurePersonality(this).currentPersonality.sociability || 0)), + openness: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "openness") : (ensurePersonality(this).currentPersonality.openness || 0)), + }; + return { + ...base, + fear: clamp((base.fear || 1) * (1 + e.neuroticism * 0.38), 0.55, 1.85), + fight: clamp((base.fight || 1) * (1 + e.aggression * 0.55), 0.35, 2.35), + social: clamp((base.social || 1) * (1 + e.sociability * 0.24), 0.62, 1.52), + relation: clamp((base.relation || 1) * (1 + e.sociability * 0.24), 0.62, 1.60), + play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10), + }; + }, + + personalityLabel() { + const tags = getPersonalityTraitTags(this); + return tags.length ? tags.join(" / ") : "\u4e2d\u7acb"; + }, + + personalityTags() { return getPersonalityTraitTags(this); }, + + adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); }, + + shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); }, + + fightWinCount() { + return this.totalFightWins || 0; + }, + + fightLossCount() { + return this.totalFightLosses || 0; + }, + + maybeBecomeTimidAfterDamage(cause = "") { + if (this.dead) return false; + const losses = this.totalFightLosses || 0; + const wins = this.totalFightWins || 0; + if (losses <= wins) return false; + if (Math.random() >= 0.05) return false; + const changedA = this.adjustPersonality?.("neuroticism", 0.025, "after being hurt") || 0; + const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; + this.fearTimer = Math.max(this.fearTimer || 0, 1.2); + this.stress = clamp((this.stress || 0) + 5, 0, 130); + return Boolean(changedA || changedB); + }, + + relationTo(id) { + if (!id) return relationDefaults(); + if (!this.relationships) this.relationships = {}; + if (!this.relationships[id]) this.relationships[id] = relationDefaults(); + return this.relationships[id]; + }, + + adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") { + if (!other || !other.id || other === this) return; + const rel = this.relationTo(other.id); + rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40); + rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40); + rel.fightsWon = rel.fightsWon || 0; + rel.fightsLost = rel.fightsLost || 0; + if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } + }, + + strongestRelation(kind = "friend", liveOnly = true) { + let bestId = null; + let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null; + for (const [id, rel] of Object.entries(this.relationships || {})) { + if (liveIds && !liveIds.has(id)) continue; + const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); + if (score > bestScore) { bestId = id; bestScore = score; } + } + return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null; + }, + + relationSummary() { + const friend = this.strongestRelation("friend"); + const fear = this.strongestRelation("fear"); + return { friend, fear }; + }, + + currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) { + let count = 0; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null; + for (const [id, rel] of Object.entries(this.relationships || {})) { + if ((rel.affinity || 0) <= minScore) continue; + if (liveIds && !liveIds.has(id)) continue; + count += 1; + } + return count; + }, + + sociabilityFriendScale() { + const friends = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + return clamp(0.82 + Math.min(6, friends) * 0.16, 0.82, 1.78); + }, + + bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) { + let best = null, bestScore = minScore; + for (const [id, rel] of Object.entries(this.relationships || {})) { + const score = rel.affinity || 0; + if (score <= bestScore) continue; + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; + best = t; + bestScore = score; + } + return best; + }, + + recentFightRival(maxAge = 52, maxDist = Infinity) { + let best = null, bestT = -Infinity; + for (const [id, rel] of Object.entries(this.relationships || {})) { + const event = String(rel.lastEvent || ""); + const when = rel.lastTime || -Infinity; + if (this.world.time - when > maxAge) continue; + if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue; + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; + if (when > bestT) { best = t; bestT = when; } + } + return best; + }, + + poke() { + audio.poke(); + this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e"); + this.addStress(22, { threshold: 8 }); + this.surpriseTimer = 0.55; + this.fearTimer = Math.max(this.fearTimer, 1.2); + this.pokeFlashTimer = 0.55; + this.hurtTimer = Math.max(this.hurtTimer, 2.4); + this.vx += rand(-100, 100); + this.vy += rand(-100, 100); + if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e"); + }, + + showHpBar(duration = 4.8) { + this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration); + }, + + showStressBar(duration = 4.2) { + this.stressBarTimer = Math.max(this.stressBarTimer || 0, duration); + }, + + addStress(amount, opts = {}) { + const tolerance = Number.isFinite(opts.tolerance) ? opts.tolerance : 0.88; + const delta = Math.max(0, (amount || 0) * tolerance); + if (delta <= 0) return 0; + const before = this.stress || 0; + this.stress = clamp(before + delta, 0, 130); + const gained = Math.max(0, this.stress - before); + const threshold = opts.threshold ?? 8; + if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2); + return gained; + }, + + damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); }, + + recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); }, + + rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); }, + + weakenedDeathReasonFor(cause = "") { return HEALTH.weakenedDeathReasonFor(cause); }, + + dominantDeathCause(reason = "", opts = {}) { return HEALTH.dominantDeathCause(this, reason, opts); }, + + normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); }, + + damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); }, + + wakeFromDamage(reason = "") { + if (this.dead) return false; + const wasSleeping = this.state === "sleep" || this.sleeping || this.insideNestBoxId; + if (!wasSleeping) return false; + const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; + this.sleeping = false; + if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f", fear: 0.85, wake: false, cause: "wake_from_damage" }); + else { this.setActionState?.("panic", { target: null, reason: "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f", wake: false }); this.fearTimer = Math.max(this.fearTimer || 0, 0.85); } + this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); + this.nestBoxStayUntil = -Infinity; + if (this.insideNestBoxId) this.leaveNestBox(box); + this.vx += rand(-34, 34); + this.vy += rand(-26, 8); + return true; + }, + + recoverHealth(amount) { + if (this.dead) return; + this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); + }, + + familyJoined() { + return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length); + }, + + nearestCoParent(maxRange = 220) { + let best = null; + let bestD = maxRange; + for (const o of this.world.tarinai || []) { + if (!o || o === this || o.dead) continue; + if (!(this.world.areCoParents?.(this, o))) continue; + const d = dist(this, o); + if (d < bestD) { + best = o; + bestD = d; + } + } + return best; + }, + + displayGeneration() { + return this.familyJoined() ? this.generation : null; + }, + + targetIdentity(target = this.target) { + if (!target) return ""; + if (target.id) return `${target.type || "tarinai"}:${target.id}`; + if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`; + return ""; + }, + + targetIgnoresFence(target = this.target) { + return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); + }, + + shouldAvoidTarget(target) { + if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false; + if (this.targetIgnoresFence(target)) return false; + const key = this.targetIdentity(target); + if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true; + return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false; + }, + + maintainTargetProgress(dt) { + const target = this.target; + if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) { + this.targetKey = ""; + this.targetSince = this.world.time; + this.targetBestDist = Infinity; + return; + } + if (this.targetIgnoresFence(target)) return; + const key = this.targetIdentity(target); + const d = distXY(this.x, this.y, target.x, target.y); + if (key !== this.targetKey) { + this.targetKey = key; + this.targetSince = this.world.time; + this.targetBestDist = d; + } else if (d < (this.targetBestDist || Infinity) - 8) { + this.targetBestDist = d; + this.targetSince = this.world.time; + } + if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) { + this.targetGiveupKey = key; + this.targetGiveupUntil = this.world.time + 8; + this.target = null; + this.goIdle?.("柵に阻まれて行き先を変えている"); + this.wanderAngle += rand(-1.4, 1.4); + return; + } + if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) { + this.targetGiveupKey = key; + this.targetGiveupUntil = this.world.time + 12; + this.target = null; + this.goIdle?.("辿り着けず、あきらめた"); + this.wanderAngle += rand(-2.2, 2.2); + } + }, + + panicDestination(threat = null, force = false) { + const now = this.world.time || 0; + const current = this.panicTarget; + if (!force && current && now < (this.panicTargetUntil || 0) && distXY(this.x, this.y, current.x, current.y) > 34) return current; + let ax = Math.cos(this.wanderAngle || 0); + let ay = Math.sin(this.wanderAngle || 0); + if (threat && Number.isFinite(threat.x) && Number.isFinite(threat.y)) { + const dx = this.x - threat.x; + const dy = this.y - threat.y; + const d = Math.hypot(dx, dy) || 1; + ax = dx / d; + ay = dy / d; + } + const turn = rand(-0.75, 0.75); + const cos = Math.cos(turn); + const sin = Math.sin(turn); + const dirX = ax * cos - ay * sin; + const dirY = ax * sin + ay * cos; + const distance = rand(150, 250); + const p = CONFIG.worldPadding + this.radius; + let x = clamp(this.x + dirX * distance, p, this.world.w - p); + let y = clamp(this.y + dirY * distance, p, this.world.h - p); + if (distXY(this.x, this.y, x, y) < 70) { + x = clamp(this.x - dirY * distance, p, this.world.w - p); + y = clamp(this.y + dirX * distance, p, this.world.h - p); + } + this.panicTarget = { x, y, dead: false, detour: true, panic: true }; + this.panicTargetUntil = now + rand(1.6, 3.0); + this.panicStuckTimer = 0; + this.wanderAngle = Math.atan2(y - this.y, x - this.x); + return this.panicTarget; + }, + + lowHealthSprite() { + // This is physical weakness only. Stress is handled by the stress sprite branch + // below so high-stress individuals do not incorrectly render as low-health. + const critical = this.hunger > 112 || this.energy < 7; + if (critical) return this.stretchTimer > 0 ? "stretch" : "weak"; + const weak = this.hunger > 102 || this.energy < 18; + if (weak) return "weak"; + return null; + }, + + briefDeathReason(reason = this.deathReason) { return HEALTH.briefDeathReason(this, reason); }, + + variantSprite(category) { + const pools = { + hurt: ["hurt", "hurt2"], + fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"], + flee: ["flee", "flee_fear2"], + sleep: ["sleep", "sleep2"], + stress: ["stress_dizzy", "stress_sweat"], + low_stress: lowStressSprites(), + intimidate: ["intimidate"], + normal: displayNormalSprites(), + }; + const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id)); + return stableChoice(this.familyKey || this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile"; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js new file mode 100644 index 0000000..20ab5e9 --- /dev/null +++ b/js/tarinai_item_effects.js @@ -0,0 +1,359 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_item_effects.js"); + + const EFFECT_REGISTRY = global.TarinaiEffectRegistry || null; + const ITEM_EFFECT_LABELS = Object.freeze(EFFECT_REGISTRY?.labels?.() || {}); + const ITEM_EFFECT_COLORS = Object.freeze(EFFECT_REGISTRY?.colors?.() || {}); + const RANDOM_EFFECT_POOL = Object.freeze(EFFECT_REGISTRY?.randomPool?.() || [ + "love_mochi", "fight_mochi", "sleep_drug", "laxative", "ammo", "protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", + "disease_zunchi", "disease_sleep", "disease_explosion", "disease_fight", + ]); + + function effectLabel(id = "") { + return EFFECT_REGISTRY?.label?.(id) || ITEM_EFFECT_LABELS[id] || id || "\u4e0d\u660e"; + } + + function sizeDurationScale(item = null) { + const size = item?.toolSize || "medium"; + if (size === "small") return 0.74; + if (size === "large") return 1.45; + return 1; + } + + function recordItemChange(tarinai, id = "", reason = "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c") { + tarinai?.recordChangeCause?.(reason, effectLabel(id)); + } + + function pickRandomEffects(count = 2) { + const pool = [...RANDOM_EFFECT_POOL]; + const out = []; + while (pool.length && out.length < count) { + const idx = Math.floor(Math.random() * pool.length); + out.push(pool.splice(idx, 1)[0]); + } + return out; + } + + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + ensureItemEffectState() { + if (!this.itemEffectTimers || typeof this.itemEffectTimers !== "object") this.itemEffectTimers = { laxative: 0, ammo: 0 }; + if (!Number.isFinite(this.itemEffectTimers.laxative)) this.itemEffectTimers.laxative = 0; + if (!Number.isFinite(this.itemEffectTimers.ammo)) this.itemEffectTimers.ammo = 0; + if (!Number.isFinite(this.laxativePoopTimer)) this.laxativePoopTimer = 3; + if (!Number.isFinite(this.ammoBumpCooldown)) this.ammoBumpCooldown = 0; + if (this.powerItemMode !== "protein" && this.powerItemMode !== "niteropu") this.powerItemMode = ""; + if (this.sizeItemMode !== "giant_drug" && this.sizeItemMode !== "dwarf_drug") this.sizeItemMode = ""; + if (this.lifeItemMode !== "mercury") this.lifeItemMode = ""; + if (!Number.isFinite(this.mercuryLifeSpanBase)) this.mercuryLifeSpanBase = null; + if (!Number.isFinite(this.mercuryLifeMultiplier)) this.mercuryLifeMultiplier = this.lifeItemMode === "mercury" ? 1.3 : 1; + this.mercuryLifeMultiplier = Math.max(1, this.mercuryLifeMultiplier || 1); + if (!Number.isFinite(this.itemDamageTimer)) this.itemDamageTimer = 1; + if (!Number.isFinite(this.powerEffectPulse)) this.powerEffectPulse = 0; + if (!Array.isArray(this.mysteryDrugHistory)) this.mysteryDrugHistory = []; + }, + + hasItemEffect(id = "") { + this.ensureItemEffectState(); + if (id === "protein" || id === "niteropu") return this.powerItemMode === id; + if (id === "giant_drug" || id === "dwarf_drug") return this.sizeItemMode === id; + if (id === "mercury") return this.lifeItemMode === "mercury"; + return (this.itemEffectTimers?.[id] || 0) > 0.04; + }, + + setTimedItemEffect(id = "", seconds = 0) { + this.ensureItemEffectState(); + if (!(id in this.itemEffectTimers)) this.itemEffectTimers[id] = 0; + this.itemEffectTimers[id] = Math.max(this.itemEffectTimers[id] || 0, Number(seconds) || 0); + if (id === "laxative") this.laxativePoopTimer = Math.min(Math.max(this.laxativePoopTimer || 3, 0.2), 3); + this.world?.emit?.("effect:applied", { tarinai: this, effectId: id, duration: this.itemEffectTimers[id] || 0 }); + recordItemChange(this, id, "\u6642\u9593\u52b9\u679c"); + return true; + }, + + itemSizeMultiplier() { + this.ensureItemEffectState(); + if (this.sizeItemMode === "giant_drug") return EFFECT_REGISTRY?.modifier?.("giant_drug", "sizeMultiplier", 3) ?? 3; + if (this.sizeItemMode === "dwarf_drug") return EFFECT_REGISTRY?.modifier?.("dwarf_drug", "sizeMultiplier", 0.25) ?? 0.25; + return 1; + }, + + effectiveScale() { + return (this.scale || 0.28) * (this.itemSizeMultiplier ? this.itemSizeMultiplier() : 1); + }, + + refreshEffectiveSize() { + const mult = this.itemSizeMultiplier ? this.itemSizeMultiplier() : 1; + this.radius = 56 * (this.scale || 0.28) * mult; + return this.radius; + }, + + setPowerItemMode(mode = "") { + this.ensureItemEffectState(); + if (mode !== "protein" && mode !== "niteropu") return false; + this.powerItemMode = mode; + this.powerEffectKind = mode; + this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 3.2); + this.spawnPowerItemEffect(mode, 1.5); + this.world?.emit?.("effect:applied", { tarinai: this, effectId: mode, permanent: true }); + recordItemChange(this, mode, "\u80fd\u529b\u5909\u5316"); + return true; + }, + + setSizeItemMode(mode = "") { + this.ensureItemEffectState(); + if (mode !== "giant_drug" && mode !== "dwarf_drug") return false; + this.sizeItemMode = mode; + this.itemDamageTimer = Math.min(Math.max(this.itemDamageTimer || 1, 0.08), 1); + this.refreshEffectiveSize(); + this.powerEffectKind = mode; + this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 4.0); + this.spawnPowerItemEffect(mode, mode === "giant_drug" ? 1.9 : 1.2); + this.world?.emit?.("effect:applied", { tarinai: this, effectId: mode, permanent: true }); + recordItemChange(this, mode, "\u4f53\u683c\u5909\u5316"); + return true; + }, + + setMercuryEffect() { + this.ensureItemEffectState(); + if (this.lifeItemMode !== "mercury" || !Number.isFinite(this.mercuryLifeSpanBase)) { + this.mercuryLifeSpanBase = Number.isFinite(this.lifeSpan) ? this.lifeSpan : null; + this.mercuryLifeMultiplier = 1; + } + this.lifeItemMode = "mercury"; + this.mercuryLifeMultiplier = Math.max(1, (this.mercuryLifeMultiplier || 1) * 1.3); + if (Number.isFinite(this.mercuryLifeSpanBase)) this.lifeSpan = Math.max(this.age + 5, this.mercuryLifeSpanBase * this.mercuryLifeMultiplier); + this.powerEffectKind = "mercury"; + this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.7); + this.spawnPowerItemEffect("mercury", 0.95); + this.world?.emit?.("effect:applied", { tarinai: this, effectId: "mercury", permanent: true, multiplier: this.mercuryLifeMultiplier }); + recordItemChange(this, "mercury", "\u5bff\u547d\u5909\u5316"); + return true; + }, + + clearAllItemEffects() { + this.ensureItemEffectState(); + for (const key of Object.keys(this.itemEffectTimers || {})) this.itemEffectTimers[key] = 0; + this.loveMochiTimer = 0; + this.fightMochiTimer = 0; + this.powerItemMode = ""; + this.sizeItemMode = ""; + if (this.lifeItemMode === "mercury" && Number.isFinite(this.mercuryLifeSpanBase)) { + this.lifeSpan = Math.max(this.age + 5, this.mercuryLifeSpanBase); + } + this.lifeItemMode = ""; + this.mercuryLifeSpanBase = null; + this.mercuryLifeMultiplier = 1; + this.laxativePoopTimer = 3; + this.ammoBumpCooldown = 0; + this.itemDamageTimer = 1; + this.powerEffectKind = "zunda_juice"; + this.powerEffectPulse = Math.max(this.powerEffectPulse || 0, 2.2); + this.refreshEffectiveSize(); + this.spawnPowerItemEffect("zunda_juice", 1.4); + this.world?.emit?.("effect:removed", { tarinai: this, effectId: "all_removable", source: "zunda_juice" }); + recordItemChange(this, "zunda_juice", "\u52b9\u679c\u89e3\u9664"); + return true; + }, + + applyParamItemEffect(type = "", item = null, options = {}) { + this.ensureItemEffectState(); + const scale = sizeDurationScale(item); + let applied = []; + if (type === "mystery_drug") { + const picks = pickRandomEffects(2); + for (const effect of picks) this.applyRandomizedBehaviorEffect(effect, item); + this.mysteryDrugHistory = [...(this.mysteryDrugHistory || []), `${picks.map(effectLabel).join(" + ")}`].slice(-8); + this.bubble("???", 1.2, ITEM_EFFECT_COLORS.mystery_drug); + this.spawnPowerItemEffect("mystery_drug", 1.0); + applied = picks; + } else if (EFFECT_REGISTRY?.apply?.(type, this, { item, scale, source: options.source || "item", nutrition: options.nutrition })) { + applied.push(type); + if (type === "laxative") { + this.spawnPowerItemEffect("laxative", 0.75); + } else if (type === "ammo") { + this.spawnPowerItemEffect("ammo", 1.1); + } + } else { + return false; + } + if (!options.silentLog && this.world?.log) { + const label = type === "mystery_drug" ? `\u602a\u3057\u3044\u85ac(${applied.map(effectLabel).join(" + ")})` : effectLabel(type); + this.world.log(`${this.name}\u306b${label}\u306e\u52b9\u679c\u304c\u51fa\u305f\u3002`, "food", { participants: [this] }); + } + this.recordChangeCause?.("\u30a2\u30a4\u30c6\u30e0\u6442\u53d6", applied.map(effectLabel).join(" + ") || effectLabel(type)); + return true; + }, + + applyRandomizedBehaviorEffect(effect = "", item = null) { + const scale = sizeDurationScale(item); + return Boolean(EFFECT_REGISTRY?.apply?.(effect, this, { item, scale, source: "mystery_drug" })); + }, + + diseaseChance(base = 0) { + const b = Math.max(0, Number(base) || 0); + if (this.powerItemMode === "protein") return b * (EFFECT_REGISTRY?.modifier?.("protein", "diseaseChanceMultiplier", 0.5) ?? 0.5); + if (this.powerItemMode === "niteropu") return b * (EFFECT_REGISTRY?.modifier?.("niteropu", "diseaseChanceMultiplier", 2.0) ?? 2.0); + return b; + }, + + outgoingDamage(amount = 0) { + let value = (Number(amount) || 0) * (this.geneticStatRatio ? this.geneticStatRatio("attack") : 1); + if (this.powerItemMode === "protein") value *= EFFECT_REGISTRY?.modifier?.("protein", "damageMultiplier", 1.75) ?? 1.75; + if (this.powerItemMode === "niteropu") value *= EFFECT_REGISTRY?.modifier?.("niteropu", "damageMultiplier", 0.5) ?? 0.5; + if (this.sizeItemMode === "giant_drug") value *= EFFECT_REGISTRY?.modifier?.("giant_drug", "damageMultiplier", 2.0) ?? 2.0; + if (this.sizeItemMode === "dwarf_drug") value *= EFFECT_REGISTRY?.modifier?.("dwarf_drug", "damageMultiplier", 0.25) ?? 0.25; + return value; + }, + + itemSpeedMultiplier() { + let value = this.hasItemEffect("ammo") ? (EFFECT_REGISTRY?.modifier?.("ammo", "speedMultiplier", 10) ?? 10) : 1; + if (this.sizeItemMode === "giant_drug") value *= EFFECT_REGISTRY?.modifier?.("giant_drug", "speedMultiplier", 1.5) ?? 1.5; + if (this.sizeItemMode === "dwarf_drug") value *= EFFECT_REGISTRY?.modifier?.("dwarf_drug", "speedMultiplier", 0.5) ?? 0.5; + return value; + }, + + applyFoodHungerRelief(amount = 0) { + if (this.hasItemEffect("laxative")) return (Number(amount) || 0) * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0); + this.hunger -= Number(amount) || 0; + return amount; + }, + + updateItemEffects(dt = 0) { + this.ensureItemEffectState(); + const step = Math.max(0, Number(dt) || 0); + this.refreshEffectiveSize(); + for (const key of Object.keys(this.itemEffectTimers)) this.itemEffectTimers[key] = Math.max(0, (this.itemEffectTimers[key] || 0) - step); + this.ammoBumpCooldown = Math.max(0, (this.ammoBumpCooldown || 0) - step); + this.powerEffectPulse = Math.max(0, (this.powerEffectPulse || 0) - step); + if (this.hasItemEffect("laxative")) { + this.laxativePoopTimer = Math.max(0, (this.laxativePoopTimer || 3) - step); + while (this.laxativePoopTimer <= 0 && this.hasItemEffect("laxative")) { + this.laxativePoopTimer += EFFECT_REGISTRY?.get?.("laxative")?.tickInterval || 3; + EFFECT_REGISTRY?.tick?.("laxative", this, step); + } + } else { + this.laxativePoopTimer = Math.min(Math.max(this.laxativePoopTimer || 3, 0.2), 3); + } + if (this.sizeItemMode === "giant_drug" || this.sizeItemMode === "dwarf_drug") { + this.itemDamageTimer = Math.max(0, (this.itemDamageTimer || 1) - step); + while (this.itemDamageTimer <= 0 && !this.dead && (this.sizeItemMode === "giant_drug" || this.sizeItemMode === "dwarf_drug")) { + this.itemDamageTimer += 1; + EFFECT_REGISTRY?.tick?.(this.sizeItemMode, this, step); + } + } else { + this.itemDamageTimer = Math.min(Math.max(this.itemDamageTimer || 1, 0.08), 1); + } + if (this.powerEffectPulse > 0.04 && Math.random() < step * 2.4) this.spawnPowerItemEffect(this.powerEffectKind || this.powerItemMode || this.sizeItemMode || this.lifeItemMode, 0.38); + if (this.powerItemMode === "protein" || this.powerItemMode === "niteropu") { + this.powerEffectKind = this.powerItemMode; + if (Math.random() < step * 0.72) this.spawnPowerItemEffect(this.powerItemMode, this.powerItemMode === "protein" ? 0.32 : 0.28); + } + if (this.hasItemEffect("ammo") && Math.random() < step * 3.0) this.spawnPowerItemEffect("ammo", 0.32); + if ((this.sizeItemMode === "giant_drug" || this.sizeItemMode === "dwarf_drug") && Math.random() < step * 1.4) this.spawnPowerItemEffect(this.sizeItemMode, 0.24); + }, + + forceLaxativePoop() { + if (!this.world || this.dead) return; + if (this.hasLodgedPinEffect?.("blocksZunchi")) { + this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; + return; + } + const side = this.facingDir ? this.facingDir() : (this.facing || 1); + const x = this.x - side * this.radius * rand(0.72, 0.96); + const y = this.y + this.radius * rand(0.25, 0.48); + this.world.spawnZunchi?.(x, y); + this.poopCount = (this.poopCount || 0) + 1; + this.digest = Math.max(0, (this.digest || 0) - 3.0); + const now = this.world?.time || 0; + if (now >= (this.nextPoopBubbleAt || -999)) { + this.nextPoopBubbleAt = now + 2.2; + this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative); + } + if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) { + this.nextLaxativeLogAt = (this.world.time || 0) + 9; + this.world.log?.(`${this.name}\u306f\u4e0b\u5264\u306e\u52b9\u679c\u3067\u305a\u3093\u3061\u3092\u653e\u51fa\u3057\u305f\u3002`, "accident", { participants: [this] }); + } + }, + + spawnPowerItemEffect(kind = "", intensity = 1) { + if (!this.world?.effects) return; + const color = kind === "protein" + ? "rgba(224,54,40,0.88)" + : (kind === "niteropu" ? "rgba(116,80,190,0.84)" : (ITEM_EFFECT_COLORS[kind] || "rgba(255,242,160,0.85)")); + const n = Math.max(1, Math.round((kind === "protein" || kind === "niteropu" ? 3 : 2) * intensity)); + for (let i = 0; i < n; i++) { + const angle = rand(0, Math.PI * 2); + const r = this.radius * rand(0.18, 0.82); + const type = kind === "niteropu" ? "zunchi_miasma" : (kind === "ammo" ? "fall" : (kind === "protein" ? "fall" : "ring")); + const startY = kind === "protein" ? this.y + this.radius * rand(0.1, 0.55) : (kind === "niteropu" ? this.y - this.radius * rand(0.1, 0.70) : this.y + Math.sin(angle) * r * 0.55); + const vy = kind === "protein" ? -rand(38, 82) * intensity : (kind === "niteropu" ? rand(26, 68) * intensity : Math.sin(angle) * rand(8, 26) * intensity - rand(10, 28) * (kind === "giant_drug" || kind === "mercury" ? 1 : 0.35)); + this.world.effects.push(new Effect(type, this.x + Math.cos(angle) * r, startY, { + vx: (kind === "protein" || kind === "niteropu") ? rand(-18, 18) * intensity : Math.cos(angle) * rand(8, 34) * intensity, + vy, + size: rand(5, 11) * (0.75 + intensity * 0.30), + life: rand(0.42, 0.90), + color, + })); + } + }, + + ammoCollisionKnockback(dt = 0) { + if (!this.hasItemEffect("ammo") || !this.world || this.dead || (this.ammoBumpCooldown || 0) > 0) return; + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed < 120) return; + const nx = (this.vx || 1) / Math.max(speed, 1); + const ny = (this.vy || 0) / Math.max(speed, 1); + const hitRadius = this.radius + 18; + for (const o of this.world.nearbyTarinai?.(this.x, this.y, hitRadius + 28, true) || []) { + if (!o || o === this || o.dead) continue; + const d = distXY(this.x, this.y, o.x, o.y); + if (d > this.radius + o.radius + 12) continue; + const px = d > 0.01 ? (o.x - this.x) / d : nx; + const py = d > 0.01 ? (o.y - this.y) / d : ny; + const push = clamp(speed * 2.2, 260, 820); + o.impulseVx = (o.impulseVx || 0) + px * push; + o.impulseVy = (o.impulseVy || 0) + py * push; + o.vx += px * 160; + o.vy += py * 160; + o.hurtTimer = Math.max(o.hurtTimer || 0, 1.1); + o.fallTimer = Math.max(o.fallTimer || 0, 0.72); + const bumpDamage = Math.max(0.6, Math.min(3.0, speed / 260)); + o.damage?.(bumpDamage, "\u5f3e\u85ac\u885d\u7a81"); + this.adjustPersonality?.("aggression", 0.012, "after ammo knockback"); + this.world.spawnFallEffect?.(o.x, o.y + o.radius * 0.45, 1.05); + audio.damage?.(12); + this.ammoBumpCooldown = 0.12; + return; + } + for (const ant of this.world.nearbyAnts?.(this.x, this.y, hitRadius + 22, true) || []) { + if (!ant || ant.dead) continue; + const d = distXY(this.x, this.y, ant.x, ant.y); + if (d > this.radius + (ant.r || 4) + 12) continue; + const px = d > 0.01 ? (ant.x - this.x) / d : nx; + const py = d > 0.01 ? (ant.y - this.y) / d : ny; + const push = clamp(speed * 1.7, 180, 620); + ant.vx = (ant.vx || 0) + px * push; + ant.vy = (ant.vy || 0) + py * push; + ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? 32) - 4); + ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.2); + this.world.spawnFallEffect?.(ant.x, ant.y, 0.65); + audio.antDie?.(); + this.ammoBumpCooldown = 0.10; + return; + } + }, + + activeItemEffectSummary() { + this.ensureItemEffectState(); + if (EFFECT_REGISTRY?.activeSummary) return EFFECT_REGISTRY.activeSummary(this); + return []; + }, + })); + + global.TARINAI_ITEM_EFFECT_LABELS = ITEM_EFFECT_LABELS; + global.TARINAI_ITEM_EFFECT_COLORS = ITEM_EFFECT_COLORS; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js new file mode 100644 index 0000000..fd65450 --- /dev/null +++ b/js/tarinai_needs_items.js @@ -0,0 +1,1131 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js"); + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + canEatItemType(type) { + const t = String(type || ""); + if (!this.isZunchiSlave) return true; + if (!t || t === "sweet" || t === "grass" || t === "water" || t === "water_bowl") return false; + if (t === "zunchi" || t === "food" || t === "ant_corpse") return true; + const foodDef = global.TarinaiFoodRegistry?.get?.(t); + return Boolean(foodDef && foodDef.interest !== "water"); + }, + + zunchiSlaveFoodTypes() { + const defs = global.TARINAI_FOOD_DEFINITIONS || {}; + const types = Object.keys(defs).filter(type => this.canEatItemType(type)); + if (!types.includes("zunchi")) types.push("zunchi"); + return types; + }, + + hasLoveMochiEffect() { + return (this.loveMochiTimer || 0) > 0.04; + }, + + hasFightMochiEffect() { + return (this.fightMochiTimer || 0) > 0.04; + }, + + zunchiSlaveSpriteId() { + if (!this.zunchiSlaveSpriteVariant) this.zunchiSlaveSpriteVariant = Math.random() < 0.5 ? "zunchi_slave" : "zunchi_slave_alt"; + return this.zunchiSlaveSpriteVariant; + }, + + becomeZunchiSlave(options = {}) { + if (this.isZunchiSlave) { + this.zunchiSlaveLocked = true; + applyZunchiSlavePersonality(this, { record: false, birth: !!options.birth }); + this.visibleSpriteId = this.zunchiSlaveSpriteId(); + return false; + } + this.formerName = this.formerName || this.name; + this.isZunchiSlave = true; + this.zunchiSlaveLocked = true; + this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; + this.birthRitualTimer = 0; + this.birthPartnerId = null; + this.birthRitualLeader = false; + this.birthRitualRole = 0; + this.target = null; + this.reproductionTimer = options.birth ? Math.max(0, this.reproductionTimer || 0) : Math.max(this.reproductionTimer || 0, CONFIG.reproductionCooldown + rand(8, 18)); + if (!options.birth) this.loveMochiTimer = 0; + this.addStress(options.birth ? 0 : 14, { threshold: 8 }); + this.hunger = Math.max(this.hunger, 40); + applyZunchiSlavePersonality(this, { birth: !!options.birth }); + this.visibleSpriteId = this.zunchiSlaveSpriteId(); + this.spriteLockUntil = 0; + this.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u72b6\u614b"); + return true; + }, + + rawSpriteId() { + if (this.dead) return this.variantSprite("fear"); + if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) return this.sunbathSpriteId ? this.sunbathSpriteId() : "sunbath_1"; + if (this.sleepDisease) return this.variantSprite("sleep"); + if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan"; + if (this.fightDisease) return "stress_dizzy"; + if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak"; + if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); + if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; + if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; + if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat"; + if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt"); + if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual"; + const low = this.lowHealthSprite(); + if (low === "stretch") return low; + if (this.hunger >= 70 && !low) return "hungry_70"; + if (this.defeatedTimer > 0.04) return this.variantSprite("flee"); + if (this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); + if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear"); + if (this.state === "fight" || this.fightTimer > 0.04) return "angry"; + if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); + if (low) return low; + if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); + if (this.surpriseTimer > 0.08) return "pokan"; + if (this.state === "cursor_friend") return "smile"; + if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; + if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back"; + if (this.state === "seek_food") return "drool"; + if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); + if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; + if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; + return this.goodMode || this.variantSprite("normal"); + }, + + spriteId() { + const next = this.rawSpriteId(); + const now = this.world?.time || 0; + const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04; + const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04; + const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate"); + if (!this.visibleSpriteId || forceImmediate) { + this.visibleSpriteId = next; + this.spriteLockUntil = forceImmediate ? now : now + 1.0; + return next; + } + if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) { + this.visibleSpriteId = next; + this.spriteLockUntil = now + 1.0; + } + return this.visibleSpriteId; + }, + + isAwakeCursorFriendly() { + if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false; + if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false; + if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false; + if (this.mood < 46 || this.stress > 64) return false; + return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); + }, + + sunbathSpriteId() { + if (!this.sunbathSpriteVariant) this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + return `sunbath_${this.sunbathSpriteVariant}`; + }, + + isSunbathTime() { + const weatherOk = (this.world?.weather || "") === "sunny"; + const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24); + return weatherOk && hour >= 6 && hour <= 15.5; + }, + + canStartSunbath() { + if (this.dead || this.sleepDisease || this.sleeping) return false; + if (!this.isSunbathTime()) return false; + if ((this.sunbathCooldown || 0) > 0) return false; + if (this.hasLodgedPinEffect?.("blocksZunchi")) return false; + if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; + if (this.hunger > 68 || this.energy < 18) return false; + return true; + }, + + tryRecoverBySunbath() { + if (this.sunbathRecoveryChecked) return false; + this.sunbathRecoveryChecked = true; + const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; + if (!sick || Math.random() >= 0.75) return false; + let healed = false; + if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("日光浴でずんち病が治まった") || healed; + if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("日光浴でねむり病が治った") || healed; + if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("日光浴で爆発病が治った") || healed; + if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("日光浴できずつき病が治った") || healed; + if (healed) this.world?.log?.(`${this.name}は日光浴で病気が軽くなった。`, "event", { participants: [this] }); + return healed; + }, + + startSunbath() { + if (!this.canStartSunbath()) return false; + this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false }); + this.sunbathTimer = rand(12, 24); + this.sunbathCooldown = rand(8, 18); + this.sunbathFrameTimer = 0; + this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + this.sunbathRecoveryChecked = false; + this.tryRecoverBySunbath(); + if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("ぽかぽか", 4.0, "rgba(112,86,36,0.78)"); + return true; + }, + + updateSunbath(dt) { + if ((this.sunbathTimer || 0) <= 0) return; + this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt); + this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt); + if ((this.sunbathFrameTimer || 0) <= 0) { + this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + this.sunbathFrameTimer = rand(0.55, 1.15); + } + this.stress = clamp((this.stress || 0) - dt * 1.15, 0, 130); + this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); + if (!this.isSunbathTime() || this.hunger > 86 || this.energy < 8 || this.state === "panic") this.sunbathTimer = 0; + if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { + this.goIdle(""); + } + }, + + applyCursorContactCare(dt) { + const pointer = this.world?.pointer; + if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; + const d = distXY(this.x, this.y, pointer.x, pointer.y); + const touch = this.radius * 1.35; + if (d > touch) return; + const p = clamp(1 - d / touch, 0, 1); + const relief = dt * (1.35 + 2.65 * p); + this.stress = clamp((this.stress || 0) - relief, 0, 130); + this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); + this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); + this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); + this.goodMode = "smile"; + if ((this.world.time || 0) >= (this.nextCursorHeartAt || 0)) { + this.nextCursorHeartAt = (this.world.time || 0) + Math.max(0.08, 0.22 - p * 0.10); + for (let i = 0; i < (p > 0.62 ? 3 : 2); i++) { + this.world.effects?.push(new Effect("heart", this.x + rand(-this.radius * 0.58, this.radius * 0.58), this.y - this.radius * rand(0.68, 1.58), { + vx: rand(-16, 16), vy: rand(-44, -20), size: rand(7, 13) * (0.8 + p * 0.45), life: rand(0.62, 0.95), color: "rgba(240,91,135,0.92)" + })); + } + } + }, + + update(dt) { + if (this.dead) return; + this.prevX = this.x; + this.prevY = this.y; + + const minute = dt / 60; + this.age += dt; + this.reproductionTimer -= dt; + this.blink += dt; + this.eatTimer = Math.max(0, this.eatTimer - dt); + this.eatCooldown = Math.max(0, this.eatCooldown - dt); + this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); + this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); + this.fearTimer = Math.max(0, this.fearTimer - dt); + this.intimidateTimer = Math.max(0, this.intimidateTimer - dt); + this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt); + this.panicTimer = Math.max(0, (this.panicTimer || 0) - dt); + this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt); + this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt); + this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt); + this.stressBarTimer = Math.max(0, (this.stressBarTimer || 0) - dt); + this.updateZunchiDisease(dt); + this.updateSpecialDiseases(dt); + if (this.updateItemEffects) this.updateItemEffects(dt); + if (this.dead) return; + this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); + this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); + if (this.fightMochiTimer > 0.04) { + this.stress = clamp(this.stress + dt * 0.11, 0, 130); + if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); + } + if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; + if (this.birthRitualTimer > 0) { + const beforeBirthTimer = this.birthRitualTimer; + this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); + if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + if (partner) this.world.finishBirthRitual(this, partner); + this.birthRitualLeader = false; + } + } + this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); + this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); + this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); + this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt); + this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt); + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + this.applyCursorContactCare(dt); + const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; + if (colonyMoodId === "weaklings" && !this.isZunchiSlave) this.stress = clamp((this.stress || 0) - dt * 0.28, 0, 130); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") this.stress = clamp((this.stress || 0) - dt * 0.34, 0, 130); + if (colonyMoodId === "ant_overrun" && this.state !== "panic") { + const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); + if (antSeen) window.TarinaiNeedSystem?.addNeedShock?.(this, { safety: 18 }, null); + } + const wasFighting = this.fightTimer > 0.04; + this.fightTimer = Math.max(0, this.fightTimer - dt); + this.fightCooldown = Math.max(0, this.fightCooldown - dt); + this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); + if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { + const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null; + this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); + this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); + this.hurtTimer = Math.max(this.hurtTimer, 1.8); + this.fallTimer = Math.max(this.fallTimer, 1.15); + this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer); + this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir; + this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45); + if (winner) this.world.resolveFightOutcome(winner, this); + this.fightTargetIds = []; + this.fightTargetId = null; + } + this.stretchTimer = Math.max(0, this.stretchTimer - dt); + this.grassEatTimer = Math.max(0, this.grassEatTimer - dt); + this.hurtTimer = Math.max(0, this.hurtTimer - dt); + this.fallTimer = Math.max(0, this.fallTimer - dt); + if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { + this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); + } + if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { + this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); + } + if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { + const side = this.facingDir(); + this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); + } + + this.tempTimer += dt; + if (this.tempTimer >= 0.55) { + this.tempComfort = this.world.temperatureAt(this.x, this.y); + this.tempTimer = 0; + } + const tempComfort = this.tempComfort ?? 0.62; + const tooCold = Math.max(0, 0.48 - tempComfort); + this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); + this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); + this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); + this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; + this.stress += tooCold * dt * 0.25; + + const phase = this.world.lightLevel(); + if (phase < 0.25) this.energy += dt * 0.07; + if (phase > 0.75) this.stress += dt * 0.04; + if (this.world.weather === "cloudy") { + this.stress -= dt * 0.018; + this.vx *= Math.pow(0.996, dt * 60); + this.vy *= Math.pow(0.996, dt * 60); + } else if (this.world.weather === "light_rain") { + this.energy += dt * 0.018; + this.stress -= dt * 0.025; + } else if (this.world.weather === "sunny") { + this.vx *= Math.pow(1.001, dt * 60); + this.vy *= Math.pow(1.001, dt * 60); + } + + if (this.generation > 1 && this.syncGrowthScale) this.syncGrowthScale(); + + this.hunger = clamp(this.hunger, 0, 115); + this.loneliness = clamp(this.loneliness, 0, 110); + this.energy = clamp(this.energy, 0, 100); + this.stress = clamp(this.stress, 0, 130); + + const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; + this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); + + if (this.freezeSleepDiseaseMotion(dt)) { + this.checkLife(dt); + return; + } + + this.aiTimer += dt; + const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; + const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; + const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055); + if (this.aiTimer >= aiInterval) { + const aiDt = Math.min(this.aiTimer, 0.48); + this.aiTimer = 0; + this.resolveNeeds(aiDt); + const sleepingNow = this.state === "sleep"; + if (!sleepingNow) { + this.reactToFreshZunchi(aiDt); + this.updateFacing(); + this.interactWithItems(aiDt); + this.interactWithOthers(aiDt); + } else { + this.updateFacing(); + } + } else { + this.updateFacing(); + } + this.envTimer += dt; + const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48; + if (this.envTimer >= envInterval) { + const envDt = Math.min(this.envTimer, 1.2); + this.envTimer = 0; + this.applyEnvironment(envDt); + } + this.updateSunbath(dt); + this.maintainTargetProgress(dt); + this.move(dt); + if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); + this.updateNestBoxPresence(dt); + if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this); + const sleepingAtFrameEnd = this.state === "sleep" || this.sleeping; + if (wasSleepingAtFrameStart && !sleepingAtFrameEnd && !this.dead) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); + if (this.needsInitialized && window.TarinaiNeedSystem?.deriveStressFromNeeds) this.stress = window.TarinaiNeedSystem.deriveStressFromNeeds(this.needs || {}); + this.checkLife(dt); + }, + + applyEnvironment(dt) { + const parent = this.parentToFollow ? this.parentToFollow() : null; + if (parent && dist(this, parent) < 96) { + this.stress = clamp(this.stress - dt * 0.42, 0, 130); + this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); + } + const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; + const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + if (friend) { + this.stress = clamp(this.stress - dt * 0.36, 0, 130); + this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); + } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { + this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); + } + let grassComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 105)) { + if (it.type !== "grass" || it.dead) continue; + const d = distXY(this.x, this.y, it.x, it.y); + const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2); + grassComfort += clamp(1 - d / 105, 0, 1) * lush; + } + let nestComfort = 0; + for (const it of this.world.nearbyItems(this.x, this.y, 120)) { + if (!it || it.dead || it.type !== "nest_box") continue; + nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); + } + if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130); + if (grassComfort > 0) { + this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); + if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); + if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); + } + }, + + bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") { + if (this.world.time < this.nextBubbleAt) return; + this.nextBubbleAt = this.world.time + cooldown; + this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); + }, + + mouthPosition(target = null) { + const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir(); + return { + x: this.x + dir * this.radius * 0.40 - this.radius * 0.34, + y: this.y - this.radius * 0.28, + }; + }, + + isSleepFurniture(target) { + return Boolean(target && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))); + }, + + sleepFurnitureLabel(target) { + return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; + }, + + isServingFoodItem(it) { + return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); + }, + + foodItemHasServingLeft(it) { + if (!it || it.dead) return false; + if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; + this.ensureFoodServings(it); + return (it.foodServingsRemaining || 0) > 0; + }, + + ensureFoodServings(it) { + if (!this.isServingFoodItem(it)) return; + const max = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize || "medium") : 5; + if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max; + if (!Number.isFinite(it.foodServingsRemaining)) { + const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62; + const ratio = clamp((it.amount ?? original) / original, 0.05, 1); + it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio)); + } + it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax); + it.amount = it.foodServingsRemaining; + }, + + consumeFoodServing(it, nutrition) { + this.ensureFoodServings(it); + if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; + const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0); + if (!it.infiniteServing) { + it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); + it.amount = it.foodServingsRemaining; + } else { + it.foodServingsRemaining = Math.max(it.foodServingsRemaining || 0, it.foodServingsMax || 1); + it.amount = it.foodServingsRemaining; + } + this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value }); + return value; + }, + + foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) { + const relief = window.TarinaiFoodRegistry?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier; + return (Number(nutrition) || 0) * relief; + }, + + sleepSpotFor(bed) { + if (!bed) return { x: this.x, y: this.y }; + if (bed.type === "nest_box") { + if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this); + if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); + return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; + } + const angle = stableUnit(this.familyKey || this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; + const baseRing = bed.r * 0.52; + const ring = baseRing + stableUnit(this.familyKey || this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95; + return { + x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), + y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding), + }; + }, + + desiredFacingDir() { + if (this.state === "sleep") return this.sleepFacing || this.facing || 1; + if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1; + if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx); + return this.facing || 1; + }, + + facingDir() { + return this.visualFacing || this.facing || 1; + }, + + updateFacing() { + const desired = this.desiredFacingDir(); + const now = this.world?.time || 0; + this.facing = desired; + if (!this.visualFacing) { + this.visualFacing = desired; + this.facingLockUntil = now + 1.0; + return; + } + if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) { + this.visualFacing = Math.sign(desired) || this.visualFacing || 1; + this.facingLockUntil = now + 1.0; + } + }, + + reactToFreshZunchi(dt) { + if (this.isZunchiSlave) return; + if (this.state === "panic" || this.fearTimer > 0.2) return; + const z = this.world.nearest(this, ["zunchi"], 90); + if (!z || z.stage !== "fresh") return; + const d = dist(this, z); + const push = clamp(1 - d / 90, 0, 1); + this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); + if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); + this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; + this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; + if (push > 0.35 && (this.state === "idle" || !this.thought)) this.thought = "新鮮なずんちを避けている"; + }, + + resolveNeeds(dt) { + const wasSleeping = this.state === "sleep"; + const previousTarget = this.target && !this.target.dead ? this.target : null; + this.target = null; + this.sleeping = false; + + if (this.birthRitualTimer > 0.04) { + this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "近くで体をゆすっている" }); + return; + } + + if (this.intimidateTimer > 0.04) { + this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "相手を威嚇している" }); + this.bubble("!", 1.2); + return; + } + + if (this.fightTimer > 0.04) { + this.setActionState?.("fight", { reason: "喧嘩している" }); + this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); + this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; + let best = null, bestD = Infinity; + for (const id of this.fightTargetIds) { + const t = this.world.tarinai.find(o => o.id === id && !o.dead); + if (!t) continue; + const d = dist(this, t); + if (d < bestD) { best = t; bestD = d; } + } + this.setActionState?.("fight", { target: best, reason: "喧嘩している" }); + this.bubble("!", 1.6); + return; + } + + if (this.defeatedTimer > 0.04) { + const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); + if (this.enterPanic) this.enterPanic({ threat: opponent, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.0, wake: true, cause: "defeated_escape" }); + else this.setActionState?.("panic", { target: this.panicDestination(opponent), reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); + if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; + return; + } else if (this.defeatedById && this.fightTimer <= 0.04) { + this.defeatedById = null; + this.fightWinnerId = null; + } + + if (this.stuckPushpinId) { + const pin = this.currentLodgedPin?.() || null; + const pinBehavior = this.currentLodgedPinBehavior?.() || null; + if (pin && pinBehavior?.panicOnAttach) { + window.TarinaiNeedSystem?.addNeedShock?.(this, { safety: 45, health: 20 }, pin); + this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); + this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); + this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); + this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u3044\u308b"; + } + this.stuckPushpinId = null; + } + + if ((this.panicTimer || 0) > 0 && this.state === "panic") { + this.setActionState?.("panic", { + target: previousTarget || this.panicDestination?.(null, true) || null, + reason: this.intent?.reasonText || "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", + wake: true, + }); + return; + } + + if (window.TarinaiNeedSystem?.runNeedDecision) { + window.TarinaiNeedSystem.runNeedDecision(this, this.world, dt); + return; + } + + const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; + const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || []) + .find(o => o && o !== this && !o.dead && !this.world?.isTarinaiHiddenInNestBox?.(o) && o.state === "panic" && (o.fearTimer || 0) > 0.12); + if (panicSource && !["panic", "sleep", "seek_bed", "fight", "birth_ritual", "eat", "sunbath"].includes(this.state)) { + const d = distXY(this.x, this.y, panicSource.x, panicSource.y); + const neuro = typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (this.currentPersonality?.neuroticism || 0); + const distFactor = clamp(1 - d / 128, 0, 1); + const stressFactor = clamp(((this.stress || 0) - 35) / 80, 0, 0.22); + const chance = clamp(0.10 + Math.max(0, neuro) * 0.36 + distFactor * 0.18 + stressFactor, 0.05, 0.76); + if (Math.random() < chance) { + if (this.enterPanic) this.enterPanic({ threat: panicSource.target || panicSource, reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, fear: 0.75, stress: 3 + distFactor * 5, stressThreshold: 6, cause: "nearby_panic_chain" }); + else { this.setActionState?.("panic", { target: this.panicDestination(panicSource.target || panicSource), reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.75 * this.personalityProfile().fear); this.addStress ? this.addStress(3 + distFactor * 5, { threshold: 6 }) : (this.stress = clamp((this.stress || 0) + 3 + distFactor * 5, 0, 130)); } + return; + } + } + + const nearbyAnts = this.world.nearbyAnts?.(this.x, this.y, 112, true) || []; + let nearestAnt = null; + let nearestAntDist = Infinity; + for (const ant of nearbyAnts) { + const ad = distXY(this.x, this.y, ant.x, ant.y); + if (ad < nearestAntDist) { + nearestAnt = ant; + nearestAntDist = ad; + } + } + if (nearestAnt) { + const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1); + if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { + if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { + // Intimidation has no effect on ants. This is only a visible reaction. + this.setActionState?.("ant_intimidate", { target: nearestAnt, reason: "アリを威嚇している" }); + this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); + return; + } + this.setActionState?.("ant_attack", { target: nearestAnt, reason: "アリを攻撃している" }); + this.goodMode = "angry"; + return; + } + if (this.enterPanic) this.enterPanic({ threat: nearestAnt, reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", fear: 0.34, wake: true, cause: "ant_seen" }); + else this.setActionState?.("panic", { target: this.panicDestination(nearestAnt), reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", wake: true }); + return; + } + + if (this.canStartSunbath?.()) { + const moodBonus = (this.world?.colonyMood?.id === "sun_party") ? 0.72 : 0; + const sickBonus = (this.zunchiDisease || this.explosionDisease || this.fightDisease) ? 0.48 : 0; + const stressBonus = this.stress > 28 ? 0.22 : 0; + const idleBonus = this.state === "idle" ? 0.18 : 0; + if (Math.random() < dt * (0.34 + moodBonus + sickBonus + stressBonus + idleBonus)) { + if (this.startSunbath()) return; + } + } + + const dayP = this.world.dayProgress(); + const personalSleep = this.sleepStart < this.sleepEnd + ? dayP >= this.sleepStart && dayP < this.sleepEnd + : dayP >= this.sleepStart || dayP < this.sleepEnd; + + if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o)); + if (opponent) { + const d = dist(this, opponent); + if (d <= Math.max(48, this.radius + opponent.radius + 18)) { + if (this.world.startForcedFight?.(this, opponent)) return; + } + this.setActionState?.("seek_friend", { target: opponent, reason: "けんか餅で相手を探している" }); + this.goodMode = "angry"; + if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); + return; + } + } + + const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; + const feared = this.strongestRelation("fear"); + if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { + const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); + if (rival && dist(this, rival) < 150) { + if (this.enterPanic) this.enterPanic({ threat: rival, reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, fear: 0.35, wake: true, cause: "avoid_rival" }); + else this.setActionState?.("panic", { target: this.panicDestination(rival), reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, wake: true }); + this.world.relationNotice(this.id, rival.id, "avoid", 22); + return; + } + } + + if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const water = this.world.nearest(this, ["water", "water_bowl"], 760); + if (water && !water.dead) { + this.setActionState?.("seek_water", { target: water, reason: this.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している" }); + return; + } + if (this.zunchiDisease) { + this.setActionState?.("zunchi_sick", { target: null, reason: "ずんち病で当てもなく彷徨っている" }); + if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); + return; + } + } + + if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + const cureFood = this.world.nearest(this, ["sweet"], 700); + if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { + this.setActionState?.("seek_food", { target: cureFood, reason: this.fightDisease ? "きずつき病でずんだ餅を探している" : "爆発病でずんだ餅を探している" }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); + return; + } + } + + if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { + this.setActionState?.("fight_sick", { target: null, reason: "きずつき病で当てもなく彷徨っている" }); + if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); + return; + } + + if (!this.isZunchiSlave) { + const medicineTypes = window.TarinaiFoodRegistry?.typesByInterest?.("medicine_low") || ["love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]; + const specialMochi = this.world.nearest(this, medicineTypes, wasSleeping ? 190 : 155); + const specialDist = specialMochi ? distXY(this.x, this.y, specialMochi.x, specialMochi.y) : Infinity; + const weakInterest = this.hunger > (wasSleeping ? 68 : 76) && (specialDist < 72 || Math.random() < dt * 0.028); + if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) { + this.setActionState?.("seek_food", { target: specialMochi, reason: `${toolLabel(specialMochi.type)}が少し気になっている` }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); + return; + } + } else if (this.hunger > 20) { + const zOnly = this.world.nearest(this, ["zunchi"], 520); + if (zOnly && zOnly.amount > 1.2) { + this.setActionState?.("seek_food", { target: zOnly, reason: "ずんちを探している" }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); + return; + } + } + + const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; + const zunda = this.world.nearest(this, ["sweet"], zundaRange); + const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); + const zundaReward = this.affection > 8 && this.hunger > 7; + if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { + this.setActionState?.("seek_food", { target: zunda, reason: "ずんだ餅を探している" }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); + return; + } + + if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { + if (this.enterPanic) this.enterPanic({ reason: "\u9003\u3052\u3066\u3044\u308b", fear: 0.65, wake: true, cause: "fear_or_stress" }); + else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u9003\u3052\u3066\u3044\u308b", wake: true }); + return; + } + + const sleepBias = this.personalityProfile().sleep; + const wakeLocked = (this.awakeLockTimer || 0) > 0 && !wasSleeping; + const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); + if (!wakeLocked && (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias)) { + let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; + if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; + if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); + if (bed && !wasSleeping && this.energy > 18) { + const spot = this.sleepSpotFor(bed); + if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { + this.setActionState?.("seek_bed", { target: bed, reason: `${this.sleepFurnitureLabel(bed)}へ向かっている` }); + return; + } + } + this.startSleeping?.(bed, bed ? (bed.type === "nest_box" ? "巣箱の中で眠っている" : "ベッドの近くで眠っている") : "眠っている"); + if (this.world.time > this.nextSleepBubbleAt) { + this.nextSleepBubbleAt = this.world.time + 4.5; + this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + this.thought = bed ? (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b") : "\u7720\u3063\u3066\u3044\u308b"; + return; + } + + const pointer = this.world.pointer; + if (pointer?.inside) { + const pd = distXY(this.x, this.y, pointer.x, pointer.y); + if (pd < 170) { + const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; + const friendly = !hostile && this.isAwakeCursorFriendly(); + if (hostile) { + this.setActionState?.("cursor_enemy", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルを避けている" }); + this.fearTimer = Math.max(this.fearTimer, 0.18); + if (this.world.time > this.nextFearBubbleAt) { + this.nextFearBubbleAt = this.world.time + 2.4; + this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); + } + this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; + return; + } + if (friendly) { + this.setActionState?.("cursor_friend", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルについていく" }); + this.goodMode = "smile"; + this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; + return; + } + } + } + + const playTrait = this.personalityProfile().play ?? 1; + const openPlay = this.shouldApplyPersonalityBehavior("openness", 1); + const closedPlay = this.shouldApplyPersonalityBehavior("openness", -1); + const ballRange = openPlay ? 540 : (closedPlay ? 170 : 240 + 60 * playTrait); + const ball = this.world.nearest(this, ["ball"], ballRange); + if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { + const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0; + const baseChance = openPlay ? 0.74 : (closedPlay ? 0.025 : 0.08); + const movingBonus = clamp(ballSpeed / 120, 0, 0.42); + const socialBonus = this.loneliness > 48 ? 0.10 : 0; + if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { + this.setActionState?.("play_ball", { target: ball, reason: openPlay ? "ボールに引き寄せられている" : "転がるものが気になっている" }); + this.goodMode = "smile"; + return; + } + } + + const parent = this.parentToFollow(); + if (parent && !this.shouldAvoidTarget(parent)) { + const d = dist(this, parent); + if (d > 34 || (this.hunger < 78 && this.energy > 24)) { + this.setActionState?.("follow_parent", { target: parent, reason: "親についていく" }); + this.bubble("?", 4.0); + return; + } + } + + if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? this.canEatItemType(friendNearby.target.type) : ["sweet", "grass", "ant_corpse"].includes(friendNearby.target.type)) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { + this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); + return; + } + + if (this.hunger > 62) { + const itemTypes = this.isZunchiSlave ? this.zunchiSlaveFoodTypes() : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; + const item = this.world.nearest(this, itemTypes, 420); + if (item) { + this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" }); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); + return; + } + } + + const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1); + const lonerSeek = this.shouldApplyPersonalityBehavior("sociability", -1); + if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { + const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); + if (other) { + this.setActionState?.("seek_friend", { target: other, reason: "仲間を探している" }); + return; + } + } + + if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { + if (this.enterPanic) this.enterPanic({ reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.55, wake: true, cause: "stress_panic" }); + else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + return; + } + + if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); + this.goIdle(`${this.need}がたりない`); + }, + + makePoop(force = 1) { + this.digest += force; + if (this.digest < 12.4) return; + if (this.hasLodgedPinEffect?.("blocksZunchi")) { + this.digest = rand(0.4, 1.6); + this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; + if ((this.oshiriByoZunchiStock || 0) >= 6) this.stress = clamp((this.stress || 0) + 1.8, 0, 130); + return; + } + this.digest = rand(0.4, 1.6); + this.poopCount += 1; + const side = this.facingDir(); + const backX = this.x - side * this.radius * rand(0.72, 0.96); + const backY = this.y + this.radius * rand(0.25, 0.48); + this.world.spawnZunchi(backX, backY); + const now = this.world?.time || 0; + if (now >= (this.nextPoopBubbleAt || -999)) { + this.nextPoopBubbleAt = now + 2.8; + this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)"); + } + if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] }); + }, + + interactWithItems(dt) { + const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18; + const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7; + const slaveCanFoodBite = this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 16; + const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104); + for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { + const d = dist(this, it); + if (d > this.radius + it.r + 12) continue; + + if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && (canBite || (slaveCanFoodBite && this.canEatItemType(it.type))))) && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it); + if (eating > 0) { + this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.vx *= Math.pow(0.25, dt * 60); + this.vy *= Math.pow(0.25, dt * 60); + const hungerRelief = this.foodHungerReliefFor(it.type, eating, it.type === "sweet" ? 0.75 : 0.95); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); + this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); + this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; + if (it.type === "sweet") { + this.stress = clamp(this.stress - eating * 0.42, 0, 130); + this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); + if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + this.eatTimer = Math.max(this.eatTimer, 0.32); + this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); + this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); + if (Math.random() < dt * 2.2) { + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7"); + } + this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7"; + this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22)); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } + if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { + this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); + this.lastLog = this.world.time; + } + break; + } + } + + if ((canSweetBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it); + if (eating <= 0) continue; + this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.vx *= Math.pow(0.25, dt * 60); + this.vy *= Math.pow(0.25, dt * 60); + const hungerRelief = this.foodHungerReliefFor(it.type, eating, 0.82); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); + this.recoverHealth(eating * 0.24); + this.affection += eating * 0.04; + const mealColors = { + love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", + protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", + mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f", + }; + const mealBubbles = { + love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"], + fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"], + sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"], + }; + if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it, { + source: "eating", + nutrition: eating, + silentLog: it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug", + }); + if (mealBubbles[it.type]) this.bubble(...mealBubbles[it.type]); + this.lastMealColor = mealColors[it.type] || "#41aa66"; + this.eatTimer = Math.max(this.eatTimer, 0.30); + this.eatCooldown = rand(0.38, 0.68); + this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); + if (Math.random() < dt * 2.2) { + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : (it.type === "fight_mochi" ? "#e07c43" : (it.type === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); + } + this.makePoop(eating * 0.20); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } + if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { + this.world.log(`${this.name}\u306f${toolLabel(it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); + this.lastLog = this.world.time; + } + break; + } + + if (canBite && it.type === "grass") { + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); + this.beginEating(it, "草を食べている"); + this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); + this.vx *= Math.pow(0.20, dt * 60); + this.vy *= Math.pow(0.20, dt * 60); + if (!it.infiniteServing) { + it.amount -= bite; + it.eatenAmount = (it.eatenAmount || 0) + bite; + it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); + } else { + it.amount = Math.max(it.amount || 0, 120); + it.growth = Math.max(it.growth || 0, 0.82); + } + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); + this.recoverHealth(bite * 0.18); + this.stress -= dt * 0.5; + this.eatTimer = Math.max(this.eatTimer, 0.22); + this.eatCooldown = rand(0.68, 1.15); + this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); + if (Math.random() < dt * 2.0) { + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); + } + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } + this.makePoop(bite * 0.10); + if (this.type === "jito") this.affection += dt * 0.25; + break; + } + + if ((canBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && it.type === "ant_corpse") { + const bite = Math.min(it.amount, rand(3.2, 5.4)); + if (bite > 0) { + this.beginEating(it, "アリの死骸を食べている"); + this.vx *= Math.pow(0.18, dt * 60); + this.vy *= Math.pow(0.18, dt * 60); + if (!it.infiniteServing) it.amount -= bite; + else it.amount = Math.max(it.amount || 0, 24); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); + this.recoverHealth(bite * 0.12); + this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); + this.eatTimer = Math.max(this.eatTimer, 0.26); + this.eatCooldown = rand(0.75, 1.25); + this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); + this.lastMealColor = "#4a3f36"; + this.makePoop(bite * 0.08); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; } + break; + } + } + + if (it.type === "zunchi") { + const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); + if (!this.isZunchiSlave) { + this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); + if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); + } + this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); + if (canZunchiBite) { + const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); + this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている"); + this.vx *= Math.pow(0.18, dt * 60); + this.vy *= Math.pow(0.18, dt * 60); + if (!it.infiniteServing) it.amount -= bite; + else it.amount = Math.max(it.amount || 0, 240); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18)); + this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); + this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); + if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); + this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); + this.lastMealColor = "#6b8d3f"; + this.eatTimer = Math.max(this.eatTimer, 0.24); + this.eatCooldown = rand(0.9, 1.4); + this.bubble(this.isZunchiSlave ? "\u3082\u3050" : "...", 2.2, "rgba(74,91,50,0.78)"); + const mouth = this.mouthPosition(it); + this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); + if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } + break; + } + } + + if (it.type === "water" || it.type === "water_bowl") { + this.thought = it.type === "water_bowl" ? "水の皿で水を飲んでいる" : "水を浴びて落ち着いている"; + this.target = it; + this.applyWaterEffect(dt, it.type); + if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { + audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); + this.nextDrinkSound = (this.world?.time || 0) + 0.72; + } + if (it.type === "water" && !it.infiniteServing) it.amount -= dt * 2.4; + if (it.type === "water" && it.infiniteServing) it.amount = Math.max(it.amount || 0, 120); + if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18; + } + + if (it.type === "stone") { + if (this.state === "idle" && (this.need === "あたたかさ" || this.stress > 48)) this.thought = "石の近くで落ち着いている"; + this.stress -= dt * 0.85; + this.energy += dt * 0.18; + if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; + } + + if (this.isSleepFurniture(it)) { + if (this.energy < 74 + 6 * this.personalityProfile().sleep) { + const occ = this.world.bedOccupancy(it); + const comfort = this.world.bedComfort(it); + this.startSleeping?.(it, it.type === "nest_box" ? "巣箱の中で休んでいる" : "寝床の近くで休んでいる"); + if (this.world.time > this.nextSleepBubbleAt) { + this.nextSleepBubbleAt = this.world.time + 4.5; + const crowded = occ > 5; + this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } + this.energy += dt * (1.9 + comfort * 1.4); + this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); + it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); + } + } + + if (it.type === "ball") { + const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); + if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { + this.goodMode = "smile"; + this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); + this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); + if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10); + } + } + + if (it.type === "splat") { + const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); + if (touch > 0) { + this.stress = clamp(this.stress + touch * dt * 2.8 * this.personalityProfile().fear, 0, 130); + if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); + } + } + + if (it.type === "trace") { + this.loneliness -= dt * 0.25; + this.stress += dt * 0.06 * this.personalityProfile().fear; + } + } + this.hunger = clamp(this.hunger, 0, 115); + this.energy = clamp(this.energy, 0, 100); + this.stress = clamp(this.stress, 0, 130); + this.loneliness = clamp(this.loneliness, 0, 110); + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_render.js b/js/tarinai_render.js new file mode 100644 index 0000000..810cf62 --- /dev/null +++ b/js/tarinai_render.js @@ -0,0 +1,378 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_render.js"); + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + draw(ctx, t, lighting = null) { + if (this.dead) return; + const lightState = lighting || getLightingState(this.world); + const sprite = this.spriteId(); + const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type)); + const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); + const w = metrics?.w || 512; + const h = metrics?.h || 512; + const moveSpeed = Math.hypot(this.vx, this.vy); + const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8); + const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18); + const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0; + const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0; + const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0; + const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20; + const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0; + const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0; + const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0; + const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25; + const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; + const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); + const diseaseSpin = this.fightDisease ? (t * 3.6 + this.age) : 0; + const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin + diseaseSpin; + const insideCardboard = false; + const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale; + let drawW = w * visualScale * 0.30; + let drawH = h * visualScale * 0.30; + if (insideCardboard) { + drawW *= 0.72; + drawH *= 0.72; + } + if (sprite === "stretch") { + drawW *= 1.16; + drawH *= 1.24; + } + if (sprite === "intimidate") { + drawW *= 1.32; + drawH *= 1.32; + } + if (sprite === "birth_ritual") { + drawW *= 0.64; + drawH *= 0.64; + } + if (fallPose > 0) { + drawW *= 1 + fallPose * 0.08; + drawH *= 1 - fallPose * 0.07; + } + const squish = this.state === "sleep" + ? 1 + breathing * 0.018 + : 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03; + const faceRight = this.state === "sleep" + ? this.sleepFacing > 0 + : this.facingDir() > 0; + const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt; + const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1; + if (nestHideAlpha <= 0.01) return; + + const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8); + const shadowContactY = this.y + bob + (drawH / squish) * 0.30; + if (img) { + drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { + faceRight, + alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha, + widthScale: this.state === "sleep" ? 1.12 : 1, + heightScale: this.state === "sleep" ? 0.88 : 1, + }); + } else { + drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha }); + } + drawCreatureGlow(ctx, this, drawW, drawH, lightState); + if (this.powerItemMode === "protein" || this.powerItemMode === "niteropu") { + ctx.save(); + const up = this.powerItemMode === "protein"; + const baseColor = up ? "rgba(224,54,40,0.92)" : "rgba(116,80,190,0.88)"; + const glowColor = up ? "rgba(255,72,44,0.45)" : "rgba(118,80,190,0.38)"; + ctx.shadowColor = glowColor; + ctx.shadowBlur = up ? 10 : 7; + const particleCount = up ? 10 : 12; + for (let i = 0; i < particleCount; i++) { + const phase = (t * (up ? 0.92 : 0.78) + stableUnit(`${this.id || this.familyKey}:power:${i}`, "phase") * 9.7) % 1; + const side = stableUnit(`${this.id || this.familyKey}:power:${i}`, "side") * 2 - 1; + const drift = Math.sin(t * 1.7 + i * 0.91) * drawW * 0.06; + const px = this.x + side * drawW * randSeed(i + this.seed, 0.12, 0.45) + drift; + const py = up + ? this.y + drawH * 0.38 - phase * drawH * 0.94 + : this.y - drawH * 0.46 + phase * drawH * 0.98; + const fade = up ? Math.sin(phase * Math.PI) : (0.42 + 0.58 * Math.sin(phase * Math.PI)); + ctx.globalAlpha = fade * (up ? 0.78 : 0.70); + ctx.fillStyle = baseColor; + ctx.beginPath(); + ctx.ellipse(px, py, Math.max(2, drawW * randSeed(i + 83, 0.025, 0.045)), Math.max(2, drawW * randSeed(i + 137, 0.020, 0.040)), randSeed(i + 223, -0.6, 0.6), 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = up ? 0.10 : 0.08; + ctx.strokeStyle = baseColor; + ctx.lineWidth = up ? 1.8 : 1.4; + ctx.beginPath(); + ctx.ellipse(this.x, this.y + drawH * 0.08, drawW * 0.58, drawH * 0.44, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + const hx = partner ? (this.x + partner.x) / 2 : this.x; + const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60; + const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10; + const size = Math.max(10, this.radius * 0.62) * pulse; + drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" }); + } + + ctx.save(); + ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY); + const observeKind = this.world.observeHighlightKind ? this.world.observeHighlightKind(this) : ""; + if (observeKind && this.world.selected !== this) { + const style = { + parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" }, + child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" }, + friend: { color: "rgba(240,91,135,0.90)", label: "\u53cb\u9054" }, + enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" }, + }[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" }; + ctx.save(); + ctx.globalAlpha = 0.96 * nestHideAlpha; + ctx.shadowColor = style.color; + ctx.shadowBlur = 16; + ctx.strokeStyle = style.color; + ctx.lineWidth = 5.2; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.78, drawH * 0.60, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineWidth = 2.0; + ctx.setLineDash([8, 5]); + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.86, drawH * 0.66, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + if (style.label) { + const labelY = -drawH * 0.62; + ctx.font = `${Math.max(11, this.radius * 0.52)}px Yomogi, ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const labelW = ctx.measureText(style.label).width + 16; + ctx.fillStyle = "rgba(255,252,238,0.92)"; + ctx.strokeStyle = style.color; + ctx.lineWidth = 2.2; + tarinaiRoundRectPath(ctx, -labelW / 2, labelY - 10, labelW, 20, 10); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = style.color; + ctx.fillText(style.label, 0, labelY); + } + ctx.restore(); + } + + if (this.world.selected === this) { + ctx.save(); + const pulse = 0.5 + 0.5 * Math.sin(this.world.time * 7.5); + const jumpPulse = clamp(this.focusPulseTimer || 0, 0, 1); + ctx.globalAlpha = (0.82 + pulse * 0.12) * nestHideAlpha; + ctx.shadowColor = "rgba(124, 210, 76, 0.92)"; + ctx.shadowBlur = 14 + 18 * Math.max(pulse, jumpPulse); + ctx.strokeStyle = "rgba(112, 198, 70, 0.98)"; + ctx.lineWidth = 4.2 + jumpPulse * 2.0; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * (0.74 + jumpPulse * 0.10), drawH * (0.56 + jumpPulse * 0.08), 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.setLineDash([9, 5]); + ctx.lineWidth = 2.0; + ctx.strokeStyle = "rgba(255,255,255,0.96)"; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.88, drawH * 0.68, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = "rgba(255,252,232,0.94)"; + ctx.strokeStyle = "rgba(112,198,70,0.88)"; + ctx.lineWidth = 2; + const focusLabel = "\u6ce8\u76ee"; + ctx.font = `${Math.max(11, this.radius * 0.54)}px Yomogi, ui-rounded, sans-serif`; + const lw = ctx.measureText(focusLabel).width + 18; + const ly = -drawH * 0.64; + tarinaiRoundRectPath(ctx, -lw / 2, ly - 11, lw, 22, 11); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(72,126,42,0.96)"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(focusLabel, 0, ly); + ctx.restore(); + } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { + ctx.save(); + ctx.globalAlpha = 0.28; + ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + ctx.rotate(angle); + ctx.globalAlpha *= nestHideAlpha; + if (insideCardboard) ctx.globalAlpha *= 0.58; + + + if (this.eatTimer > 0.02) { + ctx.save(); + ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); + ctx.strokeStyle = this.lastMealColor || "#f7cf67"; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + if (this.pokeFlashTimer > 0.03) { + ctx.save(); + ctx.globalAlpha = this.pokeFlashTimer * 0.9 * nestHideAlpha; + ctx.fillStyle = "rgba(255, 120, 120, 0.22)"; + ctx.beginPath(); + ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { + ctx.save(); + const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0; + ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; + ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9")); + const count = this.state === "cursor_friend" ? (cursorLove > 1.0 ? 10 : (cursorLove > 0.45 ? 8 : (cursorLove > 0.18 ? 6 : 4))) : 2; + for (let i = 0; i < count; i++) { + const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05; + const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit; + const py = -drawH * (0.22 + (i % 2) * 0.06) + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * (0.08 + cursorLove * 0.02); + if (this.state === "cursor_friend") { + const s = Math.max(5, drawW * (0.070 + (i % 3) * 0.012 + cursorLove * 0.01)); + drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 }); + } else { + ctx.beginPath(); + ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + } + + if (faceRight) ctx.scale(-1, 1); + const dw = drawW * squish; + const dh = drawH / squish; + const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17); + const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14); + const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0; + const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); + const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); + if (img) { + const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; + ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); + } else { + ctx.save(); + ctx.fillStyle = this.stress > 62 ? "#f1c3a8" : "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 0, dw * 0.42, dh * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { + const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); + if (stainCanvas) { + ctx.save(); + ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88); + ctx.drawImage(stainCanvas, -dw / 2, -dh / 2, dw, dh); + ctx.restore(); + } + } + if (String(sprite || "").startsWith("zunchi_slave")) { + const zImg = typeof getRenderableImage === "function" ? getRenderableImage("zunchi", "zunchi") : (images && images.get ? images.get("zunchi") : null); + if (zImg) { + ctx.save(); + ctx.shadowColor = "rgba(46, 36, 24, 0.28)"; + ctx.shadowBlur = 5; + ctx.shadowOffsetY = Math.max(1, drawH * 0.012); + const zw = Math.max(16, drawW * 0.42); + const zh = zw * 0.74; + ctx.drawImage(zImg, -zw * 0.78, -dh * 0.50 - zh * 0.28, zw, zh); + ctx.restore(); + } + } + drawDawnRimLight(ctx, dw, dh, lightState); + if (faceRight) ctx.scale(-1, 1); + + if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) { + ctx.save(); + ctx.globalAlpha = 0.80; + ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(42,36,29,0.72)"; + const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?"); + ctx.fillText(mark, 0, -drawH * 0.72); + ctx.restore(); + } + + ctx.restore(); + + if (this.favorite) { + ctx.save(); + ctx.globalAlpha = nestHideAlpha; + const starSize = Math.max(18, this.radius * 0.88); + const sx = this.x; + const sy = this.y - drawH * 0.64 - 20; + ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.shadowColor = "rgba(255, 198, 28, 0.82)"; + ctx.shadowBlur = 14; + ctx.lineWidth = Math.max(2, starSize * 0.12); + ctx.strokeStyle = "rgba(92, 62, 0, 0.86)"; + ctx.fillStyle = "rgba(255, 218, 46, 1)"; + ctx.strokeText("\u2605", sx, sy); + ctx.fillText("\u2605", sx, sy); + ctx.restore(); + } + + const hpActive = (this.hpBarTimer || 0) > 0; + const stressActive = (this.stressBarTimer || 0) > 0; + if (hpActive || stressActive) { + const bw = Math.max(28, this.radius * 2.35); + const bh = 5; + const x = this.x - bw / 2; + const baseY = this.y - drawH * 0.55 - 16; + const drawMeter = (y, pct, fill, alpha) => { + ctx.save(); + ctx.globalAlpha = alpha; + const safePct = clamp(Number(pct) || 0, 0, 1); + const fillW = safePct <= 0 ? 0 : Math.min(bw, Math.max(2, bw * safePct)); + ctx.fillStyle = "rgba(255,252,242,0.86)"; + roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5); + ctx.fill(); + ctx.fillStyle = "rgba(72,58,45,0.22)"; + roundedRect(ctx, x, y, bw, bh, 4); + ctx.fill(); + if (fillW > 0) { + const fillR = Math.min(4, bh / 2, fillW / 2); + ctx.fillStyle = fill; + roundedRect(ctx, x, y, fillW, bh, fillR); + ctx.fill(); + } + ctx.restore(); + }; + if (stressActive) { + const pct = clamp((this.stress || 0) / 130, 0, 1); + drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); + } + if (hpActive) { + const pct = clamp(this.energy / 100, 0, 1); + drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); + } + } + }, + + contains(x, y) { + if (this.insideNestBoxId || (this.nestFade || 0) >= 0.92) return false; + const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; + return distXY(this.x, this.y, x, y) < hit; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js new file mode 100644 index 0000000..c8af767 --- /dev/null +++ b/js/tarinai_social_move_life.js @@ -0,0 +1,401 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_social_move_life.js"); + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + interactWithOthers(dt) { + let closeCount = 0; + for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) { + if (o === this || o.dead) continue; + const dx = o.x - this.x, dy = o.y - this.y; + const d = Math.hypot(dx, dy); + if (d < 0.01 || d > 74) continue; + closeCount += 1; + + const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect()); + if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) { + if (this.world.startForcedFight?.(this, o)) continue; + } + + if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { + 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] }); + } + if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { + 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 mixedZunchiSlave = !!this.isZunchiSlave !== !!o.isZunchiSlave; + if (mixedZunchiSlave && d < 86) { + const nonSlave = this.isZunchiSlave ? o : this; + const slave = this.isZunchiSlave ? this : o; + const nx = (nonSlave.x - slave.x) / d; + const ny = (nonSlave.y - slave.y) / d; + nonSlave.vx += nx * dt * 34; + nonSlave.vy += ny * dt * 34; + if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") { + if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "ずんちどれいを怖がっている", fear: 0.38, wake: false, cause: "zunchi_slave_nearby" }); + else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; nonSlave.thought = "ずんちどれいを怖がっている"; } + } else { + nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); + nonSlave.target = slave; + nonSlave.thought = "ずんちどれいを怖がっている"; + } + nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); + slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); + if (Math.random() < dt * 0.35) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); + continue; + } + const overlap = this.radius + o.radius - d; + if (overlap > -2) { + const push = (overlap + 6) * (pairBond ? 0.08 : 0.17); + this.vx -= (dx / d) * push; + this.vy -= (dy / d) * push; + if (!pairBond && Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); + } + if (pairBond && d > 26 && d < 120) { + this.vx += (dx / d) * dt * 4.8; + this.vy += (dy / d) * dt * 4.8; + } + + if (d < 60) { + const rel = this.relationTo(o.id); + const pdef = this.personalityProfile(); + this.loneliness -= dt * 1.65 * 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"); + if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { + this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); + this.affection = clamp(this.affection + dt * 0.18, 0, 80); + if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation", { participants: [o, this] }); + } + if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && this.world.relationNotice(this.id, o.id, "friend", 32)) { + this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation", { participants: [o, this] }); + } + if (this.type === "angry") this.stress += dt * 0.08; + if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; + } + + const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); + if (fightingThis && d < 36 && this.id < o.id) { + const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; + if (pulse && this.world.time > this.nextHeadbutt) { + const nx = dx / d, ny = dy / d; + this.vx -= nx * rand(24, 44); + this.vy -= ny * rand(24, 44); + o.vx += nx * rand(34, 64); + o.vy += ny * rand(34, 64); + this.stress = clamp(this.stress + 1.6, 0, 130); + o.stress = clamp(o.stress + 2.8, 0, 130); + const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; + const damageToOther = this.outgoingDamage ? this.outgoingDamage(1.6) : 1.6; + this.world.emit?.("fight:hit", { attacker: o, target: this, amount: damageToThis, cause: "\u55a7\u5629" }); + this.world.emit?.("fight:hit", { attacker: this, target: o, amount: damageToOther, cause: "\u55a7\u5629" }); + this.damage(damageToThis, "\u55a7\u5629"); + o.damage(damageToOther, "\u55a7\u5629"); + this.world.maybeDefeatFromFightDamage?.(this, o, damageToThis); + this.world.maybeDefeatFromFightDamage?.(o, this, damageToOther); + this.hurtTimer = Math.max(this.hurtTimer, 1.4); + o.hurtTimer = Math.max(o.hurtTimer, 1.8); + this.adjustRelation(o, -0.16, 0.12, "fight"); + o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); + if (Math.random() < 0.22 || o.energy < 24) { + o.fallTimer = Math.max(o.fallTimer, 0.58); + o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); + o.fallDir = nx > 0 ? 1 : -1; + this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); + } + if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { + this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); + } + this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); + this.nextHeadbutt = this.world.time + rand(0.34, 0.55); + } + } + + const relA = this.relationTo(o.id); + const relB = o.relationTo(this.id); + const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect(); + const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect(); + const aggressiveThis = this.shouldApplyPersonalityBehavior("aggression", 1); + const aggressiveOther = o.shouldApplyPersonalityBehavior?.("aggression", 1); + const calmThis = this.shouldApplyPersonalityBehavior("aggression", -1); + const calmOther = o.shouldApplyPersonalityBehavior?.("aggression", -1); + const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); + const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); + const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); + const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); + const conflictBias = (battleDrug ? 2.9 : 1) * (o.isZunchiSlave ? 2.2 : 1) * (this.isZunchiSlave ? 1.15 : 1); + const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; + const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; + const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; + if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { + this.world.startConflict(this, o); + } + + const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave; + if (compatibleBirthStatus && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { + const enough = loveDrug ? (this.hunger < 90 && o.hunger < 92 && this.energy > 18 && o.energy > 18) : (this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44); + const lonelyBond = loveDrug ? (this.loneliness > 4 || o.loneliness > 4 || this.affection > 0 || o.affection > 0) : (this.loneliness > 24 || o.loneliness > 24); + if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) { + this.world.startBirthRitual(this, o); + } + } + } + + if (closeCount > 8) { + this.stress += dt * 0.9 * this.personalityProfile().fear; + this.energy -= dt * 0.25; + this.fearTimer = Math.max(this.fearTimer, 0.18); + } + + if (this.loneliness < 12 && Math.random() < dt * 0.002) { + 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] }); + } + }, + + move(dt) { + if (this.freezeSleepDiseaseMotion(dt)) return; + let ax = 0, ay = 0; + const itemSpeedMul = this.itemSpeedMultiplier ? this.itemSpeedMultiplier() : 1; + const baseSpeed = 46.5 * 1.5 * this.trait.speed * itemSpeedMul * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); + + if (this.state === "sunbath") { + this.target = null; + this.vx *= Math.pow(0.90, dt * 60); + this.vy *= Math.pow(0.90, dt * 60); + this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + return; + } + if (this.state === "sleep") { + const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72; + const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; + const crowdedSleep = bedCrowd > 5; + this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); + this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, bedCrowd - 5) : -0.12 * bedComfort), 0, 130); + this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); + if (this.target && !this.target.dead && this.target.type === "nest_box") { + this.updateNestBoxPresence(dt); + this.vx = 0; + this.vy = 0; + return; + } + if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { + const spot = this.sleepSpotFor(this.target); + const dx = spot.x - this.x; + const dy = spot.y - this.y; + const d = Math.hypot(dx, dy); + if (d > 2.2) { + const step = Math.min(d, dt * (12 + bedComfort * 14)); + this.x += dx / d * step; + this.y += dy / d * step; + } else { + this.x = spot.x; + this.y = spot.y; + } + } + this.updateNestBoxPresence(dt); + this.vx *= Math.pow(0.55, dt * 60); + this.vy *= Math.pow(0.55, dt * 60); + return; + } + + if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + if (partner) { + const midX = (this.x + partner.x) / 2; + const midY = (this.y + partner.y) / 2; + const pdx = partner.x - this.x; + const pdy = partner.y - this.y; + const plen = Math.max(0.001, Math.hypot(pdx, pdy)); + const nx = -pdy / plen; + const ny = pdx / plen; + const desiredGap = this.radius * 0.36; + const side = this.birthRitualRole || 1; + const tx = midX + nx * desiredGap * side; + const ty = midY + ny * desiredGap * side; + this.vx += (tx - this.x) * dt * 3.8; + this.vy += (ty - this.y) * dt * 3.8; + } + this.vx *= Math.pow(0.48, dt * 60); + this.vy *= Math.pow(0.48, dt * 60); + this.x += Math.sin(this.world.time * 26 + this.age) * 0.20; + return; + } + + if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") { + this.vx *= Math.pow(0.22, dt * 60); + this.vy *= Math.pow(0.22, dt * 60); + return; + } + + if (this.eatTimer > 0.04 || this.state === "eat") { + this.vx = 0; + this.vy = 0; + return; + } + + if (this.state === "panic") { + const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity; + const speed = Math.hypot(this.vx, this.vy); + this.panicStuckTimer = speed < 8 ? (this.panicStuckTimer || 0) + dt : 0; + if (!this.target || this.target.dead || targetDist < 28 || this.panicStuckTimer > 0.75) { + this.target = this.panicDestination(null, true); + } + } + + if (this.target && !this.target.dead) { + const moveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target; + const dx = moveTarget.x - this.x; + const dy = moveTarget.y - this.y; + const d = Math.hypot(dx, dy) || 1; + if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) { + const bedDx = this.target.x - this.x; + const bedDy = this.target.y - this.y; + const bedDist = Math.hypot(bedDx, bedDy); + const reachedSleepFurniture = this.target.type === "nest_box" + ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) + : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); + if (reachedSleepFurniture) { + this.startSleeping?.(this.target, this.target.type === "nest_box" ? "巣箱の中で休んでいる" : "眠る場所に着いたので休んでいる"); + if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); + return; + } + } + let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1; + if (this.target?.detour) sign = 1; + let strength = 1; + if (this.state === "follow_parent") { + strength = d < 42 ? 0.18 : 1.22; + sign = 1; + } else if (this.state === "cursor_friend") { + strength = d < 30 ? 0.12 : 0.95; + sign = 1; + } else if (this.state === "cursor_enemy") { + strength = d < 150 ? 1.35 : 0.35; + sign = -1; + } else if (this.state === "play_ball") { + strength = d < this.radius + 18 ? 1.75 : 1.28; + sign = 1; + } else if (this.state === "fight") { + const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37); + if (d < 18) { + strength = 1.05; + sign = -1; + } else { + strength = headbutt > -0.15 ? 2.65 : 0.65; + sign = headbutt > -0.15 ? 1 : -1; + } + } else if (this.state === "ant_attack") { + strength = d < this.radius + ((this.target && this.target.r) || 4) + 6 ? 0.18 : 1.32; + sign = 1; + if (d < this.radius + ((this.target && this.target.r) || 4) + 10) { + const aggression = Math.max(0, this.currentPersonality?.aggression || 0); + if ((this.world.time || 0) >= (this.nextAntAttackAt || 0)) { + this.nextAntAttackAt = (this.world.time || 0) + 0.34; + if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4))); + this.fearTimer = Math.max(this.fearTimer, 0.12); + this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; + if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); + } + } + } + ax += (dx / d) * baseSpeed * sign * strength; + ay += (dy / d) * baseSpeed * sign * strength; + } else if (this.state === "panic") { + this.wanderAngle += rand(-3.8, 3.8) * dt; + ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; + ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; + } else if (this.state === "zunchi_sick") { + this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; + ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72; + ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72; + } else if (this.state === "fight_sick") { + this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; + ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88; + ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88; + } else { + ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34; + ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34; + } + + this.vx += ax * dt; + this.vy += ay * dt; + this.vx *= Math.pow(0.84, dt * 8); + this.vy *= Math.pow(0.84, dt * 8); + + const maxV = (this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : (this.state === "fight_sick" ? 68 : 76))) * Math.max(1, itemSpeedMul || 1) * (this.geneticStatRatio ? this.geneticStatRatio("speed") : 1); + const v = Math.hypot(this.vx, this.vy); + if (v > maxV) { + this.vx = this.vx / v * maxV; + this.vy = this.vy / v * maxV; + } + + const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; + const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; + this.x += (this.vx + impulseVx) * dt; + this.y += (this.vy + impulseVy) * dt; + const impulseDrag = Math.pow(0.58, dt * 3.2); + this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; + this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; + + if (this.entryTimer > 0) { + this.entryTimer = Math.max(0, this.entryTimer - dt); + if (this.x >= CONFIG.worldPadding && this.y >= CONFIG.worldPadding && this.x <= this.world.w - CONFIG.worldPadding && this.y <= this.world.h - CONFIG.worldPadding) { + this.entryTimer = 0; + } + } + const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding; + const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding; + const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding; + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } + if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } + if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } + }, + + checkLife(dt = 0.016) { + let reason = ""; + if (this.energy <= 0) { + reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; + } + else if (this.hunger >= 108) reason = "\u98e2\u9913"; + else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; + else if (this.stress >= 124) { + reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + } + else if (this.mood <= 20) { + const lowMood = clamp((20 - this.mood) / 20, 0, 1); + const bodyLoad = clamp((this.hunger - 72) / 42, 0, 1) * 0.018 + clamp((28 - this.energy) / 28, 0, 1) * 0.020 + clamp((this.stress - 86) / 44, 0, 1) * 0.022; + const deathRisk = 0.010 + lowMood * 0.060 + bodyLoad; + if (Math.random() < Math.max(0, dt) * deathRisk) { + reason = this.dominantDeathCause("", { lowMood: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + } + } + if (reason) this.die(reason); + }, + + die(reason) { + if (this.dead) return; + const finalReason = this.normalizeDeathReason ? this.normalizeDeathReason(reason) : (reason || "\u4e8b\u6545"); + this.dead = true; + this.deathReason = finalReason; + this.world.deadCount += 1; + this.world.items.push(new Item("trace", this.x, this.y)); + this.world.items.push(new Item("splat", this.x, this.y)); + if (Math.random() < 0.55) { + const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); + if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); + } + this.world.markDead(this, finalReason); + this.releasedLiveToken = this.liveToken || 0; + this.world.releaseTarinaiLiveId?.(this); + audio.death(); + this.world.emit?.("tarinai:died", { tarinai: this, reason: finalReason }); + 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; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/text_catalog.js b/js/text_catalog.js index 7e58f38..eb561a4 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -5,50 +5,143 @@ const TEXT_CATALOG = { ecologyCards: [ { title: "\u305f\u308a\u306a\u3044\u3068\u306f", - image: "assets/sprites/tarinai_01_smile.png", + image: "assets/sprites/tarinai_01_smile.webp", text: "\u5ead\u3067\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u7a7a\u8179\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002", }, { title: "\u4f53\u529b\u3068\u30b9\u30c8\u30ec\u30b9", - image: "assets/ui/ecology_hp_stress.png", + image: "assets/ui/ecology_hp_stress.webp", text: "\u30d0\u30fc\u306f\u4e0a\u304c\u4f53\u529b\u3001\u4e0b\u304c\u30b9\u30c8\u30ec\u30b9\u3002\u4f53\u529b\u306f\u3051\u304c\u3084\u4e8b\u6545\u3067\u6e1b\u308a\u30010\u3067\u6b7b\u4ea1\u3002\u30b9\u30c8\u30ec\u30b9\u306f\u6050\u6016\u3001\u6df7\u96d1\u3001\u6c5a\u308c\u3001\u3051\u3093\u304b\u3067\u5897\u3048\u3001\u9ad8\u3044\u3068\u884c\u52d5\u304c\u4e71\u308c\u308b\u3002", }, { title: "\u66ae\u3089\u3057\u3068\u6b32\u6c42", - image: "assets/sprites/tarinai_05_drool.png", + image: "assets/sprites/tarinai_05_drool.webp", text: "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3001\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002", }, { title: "\u6027\u683c\u3068\u500b\u6027", - image: "assets/sprites/tarinai_21_normal_happy.png", + image: "assets/sprites/tarinai_21_normal_happy.webp", text: "\u653b\u6483\u6027\u3001\u958b\u653e\u6027\u3001\u793e\u4ea4\u6027\u3001\u795e\u7d4c\u8cea\u6027\u304c\u7d4c\u9a13\u3067\u5c11\u3057\u305a\u3064\u5909\u308f\u308b\u3002\u95be\u5024\u3092\u8d85\u3048\u308b\u3068\u6027\u683c\u30bf\u30b0\u3068\u884c\u52d5\u5dee\u306b\u51fa\u308b\u3002", }, { title: "\u5bb6\u65cf\u3068\u95a2\u4fc2", - image: "assets/sprites/tarinai_27_birth_ritual.png", + image: "assets/sprites/tarinai_27_birth_ritual.webp", text: "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u53cb\u9054\u3001\u6575\u5bfe\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u73fe\u5728\u6027\u683c\u3092\u53d7\u3051\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002", }, { title: "\u75c5\u6c17\u3068\u6cbb\u7642", - image: "assets/sprites/tarinai_24_stress_dizzy.png", + image: "assets/sprites/tarinai_24_stress_dizzy.webp", text: "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u304d\u305a\u3064\u304d\u75c5\u304c\u3042\u308b\u3002\u75c5\u6c17\u3054\u3068\u306b\u6c34\u3084\u305a\u3093\u3060\u9905\u306a\u3069\u3092\u6c42\u3081\u308b\u3002", }, { title: "\u3051\u3093\u304b\u3068\u4e8b\u6545", - image: "assets/sprites/tarinai_26_intimidate.png", + image: "assets/sprites/tarinai_26_intimidate.webp", text: "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u3051\u3093\u304b\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002", }, { title: "\u9053\u5177\u3068\u4ecb\u5165", - image: "assets/sprites/tarinai_18_fear.png", + image: "assets/sprites/tarinai_18_fear.webp", text: "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002", }, { title: "\u89b3\u5bdf\u306e\u65b9\u6cd5", - image: "assets/sprites/tarinai_11_weak.png", + image: "assets/sprites/tarinai_11_weak.webp", text: "\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7d71\u8a08\u304b\u3089\u500b\u4f53\u306e\u5909\u5316\u3092\u8ffd\u3048\u308b\u3002", }, ], }; + +TEXT_CATALOG.stateLabels = Object.freeze({ + idle: "待機", + seek_food: "食べ物へ向かう", + seek_bed: "寝床へ向かう", + play_ball: "ボール遊び", + seek_water: "水を探す", + zunchi_sick: "ずんち病", + fight_sick: "きずつき病", + system: "ずんち病", + seek_friend: "仲間を探す", + follow_parent: "親についていく", + cursor_friend: "カーソルについていく", + cursor_enemy: "カーソルを避ける", + fight: "喧嘩中", + sleep: "睡眠中", + eat: "食事中", + panic: "パニック", + intimidate: "威嚇中", + birth_ritual: "誕生前", + sunbath: "日光浴中", + ant_attack: "アリ攻撃中", + ant_intimidate: "アリ威嚇中", + frozen: "冷凍中", +}); + +TEXT_CATALOG.targetLabelOverrides = Object.freeze({ + system: "ずんち病", + zunchi_sick: "ずんち病", + fight_sick: "きずつき病", +}); +// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel(). +TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides; + +TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) { + return TEXT_CATALOG.stateLabels?.[s] || s || ""; +}; + +TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) { + if (!target) return "なし"; + if (target.name) return target.name; + if (target.kind === "queen") return "女王アリ"; + if (target.kind === "worker") return "働きアリ"; + if (target.type) { + const type = String(target.type || ""); + if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type]; + const label = typeof toolLabel === "function" ? toolLabel(type) : ""; + return label || type; + } + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置"; + return "不明"; +}; + +TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { + if (!t) return "なし"; + if ((t.pinchThoughtTimer || 0) > 0) return "つままれている"; + const thought = String(t.thought || "").trim(); + if (thought) return thought; + const target = TEXT_CATALOG.targetLabel(t.target); + if (t.state === "eat") return target !== "なし" ? `${target}を食べている` : "食べている"; + if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している"; + if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている"; + if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している"; + if (t.state === "ant_intimidate") return "アリを威嚇している"; + if (t.state === "ant_attack") return "アリを攻撃している"; + if (t.state === "birth_ritual") return "子が現れる前に体をゆすっている"; + if (t.state === "sunbath") return "晴れた日なたでストレスを下げている"; + if (t.state === "frozen") return "フィールド外で冷凍保存されている"; + if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている"; + if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している"; + if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している"; + if (t.state === "play_ball") return "ボールが気になっている"; + if (t.state === "seek_friend") return "仲間を探している"; + if (t.state === "follow_parent") return "親についていく"; + if (t.state === "cursor_friend") return "カーソルについていく"; + if (t.state === "cursor_enemy") return "カーソルを避けている"; + if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している"; + if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている"; + if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い"; + if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している"; + if ((t.fearTimer || 0) > 0.08) return "怖がっている"; + if ((t.grassEatTimer || 0) > 0.04) return "草を食べている"; + return "周囲を見ている"; +}; + +function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); } +function targetLabel(target) { return TEXT_CATALOG.targetLabel(target); } +function reasonLabel(t) { return TEXT_CATALOG.reasonLabel(t); } + +window.stateLabel = stateLabel; +window.targetLabel = targetLabel; +window.reasonLabel = reasonLabel; + window.TEXT_CATALOG = TEXT_CATALOG; diff --git a/js/ui.js b/js/ui.js index 993a464..8cc8fbc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -7,7 +7,11 @@ const ui = { speedBtn: document.getElementById("speedBtn"), ecologyBtn: document.getElementById("ecologyBtn"), soundBtn: document.getElementById("soundBtn"), + soundMenu: document.getElementById("soundMenu"), + soundPanel: document.getElementById("soundPanel"), + soundMasterToggle: document.getElementById("soundMasterToggle"), creditsBtn: document.getElementById("creditsBtn"), + saveBtn: document.getElementById("saveBtn"), resetBtn: document.getElementById("resetBtn"), creditsDialog: document.getElementById("creditsDialog"), creditsCloseBtn: document.getElementById("creditsCloseBtn"), @@ -18,9 +22,15 @@ const ui = { colonyChartTabs: document.getElementById("colonyChartTabs"), selectedInfo: document.getElementById("selectedInfo"), log: document.getElementById("log"), + logPushControls: document.getElementById("logPushControls"), + logPushEnabled: document.getElementById("logPushEnabled"), + logPushOverlay: document.getElementById("logPushOverlay"), + mobileModeControls: document.getElementById("mobileModeControls"), + soundCategoryControls: document.getElementById("soundPanel"), toast: document.getElementById("toast"), statPop: document.getElementById("statPop"), statDead: document.getElementById("statDead"), + statColonyMood: document.getElementById("statColonyMood"), colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), @@ -41,6 +51,8 @@ const uiCache = { selectedSnapshot: "", selectedEmpty: false, selectedCollapsedCategories: new Set(), + logPushEnabled: false, + logPushKinds: new Set(["death", "accident", "birth"]), archiveUpdateEnabled: false, toolButtons: [], archiveVersion: "", @@ -85,1041 +97,18 @@ const uiCache = { grabLastWorldX: 0, grabLastWorldY: 0, grabLastSpatialAt: 0, + mobileInputMode: window.TarinaiInputMode?.currentMode || "auto", + touchMode: "", + touchMoved: false, + touchStartX: 0, + touchStartY: 0, + touchLastX: 0, + touchLastY: 0, + touchLastWorldX: 0, + touchLastWorldY: 0, + pinchStartDistance: 0, + pinchStartZoom: 1, + pinchFocusWorld: null, + pinchLastCenterX: 0, + pinchLastCenterY: 0, }; - -function renderLog(logs) { - ui.log.innerHTML = ""; - const frag = document.createDocumentFragment(); - for (const raw of logs.slice(0, 32)) { - const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw; - const kind = entry.kind || "note"; - const div = document.createElement("div"); - div.className = `log-entry log-${kind}`; - const tag = document.createElement("span"); - tag.className = "log-tag"; - tag.textContent = logKindLabel(kind); - const time = document.createElement("span"); - time.className = "time"; - time.textContent = formatLogTime(entry.time || 0); - div.appendChild(tag); - div.appendChild(time); - div.append(document.createTextNode(formatLogDisplayText(entry.text || ""))); - frag.appendChild(div); - } - ui.log.appendChild(frag); -} - -function formatLogTime(timeValue) { - const t = Math.max(0, Number(timeValue) || 0); - const day = Math.floor(t / CONFIG.dayLength) + 1; - const dayT = ((t % CONFIG.dayLength) + CONFIG.dayLength) % CONFIG.dayLength; - const totalMinutes = Math.floor(dayT / CONFIG.dayLength * 24 * 60); - const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); - const mm = String(totalMinutes % 60).padStart(2, "0"); - return `${day}\u65e5 ${hh}\u6642${mm}\u5206`; -} - -function logKindLabel(kind) { - return { - birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349", - weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", note: "\u8a18\u9332" - }[kind] || "\u8a18\u9332"; -} - -function tarinaiSpritePathForId(id) { - const live = world?.tarinai?.find(t => (t.id === id || t.familyKey === id) && !t.dead); - const sid = live?.spriteId ? live.spriteId() : live?.type; - const fam = world?.family?.[id]; - const fallback = fam?.type; - return (SPRITES.find(s => s.id === (sid || fallback)) || SPRITES[0]).path; -} - -function relationIconHtml(id) { - if (!id) return ""; - return ``; -} - -function relationEntries(t, kind = "friend") { - const rows = []; - const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id)); - for (const [id, rel] of Object.entries(t?.relationships || {})) { - if (!liveIds.has(id)) continue; - const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0); - const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD; - if (score <= threshold) continue; - rows.push({ id, score, name: relationDisplayName(world, id) }); - } - rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "ja")); - return rows; -} - -function relationListHtml(t, kind = "friend") { - const rows = relationEntries(t, kind); - if (!rows.length) return "\u306a\u3057"; - const sign = kind === "friend" ? "+" : ""; - return `
${rows.map(r => ` - `).join("")}
`; -} - -function focusTarinaiById(id) { - const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id)); - if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } - world.selected = t; - t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); - selectTool("observe"); - if (Number.isFinite(t.x) && Number.isFinite(t.y)) { - world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; - world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; - world.clampCamera?.(); - } - uiCache.selectedSnapshot = ""; - renderSelected(); - renderStats(); - render?.(); - showToast(`\u300c${t.name}\u300d\u3078\u79fb\u52d5\u3057\u307e\u3057\u305f\u3002`); - return true; -} - -function quoteNameInRecordText(text, name) { - const raw = String(text || ""); - const n = String(name || "").trim(); - if (!n) return raw; - const parts = raw.split(n); - if (parts.length <= 1) return raw; - let out = ""; - for (let i = 0; i < parts.length - 1; i++) { - const before = parts[i].slice(-1); - const after = parts[i + 1].charAt(0); - out += parts[i] + ((before === "\u300c" && after === "\u300d") ? n : `\u300c${n}\u300d`); - } - return out + parts[parts.length - 1]; -} - -function renameSelectedTarinai(rawName) { - const t = world.selected; - if (!t || t.dead || !world.tarinai.includes(t)) return false; - const next = String(rawName || "").replace(/\s+/g, " ").trim().slice(0, 18); - if (!next) { - uiCache.selectedSnapshot = ""; - renderSelected(); - return false; - } - if (next === t.name) return false; - const oldName = t.name; - t.name = next; - if (t.isZunchiSlave) t.formerName = next; - world.recordFamily?.(t); - world.markFamilyTreeDirty?.("rename"); - uiCache.archiveFamilyVersion = null; - uiCache.selectedSnapshot = ""; - renderSelected(); - renderArchive?.(); - render?.(); - showToast(`\u300c${oldName}\u300d\u3092\u300c${next}\u300d\u306b\u6539\u540d\u3057\u307e\u3057\u305f\u3002`); - return true; -} - -function personalityValueText(value) { - const n = clamp(Number(value) || 0, -1, 1); - return `${n >= 0 ? "+" : ""}${n.toFixed(2)}`; -} - -function personalityRowsHtml(personality = {}) { - const p = normalizePersonality(personality); - return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); -} - -function selectedDataCategoryHtml(id, label, bodyHtml) { - if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); - const collapsed = uiCache.selectedCollapsedCategories.has(id); - return `
- -
${bodyHtml}
-
`; -} - -function renderSelected() { - const t = world.selected; - if (!t || t.dead || !world.tarinai.includes(t)) { - ui.selectedCard?.classList.add("hidden"); - if (uiCache.selectedEmpty) return; - uiCache.selectedSnapshot = ""; - uiCache.selectedEmpty = true; - ui.selectedInfo.className = "selected-info empty"; - ui.selectedInfo.textContent = "\u672a\u9078\u629e"; - return; - } - - ui.selectedCard?.classList.remove("hidden"); - uiCache.selectedEmpty = false; - const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id; - if (activeNameInput) return; - const rel = t.relationSummary ? t.relationSummary() : {}; - const fightRecord = relationFightRecord(t); - const generationDisplay = t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : ""); - ensurePersonality(t); - const personalityTags = t.personalityTags ? t.personalityTags() : getPersonalityTraitTags(t); - const personalitySnapshot = [ - ...PERSONALITY_KEYS.map(key => personalityValueText(t.birthPersonality?.[key])), - ...PERSONALITY_KEYS.map(key => personalityValueText(t.currentPersonality?.[key])), - personalityTags.join(",") - ].join(","); - const snapshot = [ - t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), - fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), - fmt(t.energy), fmt(t.stress), fmt(t.mood), - t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), - relationEntries(t, "friend").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), relationEntries(t, "enemy").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), fightRecord, - t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0), - t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0), - t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0, - personalitySnapshot, - (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") - ].join("|"); - if (uiCache.selectedSnapshot === snapshot) return; - uiCache.selectedSnapshot = snapshot; - - ui.selectedInfo.className = "selected-info"; - const effectBadges = []; - if (t.isZunchiSlave) effectBadges.push(`\u305a\u3093\u3061\u3069\u308c\u3044`); - if (t.favorite) effectBadges.push(`\u2605`); - if (t.zunchiDisease) effectBadges.push(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); - if (t.sleepDisease) effectBadges.push(`\u306d\u3080\u308a\u75c5`); - if (t.explosionDisease) effectBadges.push(`\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}`); - if (t.fightDisease) effectBadges.push(`\u304d\u305a\u3064\u304d\u75c5`); - if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); - if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); - const personalityTagHtml = personalityTags.length - ? personalityTags.map(tag => `${escapeHtml(tag)}`).join("") - : `\u4e2d\u7acb`; - const personalityHtml = ` -
\u6027\u683c\u30bf\u30b0${personalityTagHtml}
-
\u8a95\u751f\u6642\u6027\u683c${personalityRowsHtml(t.birthPersonality)}
-
\u73fe\u5728\u6027\u683c${personalityRowsHtml(t.currentPersonality)}
- `; - const recordHtml = (t.records || []).slice(0, 8).map(r => { - const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); - const recordText = quoteNameInRecordText(r.text || "", t.name); - return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; - }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; - const basicHtml = ` -
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    -
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    -
    \u7406\u7531${escapeHtml(reasonLabel(t))}
    -
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    -
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    - `; - const healthHtml = ` -
    \u7a7a\u8179${fmt(t.hunger)}
    -
    \u5bc2\u3057\u3055${fmt(t.loneliness)}
    -
    \u4f53\u529b${fmt(t.energy)}
    -
    \u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
    -
    \u305a\u3093\u3061\u6c5a\u308c${fmt(t.zunchiStain || 0)}
    -
    \u6c17\u5206${fmt(t.mood)}
    -
    \u305f\u308a\u306a\u3044${fmt(t.lack)}
    - `; - const relationHtml = ` -
    \u53cb\u9054${relationListHtml(t, "friend")}
    -
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    -
    \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
    -
    \u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
    - `; - const familyHtml = ` -
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    -
    \u5b50${t.children?.length || 0}
    - `; - ui.selectedInfo.innerHTML = ` -
    - -
    - ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} -
    - ${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)} - ${selectedDataCategoryHtml("health", "\u4f53\u8abf", healthHtml)} - ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} - ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} - ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} - ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    `)} -
    -
    - `; -} - -function formatRecordTime(time) { - const totalMinutes = Math.floor(((time || 0) % (CONFIG.dayLength || 120)) / (CONFIG.dayLength || 120) * 24 * 60); - const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); - const mm = String(totalMinutes % 60).padStart(2, "0"); - return `${hh}:${mm}`; -} - -function stateLabel(s) { - return { - idle: "\u5f85\u6a5f", - seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046", - seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046", - play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", - seek_water: "\u6c34\u3092\u63a2\u3059", - zunchi_sick: "\u305a\u3093\u3061\u75c5", - fight_sick: "\u304d\u305a\u3064\u304d\u75c5", - system: "\u305a\u3093\u3061\u75c5", - seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", - follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", - cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f", - cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b", - fight: "\u55a7\u5629\u4e2d", - sleep: "\u7761\u7720\u4e2d", - eat: "\u98df\u4e8b\u4e2d", - panic: "\u30d1\u30cb\u30c3\u30af", - intimidate: "\u5a01\u5687\u4e2d", - birth_ritual: "\u8a95\u751f\u524d", - }[s] || s; -} - -function reasonLabel(t) { - if (!t) return "\u306a\u3057"; - const thought = String(t.thought || "").trim(); - if (thought) return thought; - if (t.state === "sleep") return "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b"; - if (t.state === "panic") return "\u6016\u304c\u3063\u3066\u3044\u308b"; - if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; - if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; - if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b"; - if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; - if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5"; - return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; -} - -function targetLabel(target) { - if (!target) return "\u306a\u3057"; - if (target.name) return target.name; - if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb"; - return "\u4e0d\u660e"; -} - -function relationFightRecord(t) { - let won = 0, lost = 0; - for (const rel of Object.values(t.relationships || {})) { - won += rel.fightsWon || 0; - lost += rel.fightsLost || 0; - } - return won || lost ? `\u52dd${won} / \u8ca0${lost}` : "\u306a\u3057"; -} - -function setTextIfChanged(el, key, value) { - if (!el) return; - const next = String(value); - if (uiCache.stats[key] === next) return; - uiCache.stats[key] = next; - el.textContent = next; -} - -function setValueIfChanged(el, key, value) { - if (!el) return; - if (uiCache.stats[key] === value) return; - uiCache.stats[key] = value; - el.value = value; -} - -function escapeHtml(value) { - return String(value ?? "").replace(/[&<>"']/g, c => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[c])); -} - -function escapeRegExp(value) { - return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - - -function formatLogDisplayText(text) { - let out = String(text ?? ""); - const names = new Set(); - for (const t of world?.tarinai || []) if (t?.name) names.add(t.name); - for (const n of Object.values(world?.family || {})) if (n?.name) names.add(n.name); - const sorted = [...names].filter(Boolean).sort((a, b) => b.length - a.length); - for (const name of sorted) { - const re = new RegExp(`(^|[^\u300c])(${escapeRegExp(name)})(?=[\u3068\u306f\u304c\u3092\u306b\u306e\u3082\u3078\u304b\u3089\u3067\u3001\u3002]|$)`, "g"); - out = out.replace(re, (m, prefix, found) => `${prefix}\u300c${found}\u300d`); - } - return out; -} -function resizeCanvas() { - const rect = canvas.getBoundingClientRect(); - const perf = world?.performanceLevel ? world.performanceLevel() : 0; - const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2); - const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr)); - const nextW = Math.max(1, Math.floor(rect.width * dpr)); - const nextH = Math.max(1, Math.floor(rect.height * dpr)); - if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return; - uiCache.canvasDpr = dpr; - canvas.width = nextW; - canvas.height = nextH; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.fillStyle = "#cfe3d3"; - ctx.fillRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height)); - world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height)); - uiCache.lastChartDraw = ""; - if (typeof render === "function") render(); -} - -function applyFieldLayout(fieldType = world.fieldType || "garden") { - const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; - const root = document.documentElement; - root.style.setProperty("--field-height", field.height); - root.style.setProperty("--field-min-height", field.minHeight || field.height); - root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height); - root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px"); - world.fieldType = field.id; - const rect = canvas.getBoundingClientRect(); - world.setViewportSize(rect.width, rect.height); - resizeCanvas(); -} - -function openFieldDialog() { - ui.fieldDialog?.classList.remove("hidden"); -} - -function closeFieldDialog() { - ui.fieldDialog?.classList.add("hidden"); -} - -function hydrateLazyImages(root) { - const scope = root || document; - for (const img of scope.querySelectorAll("img[data-src]")) { - if (!img.getAttribute("src")) img.setAttribute("src", img.dataset.src || ""); - } -} - - -function applyToolTips() { - const tips = window.TEXT_CATALOG?.toolTips || {}; - if (!ui.toolPalette) return; - let pop = document.getElementById("toolTipPopover"); - if (!pop) { - pop = document.createElement("div"); - pop.id = "toolTipPopover"; - pop.className = "tool-tip-popover hidden"; - document.body.appendChild(pop); - } - const showTip = (btn) => { - const baseTip = btn?.dataset?.tip || ""; - if (!baseTip) return; - let tip = baseTip; - if (btn?.dataset?.tool === "nest_box") { - const count = world?.nestBoxToolTipCount?.(); - if (count) tip = `${baseTip} -(${count.current}/${count.max})\u5339`; - } - pop.textContent = tip; - pop.classList.remove("hidden"); - const rect = btn.getBoundingClientRect(); - const popW = pop.offsetWidth || 220; - const popH = pop.offsetHeight || 36; - const left = rect.left - popW - 12; - pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`; - pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`; - }; - const hideTip = () => pop.classList.add("hidden"); - for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) { - const tip = tips[btn.dataset.tool]; - if (!tip) continue; - btn.dataset.tip = tip; - if (btn.dataset.tipBound === "1") continue; - btn.dataset.tipBound = "1"; - btn.addEventListener("mouseenter", () => showTip(btn)); - btn.addEventListener("focus", () => showTip(btn)); - btn.addEventListener("mouseleave", hideTip); - btn.addEventListener("blur", hideTip); - } -} - -function renderEcologyCards() { - if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return; - const cards = window.TEXT_CATALOG?.ecologyCards || []; - ui.ecologyGrid.innerHTML = cards.map(card => ` -
    - -
    ${escapeHtml(card.title || "")}

    ${escapeHtml(card.text || "")}

    -
    `).join(""); - ui.ecologyGrid.dataset.ready = "1"; -} - -function openEcologyDialog() { - renderEcologyCards(); - hydrateLazyImages(ui.ecologyDialog); - ui.ecologyDialog?.classList.remove("hidden"); -} - -function closeEcologyDialog() { - ui.ecologyDialog?.classList.add("hidden"); -} - -function openArchiveResetDialog() { - ui.archiveResetDialog?.classList.remove("hidden"); -} - -function closeArchiveResetDialog() { - ui.archiveResetDialog?.classList.add("hidden"); -} - -function resetWithField(fieldType) { - const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; - closeFieldDialog(); - applyFieldLayout(field.id); - world.reset(null, field.id); - uiCache.archiveVersion = ""; - renderLog(world.logs); - renderArchive(); - renderStats(); - render(); -} - -function screenToWorld(e) { - const rect = canvas.getBoundingClientRect(); - const touch = e.touches ? e.touches[0] : e; - const sx = touch.clientX - rect.left; - const sy = touch.clientY - rect.top; - return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; -} - -function resizeFieldFromWheel(e) { - if (!world.setFieldZoom) return; - e.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const sx = e.clientX - rect.left; - const sy = e.clientY - rect.top; - const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; - const factor = e.deltaY > 0 ? 1 / 1.08 : 1.08; - if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return; - const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; - const scale = world.viewScale ? world.viewScale() : 1; - world.cameraX = focus.x - (sx - off.x) / scale; - world.cameraY = focus.y - (sy - off.y) / scale; - world.clampCamera?.(); - render(); -} - -function selectTool(tool) { - world.tool = tool; - for (const btn of uiCache.toolButtons) { - btn.classList.toggle("selected", btn.dataset.tool === tool); - } - syncToolSizeBadges(); - canvas.style.cursor = tool === "observe" ? "default" : (tool === "pinch" ? "grab" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair")); -} - -function showToast(text) { - ui.toast.textContent = text; - ui.toast.classList.remove("hidden"); - clearTimeout(showToast._timer); - showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400); -} - -function openCreditsDialog() { - ui.creditsDialog?.classList.remove("hidden"); -} - -function closeCreditsDialog() { - ui.creditsDialog?.classList.add("hidden"); -} - -function syncTopButtons() { - ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62"; - ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`; - if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; - syncArchiveUpdateButton(); -} - -function archiveAutoUpdateEnabled() { - return Boolean(uiCache.archiveUpdateEnabled); -} - -function syncArchiveUpdateButton() { - const btn = ui.archiveUpdateToggleBtn; - if (!btn) return; - const on = archiveAutoUpdateEnabled(); - btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF"; - btn.classList.toggle("active", on); - btn.setAttribute("aria-pressed", on ? "true" : "false"); -} - -function setArchiveUpdateEnabled(on) { - uiCache.archiveUpdateEnabled = Boolean(on); - syncArchiveUpdateButton(); - if (uiCache.archiveUpdateEnabled) { - uiCache.archiveVersion = ""; - uiCache.archiveFamilyVersion = null; - world.familyTreeDirty = true; - scheduleArchiveWindowRender?.(); - showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002"); - } else { - if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; } - uiCache.archiveScheduled = false; - showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); - } -} - -const SCALABLE_TOOLS = new Set(scalableToolIds()); -const SIZE_ORDER = ["small", "medium", "large"]; -const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; - -function toolSizeFor(tool) { - if (!SCALABLE_TOOLS.has(tool)) return ""; - return (world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium"; -} - -function cycleToolSize(tool) { - if (!SCALABLE_TOOLS.has(tool)) return false; - if (!world.toolSizes) world.toolSizes = {}; - const current = toolSizeFor(tool); - const idx = SIZE_ORDER.indexOf(current); - const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length]; - world.toolSizes[tool] = next; - world.toolSize = next; - syncToolSizeBadges(); - showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`); - return true; -} - -function syncToolSizeBadges() { - for (const btn of uiCache.toolButtons || []) { - const tool = btn.dataset.tool || ""; - const old = btn.querySelector(".tool-size-badge"); - if (!SCALABLE_TOOLS.has(tool)) { - btn.removeAttribute("data-size"); - if (old) old.remove(); - continue; - } - const size = toolSizeFor(tool); - btn.dataset.size = size; - let badge = old; - if (!badge) { - badge = document.createElement("b"); - badge.className = "tool-size-badge"; - btn.appendChild(badge); - } - badge.textContent = SIZE_LABELS[size] || size; - } -} - -function bindUI() { - applyToolTips(); - renderEcologyCards(); - uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); - syncToolSizeBadges(); - ui.colonyChartTabs?.addEventListener("click", (e) => { - const btn = e.target.closest("button[data-chart]"); - if (!btn) return; - uiCache.chartMode = btn.dataset.chart || "population"; - for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn); - uiCache.lastChartDraw = ""; - uiCache.lastChartDrawAt = 0; - renderStats(); - }); - syncToolSizeBadges(); - ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true }); - window.addEventListener("scroll", schedulePendingArchiveRender, { passive: true }); - window.addEventListener("resize", schedulePendingArchiveRender, { passive: true }); - const bindArchiveHorizontal = (root) => { - if (!root) return; - root.addEventListener("wheel", (e) => { - const scroller = e.target.closest(".lineage-family-tree, .lineage-main-content"); - if (!scroller) return; - const canX = scroller.scrollWidth > scroller.clientWidth + 4; - if (!canX) return; - const dominant = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0); - if (!dominant) return; - e.preventDefault(); - scroller.scrollLeft += dominant; - }, { passive: false }); - }; - bindArchiveHorizontal(ui.archiveContent); - ui.archiveContent?.addEventListener("click", (e) => { - const node = e.target.closest("[data-family-tarinai-id]"); - if (!node) return; - const id = node.getAttribute("data-family-tarinai-id"); - const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead); - if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } - world.selected = t; - t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); - selectTool("observe"); - if (Number.isFinite(t.x) && Number.isFinite(t.y)) { - world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; - world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; - world.clampCamera?.(); - } - renderStats(); - showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); - }); - - if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { - uiCache.archiveAutoRefreshTimer = window.setInterval(() => { - if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; - scheduleArchiveWindowRender(); - }, 5000); - } - - ui.selectedInfo?.addEventListener("click", (e) => { - const categoryToggle = e.target.closest(".selected-category-toggle"); - if (categoryToggle) { - const category = categoryToggle.closest(".selected-category"); - const id = category?.dataset?.selectedCategory || ""; - if (!category || !id) return; - if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); - category.classList.toggle("collapsed"); - const collapsed = category.classList.contains("collapsed"); - if (collapsed) uiCache.selectedCollapsedCategories.add(id); - else uiCache.selectedCollapsedCategories.delete(id); - categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - return; - } - const focusBtn = e.target.closest("[data-focus-tarinai-id]"); - if (focusBtn) { - e.preventDefault(); - focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id")); - return; - } - const btn = e.target.closest("[data-selected-action=\"favorite\"]"); - if (!btn || !world.selected || world.selected.dead) return; - world.selected.favorite = !world.selected.favorite; - uiCache.selectedSnapshot = ""; - renderSelected(); - render(); - showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); - }); - ui.selectedInfo?.addEventListener("change", (e) => { - const input = e.target.closest("[data-selected-name]"); - if (!input) return; - renameSelectedTarinai(input.value); - }); - ui.selectedInfo?.addEventListener("keydown", (e) => { - const input = e.target.closest("[data-selected-name]"); - if (!input) return; - if (e.key === "Enter") { e.preventDefault(); input.blur(); } - if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); } - }); - ui.selectedCloseBtn?.addEventListener("click", () => { - world.selected = null; - uiCache.selectedSnapshot = ""; - uiCache.selectedEmpty = false; - renderSelected(); - render(); - }); - - ui.pauseBtn?.addEventListener("click", () => { - world.paused = !world.paused; - const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); - if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { - uiCache.archivePendingWhileRunning = false; - scheduleArchiveWindowRender(); - } - syncTopButtons(); - }); - ui.speedBtn?.addEventListener("click", () => { - const speeds = [1, 2, 4]; - const i = speeds.indexOf(world.speed); - world.speed = speeds[(i + 1) % speeds.length]; - syncTopButtons(); - }); - ui.ecologyBtn?.addEventListener("click", openEcologyDialog); - ui.soundBtn?.addEventListener("click", () => { - const on = audio.toggle(); - ui.soundBtn.textContent = on ? "\u97f3 ON" : "\u97f3 OFF"; - showToast(on ? "\u97f3\u3092\u6709\u52b9\u306b\u3057\u307e\u3057\u305f\u3002" : "\u97f3\u3092\u7121\u52b9\u306b\u3057\u307e\u3057\u305f\u3002"); - }); - ui.creditsBtn?.addEventListener("click", openCreditsDialog); - ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog); - ui.creditsDialog?.addEventListener("click", (e) => { - if (e.target === ui.creditsDialog) closeCreditsDialog(); - }); - ui.resetBtn?.addEventListener("click", () => { - openFieldDialog(); - }); - ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog); - ui.fieldDialog?.addEventListener("click", (e) => { - if (e.target === ui.fieldDialog) { closeFieldDialog(); return; } - const btn = e.target.closest("[data-field-type]"); - if (!btn) return; - resetWithField(btn.dataset.fieldType || "garden"); - }); - ui.ecologyCloseBtn?.addEventListener("click", closeEcologyDialog); - ui.ecologyDialog?.addEventListener("click", (e) => { - if (e.target === ui.ecologyDialog) closeEcologyDialog(); - }); - ui.archiveUpdateToggleBtn?.addEventListener("click", () => setArchiveUpdateEnabled(!archiveAutoUpdateEnabled())); - syncArchiveUpdateButton(); - ui.archiveResetBtn?.addEventListener("click", openArchiveResetDialog); - ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog); - ui.archiveResetDialog?.addEventListener("click", (e) => { - if (e.target === ui.archiveResetDialog) closeArchiveResetDialog(); - }); - ui.archiveResetConfirmBtn?.addEventListener("click", () => { - world.resetFamilyTree?.(); - window.resetArchiveRenderState?.(); - closeArchiveResetDialog(); - renderArchive(); - renderSelected(); - showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002"); - }); - - ui.toolPalette?.addEventListener("click", (e) => { - const toggle = e.target.closest(".tool-category-toggle"); - if (toggle) { - const category = toggle.closest(".tool-category"); - if (!category) return; - category.classList.toggle("collapsed"); - toggle.setAttribute("aria-expanded", category.classList.contains("collapsed") ? "false" : "true"); - return; - } - const btn = e.target.closest(".tool"); - if (!btn) return; - const tool = btn.dataset.tool; - if (world.tool === tool && cycleToolSize(tool)) return; - selectTool(tool); - }); - - const chainScroll = (el) => { - if (!el) return; - el.addEventListener("wheel", (e) => { - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - const atTop = el.scrollTop <= 0; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; - if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { - const page = document.scrollingElement || document.documentElement; - page.scrollTop += e.deltaY; - } - }, { passive: true }); - }; - const containOwnVerticalScroll = (el) => { - if (!el) return; - el.addEventListener("wheel", (e) => { - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - const canScroll = el.scrollHeight > el.clientHeight + 1; - if (!canScroll) return; - const atTop = el.scrollTop <= 0; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; - const movingInside = (e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom); - if (!movingInside) return; - e.preventDefault(); - e.stopPropagation(); - el.scrollTop += e.deltaY; - }, { passive: false }); - }; - chainScroll(document.querySelector(".panel")); - chainScroll(ui.archiveContent); - containOwnVerticalScroll(ui.log); - - canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); - canvas?.addEventListener("mousedown", (e) => { - if (e.button === 0 && world.tool === "water_hose") { - const p = screenToWorld(e); - if (!p.inside) return; - e.preventDefault(); - uiCache.hosing = true; - uiCache.hoseMoved = false; - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - uiCache.hoseStartX = e.clientX; - uiCache.hoseStartY = e.clientY; - world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); - render(); - return; - } - if (e.button === 0 && world.tool === "pinch") { - const p = screenToWorld(e); - if (!p.inside) return; - const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; - if (!target) { - showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); - return; - } - e.preventDefault(); - uiCache.grabbing = true; - uiCache.grabMoved = false; - uiCache.grabTarget = target; - uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - uiCache.grabStartX = e.clientX; - uiCache.grabStartY = e.clientY; - if (uiCache.grabKind === "tarinai") { - world.selected = target; - target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.state = "idle"; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); - } else { - target.dropTimer = 0; - target.dropMax = 0; - target.dropImpactDone = true; - } - canvas.style.cursor = "grabbing"; - renderStats(); - return; - } - if (e.button !== 2) return; - e.preventDefault(); - uiCache.panning = true; - uiCache.panMoved = false; - uiCache.panButton = e.button; - uiCache.panStartX = e.clientX; - uiCache.panStartY = e.clientY; - uiCache.panLastX = e.clientX; - uiCache.panLastY = e.clientY; - }); - window.addEventListener("mouseup", (e) => { - if (uiCache.hosing) { - uiCache.hosing = false; - canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"))); - world.rebuildSpatial?.(); - renderStats(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (target) { - if (uiCache.grabKind === "tarinai") { - target.target = null; - target.targetKey = ""; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; - } - } - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = 0; - canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - renderStats(); - return; - } - if (!uiCache.panning) return; - if (uiCache.panButton !== null && e.button !== uiCache.panButton) return; - uiCache.panning = false; - uiCache.panButton = null; - canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - }); - window.addEventListener("mousemove", (e) => { - if (uiCache.hosing) { - const p = screenToWorld(e); - const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); - const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY)); - if (totalMove > 3) uiCache.hoseMoved = true; - if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); - canvas.style.cursor = "crosshair"; - render(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (!target || target.dead) { - uiCache.grabbing = false; - uiCache.grabTarget = null; - canvas.style.cursor = world.tool === "pinch" ? "grab" : "default"; - return; - } - const p = screenToWorld(e); - const dx = p.x - (uiCache.grabLastWorldX ?? p.x); - const dy = p.y - (uiCache.grabLastWorldY ?? p.y); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); - if (totalMove > 3) uiCache.grabMoved = true; - const pad = target.radius || target.r || 16; - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); - if (uiCache.grabKind === "tarinai") { - target.state = "idle"; - target.target = null; - target.sleeping = false; - } else if (target.type === "ball") { - target.prevX = target.x - dx; - target.prevY = target.y - dy; - target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); - } - world.drawListDirty = true; - const now = performance.now(); - if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = now; - } - canvas.style.cursor = "grabbing"; - return; - } - if (!uiCache.panning) return; - const dx = e.clientX - uiCache.panLastX; - const dy = e.clientY - uiCache.panLastY; - uiCache.panLastX = e.clientX; - uiCache.panLastY = e.clientY; - const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY); - if (totalMove > 8) { - canvas.style.cursor = "grabbing"; - world.panCamera?.(-dx, -dy); - uiCache.panMoved = true; - render(); - } - }); - - canvas?.addEventListener("click", (e) => { - if (uiCache.hoseMoved) { - uiCache.hoseMoved = false; - return; - } - if (uiCache.grabMoved) { - uiCache.grabMoved = false; - return; - } - if (uiCache.panMoved) { - uiCache.panMoved = false; - return; - } - const p = screenToWorld(e); - if (!p.inside) return; - world.handleClick(p.x, p.y); - renderStats(); - }); - - canvas?.addEventListener("mousemove", (e) => { - const p = screenToWorld(e); - const prevX = world.pointer.x; - const prevY = world.pointer.y; - world.pointer.x = p.x; - world.pointer.y = p.y; - world.pointer.inside = !!p.inside; - const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0)); - world.pointer.motion = clamp(delta / 18, 0, 2); - world.pointer.movedAt = performance.now(); - }); - canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); - canvas?.addEventListener("mouseleave", () => { - world.pointer.inside = false; - world.pointer.motion = 0; - }); - - window.addEventListener("keydown", (e) => { - if (e.key !== "Escape") return; - closeFieldDialog(); - closeEcologyDialog(); - closeArchiveResetDialog(); - closeCreditsDialog(); - }); - - window.addEventListener("resize", resizeCanvas); -} diff --git a/js/ui_bind.js b/js/ui_bind.js new file mode 100644 index 0000000..62cfbce --- /dev/null +++ b/js/ui_bind.js @@ -0,0 +1,826 @@ +"use strict"; + +function bindUI() { + renderToolPalette(); + loadLogPushSettings(); + syncLogPushControls(); + syncAudioControls(); + applyToolTips(); + renderEcologyCards(); + window.TarinaiSaveSystem?.bindSaveSystem?.(); + window.TarinaiFreezeSystem?.bindFreezeSystem?.(); + uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); + syncToolSizeBadges(); + for (const toggle of document.querySelectorAll(".panel-card-toggle")) { + toggle.addEventListener("click", () => { + const card = toggle.closest(".collapsible-card"); + if (!card) return; + audio.uiFold?.(); + const collapsed = card.classList.toggle("collapsed"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + const state = toggle.querySelector(".panel-card-state"); + if (state) state.textContent = collapsed ? "\u5c55\u958b" : "\u6298\u308a\u7573\u307f"; + }); + } + ui.colonyChartTabs?.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-chart]"); + if (!btn) return; + uiCache.chartMode = btn.dataset.chart || "population"; + for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn); + uiCache.lastChartDraw = ""; + uiCache.lastChartDrawAt = 0; + renderStats(); + }); + syncToolSizeBadges(); + ui.logPushEnabled?.addEventListener("change", () => { + uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked); + audio.notify?.(); + saveLogPushSettings(); + 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) => { + const input = e.target.closest("[data-push-kind]"); + if (!input) return; + const kind = input.dataset.pushKind || ""; + audio.uiClick?.(); + if (input.checked) uiCache.logPushKinds.add(kind); + else uiCache.logPushKinds.delete(kind); + saveLogPushSettings(); + }); + ui.soundCategoryControls?.addEventListener("change", (e) => { + const input = e.target.closest("[data-sound-category]"); + if (!input) return; + const category = input.dataset.soundCategory || ""; + audio.setCategory?.(category, Boolean(input.checked)); + if (audio.enabled && input.checked) audio.uiClick?.(); + syncAudioControls(); + showToast(`${audio.soundCategories?.[category] || category} ${input.checked ? "ON" : "OFF"}`); + }); + ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true }); + window.addEventListener("scroll", schedulePendingArchiveRender, { passive: true }); + window.addEventListener("resize", schedulePendingArchiveRender, { passive: true }); + const bindArchiveHorizontal = (root) => { + if (!root) return; + root.addEventListener("wheel", (e) => { + const scroller = e.target.closest(".lineage-family-tree, .lineage-main-content"); + if (!scroller) return; + const canX = scroller.scrollWidth > scroller.clientWidth + 4; + if (!canX) return; + const dominant = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0); + if (!dominant) return; + e.preventDefault(); + scroller.scrollLeft += dominant; + }, { passive: false }); + }; + bindArchiveHorizontal(ui.archiveContent); + ui.archiveContent?.addEventListener("click", (e) => { + const node = e.target.closest("[data-family-tarinai-id]"); + if (!node) return; + const id = node.getAttribute("data-family-tarinai-id"); + const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead); + if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } + world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); + selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } + renderStats(); + showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); + }); + + if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") { + uiCache.archiveAutoRefreshTimer = window.setInterval(() => { + if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return; + scheduleArchiveWindowRender(); + }, 5000); + } + + ui.selectedInfo?.addEventListener("click", (e) => { + const categoryToggle = e.target.closest(".selected-category-toggle"); + if (categoryToggle) { + const category = categoryToggle.closest(".selected-category"); + const id = category?.dataset?.selectedCategory || ""; + if (!category || !id) return; + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + audio.uiFold?.(); + category.classList.toggle("collapsed"); + const collapsed = category.classList.contains("collapsed"); + if (collapsed) uiCache.selectedCollapsedCategories.add(id); + else uiCache.selectedCollapsedCategories.delete(id); + categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + return; + } + const focusBtn = e.target.closest("[data-focus-tarinai-id]"); + if (focusBtn) { + e.preventDefault(); + focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id")); + return; + } + const btn = e.target.closest("[data-selected-action=\"favorite\"]"); + if (!btn || !world.selected || world.selected.dead) return; + world.selected.favorite = !world.selected.favorite; + uiCache.selectedSnapshot = ""; + renderSelected(); + render(); + showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); + }); + ui.selectedInfo?.addEventListener("change", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + renameSelectedTarinai(input.value); + }); + ui.selectedInfo?.addEventListener("keydown", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + if (e.key === "Enter") { e.preventDefault(); input.blur(); } + if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); } + }); + ui.selectedCloseBtn?.addEventListener("click", () => { + world.selected = null; + uiCache.selectedSnapshot = ""; + uiCache.selectedEmpty = false; + renderSelected(); + render(); + }); + + ui.pauseBtn?.addEventListener("click", () => { + audio.uiClick?.(); + world.paused = !world.paused; + const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0); + if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) { + uiCache.archivePendingWhileRunning = false; + scheduleArchiveWindowRender(); + } + syncTopButtons(); + }); + ui.speedBtn?.addEventListener("click", () => { + audio.uiClick?.(); + const speeds = [1, 2, 4]; + const i = speeds.indexOf(world.speed); + world.speed = speeds[(i + 1) % speeds.length]; + syncTopButtons(); + }); + ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); openEcologyDialog(); }); + ui.soundBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + toggleSoundPanel(); + }); + ui.soundPanel?.addEventListener("click", (e) => e.stopPropagation()); + ui.soundMasterToggle?.addEventListener("change", () => { + const on = audio.setEnabled ? audio.setEnabled(ui.soundMasterToggle.checked) : audio.toggle(); + if (on) audio.uiClick?.(); + syncAudioControls(); + 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) => { + if (!ui.soundMenu || ui.soundMenu.contains(e.target)) return; + closeSoundPanel(); + }); + ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); }); + ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog); + ui.creditsDialog?.addEventListener("click", (e) => { + if (e.target === ui.creditsDialog) closeCreditsDialog(); + }); + ui.resetBtn?.addEventListener("click", () => { + audio.uiClick?.(); + openFieldDialog(); + }); + ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog); + ui.fieldDialog?.addEventListener("click", (e) => { + if (e.target === ui.fieldDialog) { closeFieldDialog(); return; } + const btn = e.target.closest("[data-field-type]"); + if (!btn) return; + resetWithField(btn.dataset.fieldType || "garden"); + }); + ui.ecologyCloseBtn?.addEventListener("click", closeEcologyDialog); + ui.ecologyDialog?.addEventListener("click", (e) => { + if (e.target === ui.ecologyDialog) closeEcologyDialog(); + }); + ui.archiveUpdateToggleBtn?.addEventListener("click", () => { audio.uiClick?.(); setArchiveUpdateEnabled(!archiveAutoUpdateEnabled()); }); + syncArchiveUpdateButton(); + ui.archiveResetBtn?.addEventListener("click", () => { audio.uiClick?.(); openArchiveResetDialog(); }); + ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog); + ui.archiveResetDialog?.addEventListener("click", (e) => { + if (e.target === ui.archiveResetDialog) closeArchiveResetDialog(); + }); + ui.archiveResetConfirmBtn?.addEventListener("click", () => { + world.resetFamilyTree?.(); + window.resetArchiveRenderState?.(); + closeArchiveResetDialog(); + renderArchive(); + renderSelected(); + showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002"); + }); + + ui.toolPalette?.addEventListener("click", (e) => { + const toggle = e.target.closest(".tool-category-toggle"); + if (toggle) { + const category = toggle.closest(".tool-category"); + if (!category) return; + audio.uiFold?.(); + category.classList.toggle("collapsed"); + toggle.setAttribute("aria-expanded", category.classList.contains("collapsed") ? "false" : "true"); + return; + } + const btn = e.target.closest(".tool"); + if (!btn) return; + const tool = btn.dataset.tool; + audio.uiClick?.(); + if (world.tool === tool && cycleToolSize(tool)) return; + selectTool(tool); + }); + + const chainScroll = (el) => { + if (!el) return; + el.addEventListener("wheel", (e) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const page = document.scrollingElement || document.documentElement; + page.scrollTop += e.deltaY; + } + }, { passive: true }); + }; + const transferEdgeScroll = (child, parent) => { + if (!child || !parent) return; + child.addEventListener("wheel", (e) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const childCanScroll = child.scrollHeight > child.clientHeight + 1; + const atTop = child.scrollTop <= 0; + const atBottom = child.scrollTop + child.clientHeight >= child.scrollHeight - 1; + const movingInside = childCanScroll && ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)); + e.preventDefault(); + e.stopPropagation(); + if (movingInside) { + child.scrollTop += e.deltaY; + return; + } + parent.scrollTop += e.deltaY; + }, { passive: false }); + }; + const rightPanel = document.querySelector(".panel"); + chainScroll(rightPanel); + chainScroll(ui.archiveContent); + transferEdgeScroll(ui.log, rightPanel); + + const inputModeManager = window.TarinaiInputMode; + const setMobileInputMode = (mode = "auto") => { + mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto"); + uiCache.mobileInputMode = mode; + ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode)); + document.body.classList.toggle("mobile-mode-camera", mode === "camera"); + document.body.classList.toggle("mobile-mode-tool", mode === "tool"); + document.body.classList.toggle("mobile-mode-family", mode === "family"); + }; + const updateTouchMobileUiClass = () => { + const enabled = Boolean(inputModeManager?.refresh?.().touchFirst); + document.body.classList.toggle("touch-mobile-ui", enabled); + if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled; + }; + const mobileInputMode = () => inputModeManager?.currentMode || uiCache.mobileInputMode || "auto"; + updateTouchMobileUiClass(); + window.addEventListener("resize", updateTouchMobileUiClass, { passive: true }); + window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass); + setMobileInputMode(uiCache.mobileInputMode || "auto"); + ui.mobileModeControls?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-mobile-mode]"); + if (!btn) return; + audio.uiClick?.(); + setMobileInputMode(btn.dataset.mobileMode || "auto"); + showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`); + }); + + const touchPoint = (touches, index = 0) => { + const t = touches?.[index]; + if (!t) return null; + return { clientX: t.clientX, clientY: t.clientY }; + }; + const touchCenter = (touches) => { + const a = touchPoint(touches, 0); + const b = touchPoint(touches, 1); + if (!a) return null; + if (!b) return a; + return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 }; + }; + const touchDistance = (touches) => { + const a = touchPoint(touches, 0); + const b = touchPoint(touches, 1); + if (!a || !b) return 0; + return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + }; + const screenToWorldClient = (clientX, clientY) => { + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 }; + }; + const clientToWorldRaw = (clientX, clientY) => { + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const scale = world.viewScale ? world.viewScale() : 1; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const rawX = (sx - off.x) / scale + (world.cameraX || 0); + const rawY = (sy - off.y) / scale + (world.cameraY || 0); + return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h }; + }; + const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => { + if (!world.setFieldZoom) return false; + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; + if (!world.setFieldZoom(nextZoom)) return false; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const scale = world.viewScale ? world.viewScale() : 1; + world.cameraX = focus.x - (sx - off.x) / scale; + world.cameraY = focus.y - (sy - off.y) / scale; + world.clampCamera?.(); + return true; + }; + + function isClientOverFrozenPanel(clientX, clientY) { + const panel = document.getElementById("frozenPanel"); + if (!panel) return false; + const r = panel.getBoundingClientRect(); + return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; + } + + function rememberGrabOrigin(target) { + if (!target || uiCache.grabKind !== "tarinai") return; + uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 }; + } + + function finishTarinaiGrab(target, clientX, clientY) { + if (!target || uiCache.grabKind !== "tarinai") return false; + if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) { + window.TarinaiFreezeSystem.freezeTarinai(target, world); + uiCache.grabOrigin = null; + return true; + } + const p = screenToWorldClient(clientX, clientY); + if (!p.inside && uiCache.grabOrigin) { + target.x = uiCache.grabOrigin.x; + target.y = uiCache.grabOrigin.y; + target.vx = uiCache.grabOrigin.vx || 0; + target.vy = uiCache.grabOrigin.vy || 0; + target.thought = "フィールドに戻された"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); + showToast("フィールド外なので元の位置に戻しました。"); + uiCache.grabOrigin = null; + return true; + } + uiCache.grabOrigin = null; + return false; + } + const beginTouchGrab = (p, e) => { + const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; + if (!target) return false; + audio.grab?.(); + uiCache.grabbing = true; + uiCache.grabMoved = false; + uiCache.grabTarget = target; + uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; + rememberGrabOrigin(target); + uiCache.grabLastWorldX = p.x; + uiCache.grabLastWorldY = p.y; + uiCache.grabStartX = e.touches?.[0]?.clientX || 0; + uiCache.grabStartY = e.touches?.[0]?.clientY || 0; + if (uiCache.grabKind === "tarinai") { + target.sleeping = false; + if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + target.thought = "つままれている"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); + } else { + if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); + target.dropTimer = 0; + target.dropMax = 0; + target.dropImpactDone = true; + } + uiCache.touchMode = "grab"; + renderStats(); + return true; + }; + + canvas?.addEventListener("touchstart", (e) => { + if (!e.touches?.length) return; + e.preventDefault(); + uiCache.touchMoved = false; + const mode = mobileInputMode(); + if ((mode === "camera" || mode === "family") && e.touches.length === 1) { + const t = e.touches[0]; + uiCache.touchStartX = t.clientX; + uiCache.touchStartY = t.clientY; + uiCache.touchLastX = t.clientX; + uiCache.touchLastY = t.clientY; + uiCache.touchMode = "cameraPan"; + return; + } + if (e.touches.length >= 2) { + const c = touchCenter(e.touches); + uiCache.touchMode = "pinchZoom"; + uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches)); + uiCache.pinchStartZoom = world.fieldZoom || 1; + uiCache.pinchLastCenterX = c?.clientX || 0; + uiCache.pinchLastCenterY = c?.clientY || 0; + return; + } + const t = e.touches[0]; + const p = screenToWorldClient(t.clientX, t.clientY); + uiCache.touchStartX = t.clientX; + uiCache.touchStartY = t.clientY; + uiCache.touchLastX = t.clientX; + uiCache.touchLastY = t.clientY; + uiCache.touchLastWorldX = p.x; + uiCache.touchLastWorldY = p.y; + if (mode === "tool" && world.tool === "observe") { + uiCache.touchMode = "toolTapOnly"; + return; + } + if (world.tool === "water_hose" && p.inside) { + audio.waterHose?.(); + uiCache.hosing = true; + uiCache.hoseMoved = false; + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + uiCache.hoseStartX = t.clientX; + uiCache.hoseStartY = t.clientY; + world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); + uiCache.touchMode = "hose"; + render(); + return; + } + if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return; + uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap"; + }, { passive: false }); + + canvas?.addEventListener("touchmove", (e) => { + if (!e.touches?.length || !uiCache.touchMode) return; + e.preventDefault(); + if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) { + const c = touchCenter(e.touches); + const distNow = Math.max(1, touchDistance(e.touches)); + const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow); + setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio); + const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX); + const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY); + uiCache.pinchLastCenterX = c.clientX; + uiCache.pinchLastCenterY = c.clientY; + if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy); + uiCache.touchMoved = true; + render(); + return; + } + const t = e.touches[0]; + const p = screenToWorldClient(t.clientX, t.clientY); + const screenDx = t.clientX - (uiCache.touchLastX || t.clientX); + const screenDy = t.clientY - (uiCache.touchLastY || t.clientY); + const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY)); + uiCache.touchLastX = t.clientX; + uiCache.touchLastY = t.clientY; + if (uiCache.touchMode === "cameraPan") { + if (totalMove > 2) uiCache.touchMoved = true; + world.panCamera?.(-screenDx, -screenDy); + render(); + return; + } + if (uiCache.touchMode === "toolTapOnly") { + if (totalMove > 8) uiCache.touchMoved = true; + return; + } + if (uiCache.touchMode === "hose" && uiCache.hosing) { + const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); + const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; } + if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); + render(); + return; + } + if (uiCache.touchMode === "grab" && uiCache.grabbing) { + const target = uiCache.grabTarget; + if (!target || target.dead) { + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.touchMode = ""; + return; + } + const dx = p.x - (uiCache.grabLastWorldX ?? p.x); + const dy = p.y - (uiCache.grabLastWorldY ?? p.y); + uiCache.grabLastWorldX = p.x; + uiCache.grabLastWorldY = p.y; + if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } + const pad = target.radius || target.r || 16; + if (uiCache.grabKind === "tarinai" && !p.inside) { + const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0); + target.x = raw.x; + target.y = raw.y; + } else { + target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); + target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + } + target.vx = clamp(dx * 16, -160, 160); + target.vy = clamp(dy * 16, -160, 160); + if (uiCache.grabKind === "tarinai") { + target.goIdle?.("つままれている"); + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); + } else if (target.type === "ball") { + target.prevX = target.x - dx; + target.prevY = target.y - dy; + target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); + } + world.drawListDirty = true; + const now = performance.now(); + if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = now; + } + render(); + return; + } + if (uiCache.touchMode === "panOrTap") { + if (totalMove > 7) { + uiCache.touchMoved = true; + world.panCamera?.(-screenDx, -screenDy); + render(); + } + } + }, { passive: false }); + + canvas?.addEventListener("touchend", (e) => { + e.preventDefault(); + if (uiCache.hosing) { + uiCache.hosing = false; + world.rebuildSpatial?.(); + renderStats(); + } + if (uiCache.grabbing) { + const target = uiCache.grabTarget; + if (target) { + if (uiCache.grabKind === "tarinai") { + target.target = null; + target.targetKey = ""; + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); + finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); + } else if (target.type === "ball") { + target.prevX = target.x; + target.prevY = target.y; + } + } + audio.drop?.(); + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.grabKind = ""; + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = 0; + renderStats(); + } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { + const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); + if (p.inside) { + world.handleClick(p.x, p.y); + renderStats(); + } + } else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) { + const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); + if (p.inside) { + world.handleClick(p.x, p.y); + renderStats(); + } + } + if (!e.touches?.length) { + uiCache.touchMode = ""; + uiCache.touchMoved = false; + } + }, { passive: false }); + + canvas?.addEventListener("touchcancel", () => { + uiCache.touchMode = ""; + uiCache.touchMoved = false; + uiCache.hosing = false; + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.grabKind = ""; + }, { passive: true }); + + + canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); + canvas?.addEventListener("mousedown", (e) => { + if (e.button === 0 && world.tool === "water_hose") { + audio.waterHose?.(); + const p = screenToWorld(e); + if (!p.inside) return; + e.preventDefault(); + uiCache.hosing = true; + uiCache.hoseMoved = false; + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + uiCache.hoseStartX = e.clientX; + uiCache.hoseStartY = e.clientY; + world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); + render(); + return; + } + if (e.button === 0 && world.tool === "pinch") { + const p = screenToWorld(e); + if (!p.inside) return; + const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; + if (!target) { + showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); + return; + } + e.preventDefault(); + audio.grab?.(); + uiCache.grabbing = true; + uiCache.grabMoved = false; + uiCache.grabTarget = target; + uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; + rememberGrabOrigin(target); + uiCache.grabLastWorldX = p.x; + uiCache.grabLastWorldY = p.y; + uiCache.grabStartX = e.clientX; + uiCache.grabStartY = e.clientY; + if (uiCache.grabKind === "tarinai") { + target.sleeping = false; + if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + target.thought = "つままれている"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); + } else { + if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); + target.dropTimer = 0; + target.dropMax = 0; + target.dropImpactDone = true; + } + canvas.style.cursor = "grabbing"; + renderStats(); + return; + } + if (e.button !== 2) return; + e.preventDefault(); + uiCache.panning = true; + uiCache.panMoved = false; + uiCache.panButton = e.button; + uiCache.panStartX = e.clientX; + uiCache.panStartY = e.clientY; + uiCache.panLastX = e.clientX; + uiCache.panLastY = e.clientY; + }); + window.addEventListener("mouseup", (e) => { + if (uiCache.hosing) { + uiCache.hosing = false; + canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"))); + world.rebuildSpatial?.(); + renderStats(); + return; + } + if (uiCache.grabbing) { + const target = uiCache.grabTarget; + if (target) { + if (uiCache.grabKind === "tarinai") { + target.target = null; + target.targetKey = ""; + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); + finishTarinaiGrab(target, e.clientX, e.clientY); + } else if (target.type === "ball") { + target.prevX = target.x; + target.prevY = target.y; + } + } + audio.drop?.(); + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.grabKind = ""; + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = 0; + canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); + renderStats(); + return; + } + if (!uiCache.panning) return; + if (uiCache.panButton !== null && e.button !== uiCache.panButton) return; + uiCache.panning = false; + uiCache.panButton = null; + canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); + }); + window.addEventListener("mousemove", (e) => { + if (uiCache.hosing) { + const p = screenToWorld(e); + const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); + const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY)); + if (totalMove > 3) uiCache.hoseMoved = true; + if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); + canvas.style.cursor = "crosshair"; + render(); + return; + } + if (uiCache.grabbing) { + const target = uiCache.grabTarget; + if (!target || target.dead) { + uiCache.grabbing = false; + uiCache.grabTarget = null; + canvas.style.cursor = world.tool === "pinch" ? "grab" : "default"; + return; + } + const p = screenToWorld(e); + const dx = p.x - (uiCache.grabLastWorldX ?? p.x); + const dy = p.y - (uiCache.grabLastWorldY ?? p.y); + uiCache.grabLastWorldX = p.x; + uiCache.grabLastWorldY = p.y; + const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); + if (totalMove > 3) uiCache.grabMoved = true; + const pad = target.radius || target.r || 16; + if (uiCache.grabKind === "tarinai" && !p.inside) { + const raw = clientToWorldRaw(e.clientX, e.clientY); + target.x = raw.x; + target.y = raw.y; + } else { + target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); + target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + } + target.vx = clamp(dx * 16, -160, 160); + target.vy = clamp(dy * 16, -160, 160); + if (uiCache.grabKind === "tarinai") { + target.goIdle?.("つままれている"); + } else if (target.type === "ball") { + target.prevX = target.x - dx; + target.prevY = target.y - dy; + target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); + } + world.drawListDirty = true; + const now = performance.now(); + if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = now; + } + canvas.style.cursor = "grabbing"; + return; + } + if (!uiCache.panning) return; + const dx = e.clientX - uiCache.panLastX; + const dy = e.clientY - uiCache.panLastY; + uiCache.panLastX = e.clientX; + uiCache.panLastY = e.clientY; + const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY); + if (totalMove > 8) { + canvas.style.cursor = "grabbing"; + world.panCamera?.(-dx, -dy); + uiCache.panMoved = true; + render(); + } + }); + + canvas?.addEventListener("click", (e) => { + if (uiCache.hoseMoved) { + uiCache.hoseMoved = false; + return; + } + if (uiCache.grabMoved) { + uiCache.grabMoved = false; + return; + } + if (uiCache.panMoved) { + uiCache.panMoved = false; + return; + } + const p = screenToWorld(e); + if (!p.inside) return; + world.handleClick(p.x, p.y); + renderStats(); + }); + + canvas?.addEventListener("mousemove", (e) => { + const p = screenToWorld(e); + const prevX = world.pointer.x; + const prevY = world.pointer.y; + world.pointer.x = p.x; + world.pointer.y = p.y; + world.pointer.inside = !!p.inside; + const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0)); + world.pointer.motion = clamp(delta / 18, 0, 2); + world.pointer.movedAt = performance.now(); + }); + canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); + canvas?.addEventListener("mouseleave", () => { + world.pointer.inside = false; + world.pointer.motion = 0; + }); + + window.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + closeFieldDialog(); + closeEcologyDialog(); + closeArchiveResetDialog(); + closeCreditsDialog(); + }); + + window.addEventListener("resize", resizeCanvas); +} diff --git a/js/ui_charts.js b/js/ui_charts.js index caf9f7b..d2217a4 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -10,7 +10,7 @@ function renderStats() { hunger: avg(t => t.hunger), lonely: avg(t => t.loneliness), stress: avg(t => t.stress), - lack: avg(t => t.lack), + mood: avg(t => t.mood), security: environment.security, hygiene: environment.hygiene, happiness: environment.happiness, @@ -18,6 +18,7 @@ function renderStats() { }; setTextIfChanged(ui.statPop, "pop", values.pop); setTextIfChanged(ui.statDead, "dead", values.dead); + setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "のんびり"); updateColonyHistory(values); drawColonyChart(values); renderSelected(); @@ -28,7 +29,7 @@ function updateColonyHistory(values) { const t = Math.max(0, world.time || 0); const history = uiCache.chartHistory; const interval = Math.max(1, (CONFIG.dayLength || 120) / 12); - const maxPoints = 7 * 12; + const maxPoints = 5 * 12; if ((history.length && t < history[history.length - 1].t) || (Number.isFinite(uiCache.chartBucketStart) && t < uiCache.chartBucketStart)) { history.length = 0; uiCache.chartBucketStart = -Infinity; @@ -42,7 +43,7 @@ function updateColonyHistory(values) { start, end: start + interval, count: 0, - sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, lack: 0, security: 0, hygiene: 0, happiness: 0 }, + sums: { pop: 0, hunger: 0, lonely: 0, stress: 0, mood: 0, security: 0, hygiene: 0, happiness: 0 }, objects: Object.fromEntries(objectKeys.map(k => [k, 0])), }); if (!uiCache.chartBucketAccum) { @@ -59,7 +60,7 @@ function updateColonyHistory(values) { hunger: bucket.sums.hunger / bucket.count, lonely: bucket.sums.lonely / bucket.count, stress: bucket.sums.stress / bucket.count, - lack: bucket.sums.lack / bucket.count, + mood: bucket.sums.mood / bucket.count, security: bucket.sums.security / bucket.count, hygiene: bucket.sums.hygiene / bucket.count, happiness: bucket.sums.happiness / bucket.count, @@ -81,7 +82,7 @@ function updateColonyHistory(values) { bucket.sums.hunger += values.hunger || 0; bucket.sums.lonely += values.lonely || 0; bucket.sums.stress += values.stress || 0; - bucket.sums.lack += values.lack || 0; + bucket.sums.mood += values.mood || 0; bucket.sums.security += values.security || 0; bucket.sums.hygiene += values.hygiene || 0; bucket.sums.happiness += values.happiness || 0; @@ -97,7 +98,7 @@ function chartRows(values) { hunger: bucket.sums.hunger / bucket.count, lonely: bucket.sums.lonely / bucket.count, stress: bucket.sums.stress / bucket.count, - lack: bucket.sums.lack / bucket.count, + mood: bucket.sums.mood / bucket.count, security: bucket.sums.security / bucket.count, hygiene: bucket.sums.hygiene / bucket.count, happiness: bucket.sums.happiness / bucket.count, @@ -177,7 +178,7 @@ function chartSeries(mode = "population") { { key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true }, { key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true }, { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true }, - { key: "lack", label: "\u305f\u308a\u306a\u3044", color: "#5b9d61", percent: true }, + { key: "mood", label: "\u7dcf\u5408\u7684\u6c17\u5206", color: "#0072b2", percent: true }, ]; if (mode === "objects") return [ { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, diff --git a/js/ui_family.js b/js/ui_family.js deleted file mode 100644 index b4a6d6a..0000000 --- a/js/ui_family.js +++ /dev/null @@ -1,1663 +0,0 @@ -"use strict"; - -function buildArchiveRows(family) { - return liveFamilyComponents(family); -} - -function lineageEdgeFamily(family = world.family || {}) { - return family || {}; -} - -let lineageRelationIndexCache = { family: null, version: null, index: null }; - -function lineageBuildRelationIndex(family) { - const known = new Set(Object.keys(family || {})); - const parentsByChild = new Map(); - const childrenByParent = new Map(); - const addParent = (childId, parentId) => { - if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; - if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); - if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); - parentsByChild.get(childId).add(parentId); - childrenByParent.get(parentId).add(childId); - }; - for (const n of Object.values(family || {})) { - if (!n?.id) continue; - for (const parentId of n.parents || []) addParent(n.id, parentId); - for (const childId of n.children || []) addParent(childId, n.id); - } - return { parentsByChild, childrenByParent }; -} - -function lineageRelationIndex(family = world.family || {}) { - family = lineageEdgeFamily(family); - if (family !== (world.family || {})) return lineageBuildRelationIndex(family); - const version = world.familyVersion || 0; - if (lineageRelationIndexCache.family !== family || lineageRelationIndexCache.version !== version) { - lineageRelationIndexCache = { family, version, index: lineageBuildRelationIndex(family) }; - } - return lineageRelationIndexCache.index; -} - -function lineageMutualParentIds(n, family = world.family || {}, idSet = null) { - const seen = new Set(); - const parents = []; - if (!n?.id) return parents; - const relationIndex = lineageRelationIndex(family); - const candidates = new Set((n.parents || []).filter(Boolean)); - // Runtime updates can briefly touch one side first; recover the reciprocal - // edge from the relation index so the archive does not flicker. - for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId); - for (const id of candidates) { - if (!id || id === n.id || seen.has(id)) continue; - if (idSet && !idSet.has(id)) continue; - const parent = family[id]; - if (!parent) continue; - const childListsParent = Array.isArray(n.parents) && n.parents.includes(id); - const parentListsChild = Array.isArray(parent.children) && parent.children.includes(n.id); - if (!childListsParent && !parentListsChild) continue; - seen.add(id); - parents.push(id); - } - parents.sort((a, b) => lineageNodeSort(family[a], family[b])); - return parents; -} - -function lineageMutualChildIds(n, family = world.family || {}, idSet = null) { - const seen = new Set(); - const children = []; - if (!n?.id) return children; - const relationIndex = lineageRelationIndex(family); - const candidates = new Set((n.children || []).filter(Boolean)); - // Same recovery as above, in the opposite direction. - for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId); - for (const id of candidates) { - if (!id || id === n.id || seen.has(id)) continue; - if (idSet && !idSet.has(id)) continue; - const child = family[id]; - if (!child) continue; - const parentListsChild = Array.isArray(n.children) && n.children.includes(id); - const childListsParent = Array.isArray(child.parents) && child.parents.includes(n.id); - if (!parentListsChild && !childListsParent) continue; - seen.add(id); - children.push(id); - } - children.sort((a, b) => lineageNodeSort(family[a], family[b])); - return children; -} - -function lineageHasActualRelation(n, family = world.family || {}) { - return lineageMutualParentIds(n, family).length > 0 || lineageMutualChildIds(n, family).length > 0; -} - -function lineageRelatedIdSet(family) { - family = lineageEdgeFamily(family); - const relationIndex = lineageRelationIndex(family); - const ids = new Set(); - for (const n of Object.values(family || {}).filter(Boolean)) { - const parents = relationIndex.parentsByChild.get(n.id) || []; - const children = relationIndex.childrenByParent.get(n.id) || []; - if (parents.size || children.size) ids.add(n.id); - for (const id of parents) ids.add(id); - for (const id of children) ids.add(id); - } - return ids; -} - -function liveFamilyComponents(family) { - // Childless pairs, including pairs currently performing the birth ritual, are - // intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child - // relation exists; otherwise ritual participants become isolated pseudo-families. - family = lineageEdgeFamily(family); - const relatedIds = lineageRelatedIdSet(family); - const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); - const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(nodes.map(n => [n.id, new Set()])); - const addEdge = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - for (const p of lineageMutualParentIds(n, family, ids)) addEdge(n.id, p); - for (const c of lineageMutualChildIds(n, family, ids)) addEdge(n.id, c); - } - const seen = new Set(); - const comps = []; - for (const n of nodes.sort(lineageNodeSort)) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - const item = family[id]; - if (item) comp.push(item); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (comp.some(x => x.alive)) comps.push(comp); - } - comps.sort((a, b) => { - const ag = Math.min(...a.map(n => n.generation || 1)); - const bg = Math.min(...b.map(n => n.generation || 1)); - const at = Math.min(...a.map(n => n.birthTime || 0)); - const bt = Math.min(...b.map(n => n.birthTime || 0)); - return ag - bg || at - bt || b.length - a.length; - }); - return comps; -} - -function lineageIdleSchedule(fn, timeout = 180) { - const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); - schedule(fn, { timeout }); -} - -function lineageSameFamilyToken(token) { - return uiCache.archiveRenderToken === token; -} - -function lineageBuildComponentsIdle(family, token, onProgress, onDone) { - family = lineageEdgeFamily(family); - const entries = Object.values(family || {}).filter(n => n?.id); - const known = new Set(entries.map(n => n.id)); - const parentsByChild = new Map(); - const childrenByParent = new Map(); - const relatedIds = new Set(); - const graph = new Map(); - const comps = []; - let stage = "relations"; - let entryIndex = 0; - let nodeIndex = 0; - let nodes = []; - let sortedNodes = []; - let dfsIndex = 0; - const seen = new Set(); - - const addParent = (childId, parentId) => { - if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; - if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); - if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); - parentsByChild.get(childId).add(parentId); - childrenByParent.get(parentId).add(childId); - }; - const addGraphEdge = (a, b) => { - if (!a || !b || !graph.has(a) || !graph.has(b)) return; - graph.get(a).add(b); - graph.get(b).add(a); - }; - const validParentChild = (childId, parentId) => { - const child = family[childId]; - const parent = family[parentId]; - if (!child || !parent) return false; - const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId); - const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId); - return childListsParent || parentListsChild; - }; - const finishEmpty = () => { - lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; - onDone([]); - }; - - const pump = () => { - if (!lineageSameFamilyToken(token)) return; - const started = performance.now(); - while (performance.now() - started < 6) { - if (stage === "relations") { - if (entryIndex >= entries.length) { - lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; - stage = "related"; - entryIndex = 0; - onProgress?.("\u95a2\u4fc2\u3092\u6574\u7406\u4e2d", 0, entries.length); - continue; - } - const n = entries[entryIndex++]; - for (const parentId of n.parents || []) addParent(n.id, parentId); - for (const childId of n.children || []) addParent(childId, n.id); - } else if (stage === "related") { - if (entryIndex >= entries.length) { - nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); - for (const n of nodes) graph.set(n.id, new Set()); - if (!nodes.length) return finishEmpty(); - stage = "graph"; - nodeIndex = 0; - onProgress?.("\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d", 0, nodes.length); - continue; - } - const n = entries[entryIndex++]; - const parents = parentsByChild.get(n.id) || new Set(); - const children = childrenByParent.get(n.id) || new Set(); - if (parents.size || children.size) relatedIds.add(n.id); - for (const id of parents) relatedIds.add(id); - for (const id of children) relatedIds.add(id); - } else if (stage === "graph") { - if (nodeIndex >= nodes.length) { - sortedNodes = nodes.slice().sort(lineageNodeSort); - stage = "components"; - dfsIndex = 0; - onProgress?.("\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", 0, sortedNodes.length); - continue; - } - const n = nodes[nodeIndex++]; - const parentCandidates = new Set((n.parents || []).filter(Boolean)); - for (const parentId of parentsByChild.get(n.id) || []) parentCandidates.add(parentId); - for (const parentId of parentCandidates) { - if (parentId !== n.id && graph.has(parentId) && validParentChild(n.id, parentId)) addGraphEdge(n.id, parentId); - } - const childCandidates = new Set((n.children || []).filter(Boolean)); - for (const childId of childrenByParent.get(n.id) || []) childCandidates.add(childId); - for (const childId of childCandidates) { - if (childId !== n.id && graph.has(childId) && validParentChild(childId, n.id)) addGraphEdge(n.id, childId); - } - } else if (stage === "components") { - while (dfsIndex < sortedNodes.length && seen.has(sortedNodes[dfsIndex].id)) dfsIndex += 1; - if (dfsIndex >= sortedNodes.length) { - comps.sort((a, b) => { - const ag = Math.min(...a.map(n => n.generation || 1)); - const bg = Math.min(...b.map(n => n.generation || 1)); - const at = Math.min(...a.map(n => n.birthTime || 0)); - const bt = Math.min(...b.map(n => n.birthTime || 0)); - return ag - bg || at - bt || b.length - a.length; - }); - onDone(comps); - return; - } - const start = sortedNodes[dfsIndex++]; - const stack = [start.id]; - const comp = []; - seen.add(start.id); - while (stack.length && performance.now() - started < 6) { - const id = stack.pop(); - const item = family[id]; - if (item) comp.push(item); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (stack.length) { - const continueComp = () => { - if (!lineageSameFamilyToken(token)) return; - const resumedStarted = performance.now(); - while (stack.length && performance.now() - resumedStarted < 6) { - const id = stack.pop(); - const item = family[id]; - if (item) comp.push(item); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (stack.length) return lineageIdleSchedule(continueComp, 180); - if (comp.some(x => x.alive)) comps.push(comp); - lineageIdleSchedule(pump, 180); - }; - return lineageIdleSchedule(continueComp, 180); - } - if (comp.some(x => x.alive)) comps.push(comp); - } - } - onProgress?.( - stage === "relations" ? "\u95a2\u4fc2\u3092\u6574\u7406\u4e2d" : stage === "related" ? "\u8868\u793a\u5bfe\u8c61\u3092\u9078\u5225\u4e2d" : stage === "graph" ? "\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d" : "\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", - stage === "graph" ? nodeIndex : entryIndex, - stage === "graph" ? nodes.length : entries.length - ); - lineageIdleSchedule(pump, 180); - }; - lineageIdleSchedule(pump, 160); -} - -function familyStatusText(n) { - if (!n) return "\u4e0d\u660e"; - return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; -} -function familyDeathReasonLabel(rawReason, entity = null) { - const text = String(rawReason || ""); - if (!text) return ""; - if (entity?.briefDeathReason) return entity.briefDeathReason(text); - if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text; - if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629"; - if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; - if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"; - if (/\u885d\u7a81|collision/.test(text)) return "\u885d\u7a81"; - if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f"; - if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5"; - if (/\u75c5/.test(text)) { - const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/); - return disease ? disease[1] : "\u305a\u3093\u3061\u75c5"; - } - if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; - if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; - if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; - return "\u4e8b\u6545"; -} - - -function lineageNodeSort(a, b) { - return (a.generation || 1) - (b.generation || 1) || - (a.birthTime || 0) - (b.birthTime || 0) || - String(a.name || "").localeCompare(String(b.name || "")) || - String(a.id || "").localeCompare(String(b.id || "")); -} - -function lineageTreeNodeHtml(n, x, y) { - const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; - const live = entity && !entity.dead ? entity : null; - const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; - const name = escapeHtml(n?.name || "\u4e0d\u660e"); - const status = familyStatusText(n); - const gen = n?.generation ? `G${n.generation}` : "G?"; - const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; - const briefReason = familyDeathReasonLabel(rawReason, entity); - const reason = briefReason ? ` / ${briefReason}` : ""; - const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}`); - const deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; - const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; - const childCls = (n?.parents || []).length ? " child" : ""; - const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; - return `
    - -
    - ${name} - ${escapeHtml(gen)} / ${escapeHtml(status)} - ${deathLine} -
    -
    `; -} - -/* - * Family archive rendering is intentionally a top-down tree diagram. - * Do not replace this section with list/cards/readable grids: the archive must remain a \u5bb6\u7cfb\u56f3. - * The layout below may reorder siblings/parents inside the same generation only to reduce crossings. - */ -function lineageParentIds(n, idSet, family = world.family || {}) { - return lineageMutualParentIds(n, family, idSet).slice(0, 2); -} - -function lineageParentKey(n, idSet) { - const parents = lineageParentIds(n, idSet); - if (!parents.length) return `root:${n?.id || Math.random()}`; - return parents.slice().sort().join("+"); -} - -function lineageMedian(values) { - if (!values.length) return Number.POSITIVE_INFINITY; - const sorted = values.slice().sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -function lineageRankMap(rows) { - const rank = new Map(); - rows.forEach((row, rowIndex) => { - row.forEach((n, i) => rank.set(n.id, rowIndex * 10000 + i)); - }); - return rank; -} - -function lineageChildIds(n, idSet, family = world.family || {}) { - return lineageMutualChildIds(n, family, idSet); -} - -function lineageOrderChildGroups(row, idSet, rank) { - const groups = new Map(); - for (const n of row) { - const key = lineageParentKey(n, idSet); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(n); - } - const records = Array.from(groups.entries()).map(([key, items]) => { - const parents = key.startsWith("root:") ? [] : key.split("+").filter(Boolean); - const parentRanks = parents.map(id => rank.get(id)).filter(v => Number.isFinite(v)); - const score = parentRanks.length ? lineageMedian(parentRanks) : Number.POSITIVE_INFINITY; - items.sort(lineageNodeSort); - return { - key, - items, - score, - fallback: Math.min(...items.map(n => n.birthTime || 0)), - generation: Math.min(...items.map(n => n.generation || 1)), - }; - }); - records.sort((a, b) => a.score - b.score || a.generation - b.generation || a.fallback - b.fallback || String(a.key).localeCompare(String(b.key))); - return records.flatMap(r => r.items); -} - -function lineageOrderParentsByChildren(row, idSet, childRank) { - if (!row.length || !childRank?.size) return row; - const records = row.map((n, index) => { - const childRanks = lineageChildIds(n, idSet).map(id => childRank.get(id)).filter(v => Number.isFinite(v)); - return { - node: n, - score: childRanks.length ? lineageMedian(childRanks) : Number.POSITIVE_INFINITY, - original: index, - }; - }); - records.sort((a, b) => a.score - b.score || a.original - b.original || lineageNodeSort(a.node, b.node)); - return records.map(r => r.node); -} - -function lineagePartnerEdgesByGeneration(nodes, idToNode, idSet) { - const byGeneration = new Map(); - const seen = new Set(); - for (const child of nodes) { - const parents = lineageParentIds(child, idSet); - if (parents.length < 2) continue; - const a = idToNode.get(parents[0]); - const b = idToNode.get(parents[1]); - if (!a || !b) continue; - const generation = Math.min(a.generation || 1, b.generation || 1); - const key = parents.slice().sort().join("+"); - if (seen.has(key)) continue; - seen.add(key); - if (!byGeneration.has(generation)) byGeneration.set(generation, []); - byGeneration.get(generation).push(parents.slice()); - } - return byGeneration; -} - -function lineageOrderPartnerComponent(ids, adjacency, rank, idToNode) { - if (ids.length <= 2) { - return ids.slice().sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); - } - const degree = id => (adjacency.get(id) || new Set()).size; - const start = ids.slice().sort((a, b) => degree(b) - degree(a) || (rank.get(a) ?? 0) - (rank.get(b) ?? 0))[0]; - const neighbors = Array.from(adjacency.get(start) || []).filter(id => ids.includes(id)); - const left = []; - const right = []; - neighbors.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); - neighbors.forEach((id, i) => (i % 2 ? left : right).push(id)); - const placed = new Set([start, ...neighbors]); - const rest = ids.filter(id => !placed.has(id)).sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); - return [...left.reverse(), start, ...right, ...rest]; -} - -function lineageOrderPartnerClusters(row, partnerEdges, rank, idToNode) { - if (!partnerEdges?.length) return row; - const rowIds = new Set(row.map(n => n.id)); - const adjacency = new Map(); - for (const [a, b] of partnerEdges) { - if (!rowIds.has(a) || !rowIds.has(b)) continue; - if (!adjacency.has(a)) adjacency.set(a, new Set()); - if (!adjacency.has(b)) adjacency.set(b, new Set()); - adjacency.get(a).add(b); - adjacency.get(b).add(a); - } - if (!adjacency.size) return row; - - const clusteredIds = new Set(adjacency.keys()); - const seen = new Set(); - const records = []; - for (const n of row) { - if (!clusteredIds.has(n.id)) { - records.push({ ids: [n.id], score: rank.get(n.id) ?? Number.POSITIVE_INFINITY, fallback: idToNode.get(n.id)?.birthTime || 0 }); - continue; - } - if (seen.has(n.id)) continue; - const stack = [n.id]; - const ids = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - ids.push(id); - for (const next of adjacency.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - const orderedIds = lineageOrderPartnerComponent(ids, adjacency, rank, idToNode); - records.push({ - ids: orderedIds, - score: lineageMedian(orderedIds.map(id => rank.get(id)).filter(v => Number.isFinite(v))), - fallback: Math.min(...orderedIds.map(id => idToNode.get(id)?.birthTime || 0)), - }); - } - records.sort((a, b) => a.score - b.score || a.fallback - b.fallback || String(a.ids[0]).localeCompare(String(b.ids[0]))); - return records.flatMap(c => c.ids.map(id => idToNode.get(id)).filter(Boolean)); -} - -function lineageAdjacentCrossCost(parentRow, childRow, idSet) { - if (!parentRow?.length || !childRow?.length) return 0; - const parentPos = new Map(parentRow.map((n, i) => [n.id, i])); - const edges = []; - for (let childIndex = 0; childIndex < childRow.length; childIndex++) { - const child = childRow[childIndex]; - const parents = lineageParentIds(child, idSet).filter(id => parentPos.has(id)); - if (!parents.length) continue; - const from = lineageMedian(parents.map(id => parentPos.get(id))); - edges.push({ from, to: childIndex, weight: parents.length >= 2 ? 1.4 : 1 }); - } - let cost = 0; - for (let i = 0; i < edges.length; i++) { - for (let j = i + 1; j < edges.length; j++) { - const a = edges[i]; - const b = edges[j]; - const cross = (a.from - b.from) * (a.to - b.to) < 0; - if (cross) cost += a.weight * b.weight * 4.5; - } - } - return cost; -} - -function lineageRowCost(row, rowIndex, rows, idSet) { - const pos = new Map(row.map((n, i) => [n.id, i])); - let cost = 0; - for (const n of row) { - const i = pos.get(n.id); - const parents = lineageParentIds(n, idSet); - if (parents.length && rowIndex > 0) { - const parentRow = rows[rowIndex - 1] || []; - const parentPos = parents.map(id => parentRow.findIndex(x => x.id === id)).filter(v => v >= 0); - if (parentPos.length) cost += Math.abs(i - lineageMedian(parentPos)) * (parents.length >= 2 ? 1.5 : 1.2); - } - const childRow = rows[rowIndex + 1] || []; - const childPos = childRow.filter(x => lineageParentIds(x, idSet).includes(n.id)).map(x => childRow.findIndex(y => y.id === x.id)); - if (childPos.length) cost += Math.abs(i - lineageMedian(childPos)) * 1.0; - - const siblingPos = row.filter(x => x.id !== n.id && lineageParentKey(x, idSet) === lineageParentKey(n, idSet)).map(x => pos.get(x.id)); - if (siblingPos.length) cost += Math.abs(i - lineageMedian(siblingPos)) * 0.22; - } - - const seenPairs = new Set(); - for (const child of rows[rowIndex + 1] || []) { - const parents = lineageParentIds(child, idSet).filter(id => pos.has(id)); - if (parents.length < 2) continue; - const key = parents.slice().sort().join("+"); - if (seenPairs.has(key)) continue; - seenPairs.add(key); - const pair = parents.slice().sort((a, b) => pos.get(a) - pos.get(b)); - const span = pos.get(pair[1]) - pos.get(pair[0]); - cost += span * span * 3.4; - if (span > 1) cost += 12; - } - - return cost; -} - -function lineageTotalCost(rows, idSet) { - let cost = 0; - for (let i = 0; i < rows.length; i++) { - cost += lineageRowCost(rows[i], i, rows, idSet); - if (i < rows.length - 1) cost += lineageAdjacentCrossCost(rows[i], rows[i + 1], idSet); - } - return cost; -} - -function lineageMoveItem(row, fromIndex, toIndex) { - if (fromIndex === toIndex) return row.slice(); - const next = row.slice(); - const [item] = next.splice(fromIndex, 1); - next.splice(toIndex, 0, item); - return next; -} - -function lineageOptimizeRows(rows, idSet) { - const totalNodes = rows.reduce((sum, row) => sum + row.length, 0); - const maxRow = Math.max(0, ...rows.map(row => row.length)); - // The old exhaustive optimizer was cubic-to-quartic in practice and froze on - // large archives. Keep it only for small families; large trees use the - // barycentric ordering above, which is stable and linear-ish. - if (totalNodes > 90 || maxRow > 16) return rows.map(row => row.slice()); - let bestRows = rows.map(row => row.slice()); - let bestCost = lineageTotalCost(bestRows, idSet); - const passes = totalNodes > 54 || maxRow > 10 ? 3 : 6; - for (let pass = 0; pass < passes; pass++) { - let changed = false; - for (let rowIndex = 0; rowIndex < bestRows.length; rowIndex++) { - const row = bestRows[rowIndex]; - if (!row || row.length < 2) continue; - for (let from = 0; from < row.length; from++) { - const span = row.length > 10 ? 3 : row.length; - const minTo = Math.max(0, from - span); - const maxTo = Math.min(row.length - 1, from + span); - for (let to = minTo; to <= maxTo; to++) { - if (from === to) continue; - const moved = lineageMoveItem(bestRows[rowIndex], from, to); - const candidate = bestRows.slice(); - candidate[rowIndex] = moved; - const cost = lineageTotalCost(candidate, idSet); - if (cost + 0.001 < bestCost) { - bestRows = candidate; - bestCost = cost; - changed = true; - } - } - } - } - if (!changed) break; - } - return bestRows; -} - -function lineageRowsForLayout(nodes, generations, idToNode, idSet) { - const byGeneration = new Map(generations.map(g => [g, []])); - for (const n of nodes) byGeneration.get(n.generation || 1).push(n); - let rows = generations.map(g => (byGeneration.get(g) || []).slice().sort(lineageNodeSort)); - const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet); - const totalNodes = nodes.length; - const maxRow = Math.max(0, ...rows.map(row => row.length)); - const veryLarge = totalNodes > 220 || maxRow > 44; - const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6)); - - for (let pass = 0; pass < passes; pass++) { - let rank = lineageRankMap(rows); - rows = rows.map(row => lineageOrderChildGroups(row, idSet, rank)); - rank = lineageRankMap(rows); - for (let i = rows.length - 2; i >= 0; i--) { - const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index])); - rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank); - } - if (!veryLarge) { - rank = lineageRankMap(rows); - rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); - if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); - } - } - return rows; -} - -function lineageBuildRowGroups(row, idSet) { - const groups = []; - let i = 0; - while (i < row.length) { - const key = lineageParentKey(row[i], idSet); - const items = [row[i]]; - i += 1; - while (i < row.length && lineageParentKey(row[i], idSet) === key) { - items.push(row[i]); - i += 1; - } - groups.push({ key, items }); - } - return groups; -} - -function lineageLayoutRows(rows, idSet) { - const NODE_W = 132; - const NODE_H = 68; - const SIBLING_GAP = 32; - const GROUP_GAP = 52; - const PARTNER_GROUP_GAP = 24; - const ROW_GAP = 138; - const LEFT_PAD = 82; - const TOP_PAD = 44; - const RIGHT_PAD = 48; - const BOTTOM_PAD = 30; - - const rowGroups = rows.map(row => lineageBuildRowGroups(row, idSet).map(group => ({ - ...group, - groupW: group.items.length * NODE_W + Math.max(0, group.items.length - 1) * SIBLING_GAP, - }))); - - const pairedKeysByRow = rows.map((row) => { - const set = new Set(); - const rowIds = new Set(row.map(n => n.id)); - for (const futureRow of rows) { - for (const child of futureRow) { - const parents = lineageParentIds(child, rowIds); - if (parents.length >= 2) set.add(parents.slice().sort().join("+")); - } - } - return set; - }); - - let positions = new Map(); - let width = 420; - - const placePass = (existingPositions) => { - const nextPositions = new Map(); - let nextWidth = 420; - const rowLayouts = []; - - rows.forEach((row, rowIndex) => { - const groups = rowGroups[rowIndex] || []; - const desiredLefts = []; - const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); - const nextRow = rows[rowIndex + 1] || []; - - for (let gIndex = 0; gIndex < groups.length; gIndex++) { - const group = groups[gIndex]; - const first = group.items[0]; - const parentCenters = lineageParentIds(first, idSet) - .map(id => existingPositions.get(id) || nextPositions.get(id)) - .filter(Boolean) - .map(p => p.x + NODE_W / 2); - const groupIds = new Set(group.items.map(n => n.id)); - const childCenters = nextRow - .filter(child => lineageParentIds(child, idSet).some(id => groupIds.has(id))) - .map(child => existingPositions.get(child.id)) - .filter(Boolean) - .map(p => p.x + NODE_W / 2); - const desiredCenterValues = []; - if (parentCenters.length) desiredCenterValues.push(...parentCenters, ...parentCenters); - if (childCenters.length) desiredCenterValues.push(...childCenters); - const fallbackCenter = LEFT_PAD + group.groupW / 2 + gIndex * (group.groupW + GROUP_GAP); - const desiredCenter = Number.isFinite(lineageMedian(desiredCenterValues)) ? lineageMedian(desiredCenterValues) : fallbackCenter; - desiredLefts[gIndex] = desiredCenter - group.groupW / 2; - } - - const lefts = []; - const SOFT_EXTRA_GAP = 68; - for (let gIndex = 0; gIndex < groups.length; gIndex++) { - const prevGroup = groups[gIndex - 1]; - const pairGap = prevGroup && prevGroup.items.length === 1 && groups[gIndex].items.length === 1 - && pairedKeysByRow[rowIndex].has([prevGroup.items[0].id, groups[gIndex].items[0].id].sort().join("+")); - const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; - const minLeft = gIndex === 0 ? LEFT_PAD : lefts[gIndex - 1] + groups[gIndex - 1].groupW + gap; - const desired = Number.isFinite(desiredLefts[gIndex]) ? desiredLefts[gIndex] : minLeft; - lefts[gIndex] = Math.max(minLeft, Math.min(desired, minLeft + SOFT_EXTRA_GAP)); - } - for (let gIndex = groups.length - 2; gIndex >= 0; gIndex--) { - const nextGroup = groups[gIndex + 1]; - const pairGap = nextGroup && groups[gIndex].items.length === 1 && nextGroup.items.length === 1 - && pairedKeysByRow[rowIndex].has([groups[gIndex].items[0].id, nextGroup.items[0].id].sort().join("+")); - const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; - const maxLeft = lefts[gIndex + 1] - groups[gIndex].groupW - gap; - lefts[gIndex] = Math.min(lefts[gIndex], maxLeft); - if (gIndex === 0) lefts[gIndex] = Math.max(lefts[gIndex], LEFT_PAD); - } - - const contentW = groups.length ? (lefts[lefts.length - 1] + groups[groups.length - 1].groupW - LEFT_PAD) : 0; - nextWidth = Math.max(nextWidth, LEFT_PAD + contentW + RIGHT_PAD); - groups.forEach((group, gIndex) => { - const left = lefts[gIndex]; - group.items.forEach((n, i) => { - const x = left + i * (NODE_W + SIBLING_GAP); - nextPositions.set(n.id, { x, y, rowIndex, node: n }); - }); - }); - rowLayouts.push({ rowIndex, groups, lefts, contentW, y }); - }); - - return { positions: nextPositions, width: nextWidth, rowLayouts }; - }; - - for (let pass = 0; pass < 5; pass++) { - const result = placePass(positions); - positions = result.positions; - width = result.width; - } - - // Re-run once more and center the entire family as one block to avoid left-heavy layouts - // without breaking parent/child alignment between rows. - const finalResult = placePass(positions); - positions = new Map(finalResult.positions); - let minX = Number.POSITIVE_INFINITY; - let maxX = 0; - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x); - maxX = Math.max(maxX, pos.x + NODE_W); - } - const contentW = Number.isFinite(minX) ? Math.max(0, maxX - minX) : 0; - width = Math.max(finalResult.width, LEFT_PAD + contentW + RIGHT_PAD); - if (Number.isFinite(minX)) { - const targetLeft = LEFT_PAD + Math.max(0, (width - LEFT_PAD - RIGHT_PAD - contentW) / 2); - const shift = targetLeft - minX; - if (shift) { - for (const [id, prev] of positions.entries()) positions.set(id, { ...prev, x: prev.x + shift }); - } - } - - const groupBounds = (group) => { - const xs = group.items.map(n => positions.get(n.id)?.x).filter(Number.isFinite); - if (!xs.length) return null; - return { left: Math.min(...xs), right: Math.max(...xs) + NODE_W, width: group.groupW }; - }; - const groupGap = (leftGroup, rightGroup, rowIndex) => { - const pairGap = leftGroup && rightGroup && leftGroup.items.length === 1 && rightGroup.items.length === 1 - && pairedKeysByRow[rowIndex].has([leftGroup.items[0].id, rightGroup.items[0].id].sort().join("+")); - return pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; - }; - const shiftGroup = (group, dx) => { - if (!dx) return; - for (const n of group.items) { - const prev = positions.get(n.id); - if (prev) positions.set(n.id, { ...prev, x: prev.x + dx }); - } - }; - for (let pass = 0; pass < 3; pass++) { - for (let rowIndex = 1; rowIndex < rowGroups.length; rowIndex++) { - const groups = rowGroups[rowIndex] || []; - for (let gIndex = 0; gIndex < groups.length; gIndex++) { - const group = groups[gIndex]; - const first = group.items[0]; - const parentIds = lineageParentIds(first, idSet); - if (!parentIds.length) continue; - const parentPositions = parentIds.map(id => positions.get(id)).filter(Boolean); - if (!parentPositions.length) continue; - const parentCenter = parentPositions.reduce((sum, p) => sum + p.x + NODE_W / 2, 0) / parentPositions.length; - const bounds = groupBounds(group); - if (!bounds) continue; - const idealLeft = parentCenter - bounds.width / 2; - let minShift = LEFT_PAD - bounds.left; - let maxShift = Number.POSITIVE_INFINITY; - const prevGroup = groups[gIndex - 1]; - if (prevGroup) { - const pb = groupBounds(prevGroup); - if (pb) minShift = Math.max(minShift, pb.right + groupGap(prevGroup, group, rowIndex) - bounds.left); - } - const nextGroup = groups[gIndex + 1]; - if (nextGroup) { - const nb = groupBounds(nextGroup); - if (nb) maxShift = nb.left - groupGap(group, nextGroup, rowIndex) - bounds.right; - } - const desiredShift = idealLeft - bounds.left; - const shift = clamp(desiredShift, minShift, maxShift); - if (Math.abs(shift) > 0.2) shiftGroup(group, shift); - } - } - } - width = Math.max(width, ...Array.from(positions.values()).map(p => p.x + NODE_W + RIGHT_PAD)); - - // Fallback placement for any node that somehow missed layout. - rows.forEach((row, rowIndex) => { - const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); - let fallbackX = LEFT_PAD; - for (const n of row) { - if (positions.has(n.id)) { - fallbackX = Math.max(fallbackX, positions.get(n.id).x + NODE_W + GROUP_GAP); - continue; - } - positions.set(n.id, { x: fallbackX, y, rowIndex, node: n }); - fallbackX += NODE_W + GROUP_GAP; - width = Math.max(width, fallbackX + RIGHT_PAD); - } - }); - - const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; - return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP }; -} - -function lineageSegmentKind(x1, y1, x2, y2) { - if (Math.abs(y2 - y1) < 0.01) return 'h'; - if (Math.abs(x2 - x1) < 0.01) return 'v'; - return 'd'; -} - -function lineageSegmentsFromPoints(points) { - const segments = []; - for (let i = 1; i < points.length; i++) { - const [x1, y1] = points[i - 1]; - const [x2, y2] = points[i]; - segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 }); - } - return segments; -} - -function lineagePathFromPoints(points) { - if (!points.length) return ""; - return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); -} - -function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) { - const ax = a.x + nodeW / 2; - const bx = b.x + nodeW / 2; - const ay = a.y + nodeH; - const by = b.y + nodeH; - const y = Math.max(unionY, ay + 12, by + 12); - const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2; - const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]]; - return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y }; -} - -function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { - const left = a.x <= b.x ? a : b; - const right = left === a ? b : a; - const midYLeft = left.y + nodeH / 2 + attachOffsetA; - const midYRight = right.y + nodeH / 2 + attachOffsetB; - const x1 = left.x + nodeW; - const x2 = right.x; - if (x2 <= x1) return { d: "", segments: [] }; - const direct = left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10 - && (!Number.isFinite(unionY) || unionY <= Math.max(midYLeft, midYRight) + 16); - if (direct) { - const y = (midYLeft + midYRight) / 2; - return { d: `M ${x1.toFixed(1)} ${y.toFixed(1)} L ${x2.toFixed(1)} ${y.toFixed(1)}`, segments: [{ kind: "h", x1, y1: y, x2, y2: y }] }; - } - const bend = 10; - const y = Number.isFinite(unionY) ? unionY : Math.max(midYLeft, midYRight); - const points = [ - [x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight] - ]; - return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y }; -} - -function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { - if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY); - return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB); -} - -function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) { - if (!Number.isFinite(x) || !Number.isFinite(y1) || !Number.isFinite(y2)) return ''; - const down = y2 >= y1; - const sign = down ? 1 : -1; - const sorted = bridges.slice().sort((a, b) => down ? a - b : b - a).filter(y => Math.abs(y - y1) > radius + 1 && Math.abs(y - y2) > radius + 1); - let d = `M ${x.toFixed(1)} ${y1.toFixed(1)}`; - let cursor = y1; - for (const by of sorted) { - const before = by - sign * radius; - const after = by + sign * radius; - d += ` L ${x.toFixed(1)} ${before.toFixed(1)}`; - d += ` Q ${(x + radius * 1.55).toFixed(1)} ${by.toFixed(1)} ${x.toFixed(1)} ${after.toFixed(1)}`; - cursor = after; - } - if (Math.abs(cursor - y2) > 0.01) d += ` L ${x.toFixed(1)} ${y2.toFixed(1)}`; - return d; -} - -function lineageIntersectionYsForVertical(x, y1, y2, horizontalSegments) { - const top = Math.min(y1, y2); - const bottom = Math.max(y1, y2); - const ys = []; - for (const seg of horizontalSegments || []) { - if (seg.kind !== 'h') continue; - const minX = Math.min(seg.x1, seg.x2); - const maxX = Math.max(seg.x1, seg.x2); - const y = seg.y1; - if (x > minX + 3 && x < maxX - 3 && y > top + 3 && y < bottom - 3) ys.push(y); - } - return ys; -} - -function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) { - const children = group.children; - if (!children.length) return { d: '' }; - const joinX = group.joinX; - const fromY = group.fromY; - const laneY = group.busY; - const childPoints = children - .map(c => ({ x: c.x + nodeW / 2, y: c.y })) - .sort((a, b) => a.x - b.x || a.y - b.y); - if (childPoints.length === 1 && Math.abs(childPoints[0].x - joinX) <= 0.5) { - const p = childPoints[0]; - const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals); - return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) }; - } - if (childPoints.length === 1) { - const p = childPoints[0]; - const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals); - const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); - const parentLane = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges); - const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`; - const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).replace(/^M\s+[-\d.]+\s+[-\d.]+/, ""); - return { d: `${parentLane}${horizontal}${childLane}` }; - } - const minX = Math.min(joinX, ...childPoints.map(p => p.x)); - const maxX = Math.max(joinX, ...childPoints.map(p => p.x)); - let d = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals)); - d += ` M ${minX.toFixed(1)} ${laneY.toFixed(1)} L ${maxX.toFixed(1)} ${laneY.toFixed(1)}`; - for (const p of childPoints) { - const bridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); - const branch = lineageVerticalBridgePath(p.x, laneY, p.y, bridges); - d += ` ${branch}`; - } - return { d }; -} - -function lineageComponentSignature(component, family) { - family = lineageEdgeFamily(family); - const idSet = new Set(component.map(x => x?.id).filter(Boolean)); - return component - .filter(Boolean) - .map(n => { - const parents = lineageMutualParentIds(n, family, idSet).join(","); - const children = lineageMutualChildIds(n, family, idSet).join(","); - return [ - n.id, - n.name || "", - n.type || "", - n.generation || 1, - n.birthTime || 0, - n.hasPaired ? 1 : 0, - n.alive === false ? 0 : 1, - n.deathReason || "", - parents, - children, - ].join(":"); - }) - .sort() - .join("|"); -} - -function lineageComponentIdentity(component) { - return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(","); -} - -function lineageFamilyCacheKey(component, family, signature = "") { - const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); - return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`; -} - -function lineageHydrateFamilyHtml(html, index) { - return html - .split("{{LINEAGE_INDEX}}").join(String(index + 1)) - .split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`); -} - -function lineageCaptureArchiveScroll() { - const root = ui.archiveContent; - if (!root) return null; - const familyScroll = {}; - for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { - const key = el.getAttribute("data-lineage-key"); - if (key) familyScroll[key] = el.scrollLeft || 0; - } - return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll }; -} - -function lineageRestoreArchiveScroll(state) { - const root = ui.archiveContent; - if (!root || !state) return; - const maxTop = Math.max(0, root.scrollHeight - root.clientHeight); - const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth); - root.scrollTop = Math.min(state.top || 0, maxTop); - root.scrollLeft = Math.min(state.left || 0, maxLeft); - for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { - const key = el.getAttribute("data-lineage-key"); - if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue; - el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth)); - } -} - - -function lineageExpectedChildEdgeCount(component, family) { - family = lineageEdgeFamily(family); - const idSet = new Set(component.map(x => x?.id).filter(Boolean)); - let count = 0; - for (const n of component || []) count += lineageParentIds(n, idSet, family).length; - return count; -} - -function lineageVisibleRelationSignature(family) { - family = lineageEdgeFamily(family); - const relationIndex = lineageRelationIndex(family); - const relatedIds = new Set(); - for (const [childId, parents] of relationIndex.parentsByChild.entries()) { - if (!parents.size) continue; - relatedIds.add(childId); - for (const parentId of parents) relatedIds.add(parentId); - } - return Array.from(relatedIds) - .sort() - .map(id => { - const n = family[id] || {}; - const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(","); - const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(","); - return [ - id, - n.name || "", - n.type || "", - n.generation || 1, - n.birthTime || 0, - n.alive === false ? 0 : 1, - n.deathReason || "", - parents, - children, - ].join(":"); - }) - .join("|"); -} - -function lineageArchiveNearViewport() { - if (!ui.archiveContent?.getBoundingClientRect) return true; - const rect = ui.archiveContent.getBoundingClientRect(); - const h = window.innerHeight || document.documentElement?.clientHeight || 0; - return rect.top < h + 260 && rect.bottom > -260; -} - -function lineageSetArchiveStaleStatus(show) { - if (!ui.archiveContent) return; - const existing = ui.archiveContent.querySelector(".lineage-stale-status"); - if (!show) { - existing?.remove(); - return; - } - if (existing) return; - ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); -} - -function lineageAssignPartnerAttachOffsets(linkGroups) { - const buckets = new Map(); - const pushBucket = (nodeId, side, group) => { - const key = `${nodeId}:${side}`; - if (!buckets.has(key)) buckets.set(key, []); - buckets.get(key).push(group); - }; - for (const group of linkGroups) { - if ((group.parents || []).length < 2) continue; - const left = group.parents[0]; - const right = group.parents[group.parents.length - 1]; - if (!group.attachOffsets) group.attachOffsets = new Map(); - pushBucket(left.node.id, 'right', group); - pushBucket(right.node.id, 'left', group); - } - for (const [key, groups] of buckets.entries()) { - groups.sort((a, b) => a.joinX - b.joinX || a.childTop - b.childTop); - const n = groups.length; - const step = 9; - groups.forEach((group, i) => { - const offset = n <= 1 ? 0 : (i - (n - 1) / 2) * step; - const [nodeId] = key.split(':'); - group.attachOffsets.set(nodeId, offset); - }); - } -} - -function lineageBuildLinks(nodes, idSet, layout) { - const { positions, nodeW, nodeH } = layout; - const groups = new Map(); - for (const child of nodes) { - const parents = lineageParentIds(child, idSet).filter(id => positions.has(id)); - if (parents.length < 1 || !positions.has(child.id)) continue; - const key = parents.slice().sort().join('+'); - if (!groups.has(key)) groups.set(key, { parentIds: parents.slice().sort(), childIds: [] }); - groups.get(key).childIds.push(child.id); - } - - const linkGroups = []; - for (const group of groups.values()) { - const parents = group.parentIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); - const children = group.childIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); - if (!parents.length || !children.length) continue; - const parentBottom = Math.max(...parents.map(p => p.y + nodeH)); - const childTop = Math.min(...children.map(c => c.y)); - const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; - const directCouple = parents.length >= 2 && parents[0].rowIndex === parents[1].rowIndex && (parents[1].x - (parents[0].x + nodeW) <= 168); - linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() }); - } - lineageAssignPartnerAttachOffsets(linkGroups); - const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0); - - const byGap = new Map(); - for (const group of linkGroups) { - const key = `${group.parentBottom.toFixed(1)}:${group.childTop.toFixed(1)}`; - if (!byGap.has(key)) byGap.set(key, []); - byGap.get(key).push(group); - } - - const partnerLinks = []; - const childLinks = []; - const horizontalSegments = []; - - for (const groupsInGap of byGap.values()) { - groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length); - const top = Math.min(...groupsInGap.map(g => g.parentBottom)) + 44; - const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 42; - const span = Math.max(16, bottom - top); - groupsInGap.forEach((group, i) => { - const laneY = top + span * ((i + 1) / (groupsInGap.length + 1)); - if (group.parents.length >= 2) { - const left = group.parents[0]; - const right = group.parents[group.parents.length - 1]; - // Marriage line stays at the parent-node center. The child trunk drops - // to a visible point below the parents before branching, so the fork is - // never hidden behind a node. - const attachA = 0; - const attachB = 0; - const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; - const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB); - if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX; - const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY; - const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36); - group.fromY = clearY; - group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3)); - if (partner.d) { - partnerLinks.push(partner.d); - horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h')); - } - const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []); - if (trunk) childLinks.push(trunk); - } else { - group.fromY = group.parentBottom; - group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, group.fromY + 30) : Math.min(group.childTop - 24, Math.max(group.fromY + 28, laneY + 18 + i * 3)); - } - }); - } - - for (const groupsInGap of byGap.values()) { - groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length); - for (const group of groupsInGap) { - const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b); - if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) { - horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY }); - } - const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments); - if (child.d) childLinks.push(child.d); - } - } - - return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges }; -} - -function lineageSplitByActualEdges(component) { - const family = lineageEdgeFamily(world.family || {}); - const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); - const idSet = new Set(nodes.map(n => n.id)); - const byId = new Map(nodes.map(n => [n.id, n])); - const graph = new Map(nodes.map(n => [n.id, new Set()])); - const connect = (a, b) => { - if (!a || !b || !idSet.has(a) || !idSet.has(b)) return; - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - for (const p of lineageMutualParentIds(n, family, idSet)) connect(n.id, p); - for (const c of lineageMutualChildIds(n, family, idSet)) connect(n.id, c); - } - const seen = new Set(); - const parts = []; - for (const n of nodes.sort(lineageNodeSort)) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const part = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - const node = byId.get(id); - if (node) part.push(node); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (part.length && part.some(x => x.alive)) parts.push(part.sort(lineageNodeSort)); - } - return parts; -} - -function lineageExpandComponent(component, family) { - family = lineageEdgeFamily(family); - const ids = new Set(component.filter(Boolean).map(n => n.id)); - let changed = true; - while (changed) { - changed = false; - for (const id of Array.from(ids)) { - const n = family[id]; - if (!n) continue; - for (const p of lineageMutualParentIds(n, family)) { - if (!ids.has(p)) { ids.add(p); changed = true; } - } - for (const c of lineageMutualChildIds(n, family)) { - if (!ids.has(c)) { ids.add(c); changed = true; } - } - } - } - return Array.from(ids).map(id => family[id]).filter(Boolean); -} - -function lineageCleanComponent(component, family) { - family = lineageEdgeFamily(family); - const expanded = lineageExpandComponent(component, family); - const idSet = new Set(expanded.map(n => n.id)); - return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length); -} - -function lineageFastLayoutNeeded(nodes, idSet, family) { - const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0); - const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length)); - return { - edgeCount, - fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34, - }; -} - -function lineageLayoutRowsFast(rows, idSet) { - const NODE_W = 132; - const NODE_H = 68; - const SIBLING_GAP = 22; - const ROW_GAP = 116; - const LEFT_PAD = 82; - const TOP_PAD = 44; - const RIGHT_PAD = 48; - const BOTTOM_PAD = 30; - const positions = new Map(); - let width = 420; - rows.forEach((row, rowIndex) => { - const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); - let x = LEFT_PAD; - for (const n of row) { - positions.set(n.id, { x, y, rowIndex, node: n }); - x += NODE_W + SIBLING_GAP; - } - width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD); - }); - const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; - return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true }; -} - -function lineageBuildLinksFast(nodes, idSet, layout) { - const { positions, nodeW, nodeH } = layout; - const partnerLinks = []; - const childLinks = []; - const seenPartners = new Set(); - let childLinkGroups = 0; - let childLinkEdges = 0; - for (const child of nodes) { - const childPos = positions.get(child.id); - if (!childPos) continue; - const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); - if (!parents.length) continue; - childLinkGroups += 1; - childLinkEdges += parents.length; - const childX = childPos.x + nodeW / 2; - const childY = childPos.y; - const fromY = Math.max(...parents.map(p => p.y + nodeH)); - const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; - if (parents.length >= 2) { - const left = parents[0]; - const right = parents[parents.length - 1]; - const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+"); - if (partnerKey && !seenPartners.has(partnerKey)) { - seenPartners.add(partnerKey); - const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; - partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`); - } - } - const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48)); - childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`); - } - return { partnerLinks, childLinks, childLinkGroups, childLinkEdges }; -} - -function buildLineageTreeLayout(component, family) { - family = lineageEdgeFamily(family); - const cleaned = lineageCleanComponent(component, family); - const nodes = Array.from(new Map(cleaned.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); - const idToNode = new Map(nodes.map(n => [n.id, n])); - const idSet = new Set(idToNode.keys()); - const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b); - const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet); - const perf = lineageFastLayoutNeeded(nodes, idSet, family); - const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet); - const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout); - - const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 })); - const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y)); - return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount }; -} - -function lineageFamilyHtml(component, index, family, signature = "") { - const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); - const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature); - if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index); - const layout = buildLineageTreeLayout(uniqueComponent, family); - const aliveCount = uniqueComponent.filter(n => n.alive).length; - const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family); - const maskId = "{{LINEAGE_MASK_ID}}"; - const nodeMaskRects = layout.nodes - .map(p => ``) - .join(""); - const labels = layout.labels.map(label => `
    \u4e16\u4ee3 ${label.generation}
    `).join(""); - const partnerPaths = layout.partnerLinks.map(d => ``).join(""); - const childPaths = layout.childLinks.map(d => ``).join(""); - const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); - const empty = !layout.childLinks.length ? `
    \u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    ` : ""; - - const html = `
    -

    \u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

    -
    - - ${labels}${nodeHtml}${empty} -
    -
    `; - uiCache.archiveHtmlCache?.set(cacheKey, html); - return lineageHydrateFamilyHtml(html, index); -} - -function renderArchive() { - if (!ui.archiveContent) return; - if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { - uiCache.archiveScheduled = false; - if (!uiCache.archiveVersion) { - ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002
    `; - } else if (world.familyTreeDirty) { - lineageSetArchiveStaleStatus(true); - } - return; - } - if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; - uiCache.deferArchiveUntil = 0; - uiCache.archiveScheduled = false; - const scrollState = lineageCaptureArchiveScroll(); - const familyVersion = world.familyVersion || 0; - const familyDirty = Boolean(world.familyTreeDirty); - if (uiCache.archiveRenderRunning) { - if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; - return; - } - if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return; - if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { - uiCache.archivePendingOffscreen = true; - const hiddenInterval = 30000; - const hiddenNow = performance.now(); - if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return; - uiCache.archiveLastHiddenCheckAt = hiddenNow; - } - const now = performance.now(); - const minUpdateGap = 4800; - if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { - if (!uiCache.archiveRenderTimer) { - uiCache.archiveRenderTimer = setTimeout(() => { - uiCache.archiveRenderTimer = 0; - uiCache.archiveRenderForce = true; - renderArchive(); - uiCache.archiveRenderForce = false; - }, Math.max(80, minUpdateGap - (now - (uiCache.archiveLastRenderedAt || 0)))); - } - return; - } - if (world.familyPrunePending && world.pruneExtinctFamilies) { - world.familyPrunePending = false; - world.pruneExtinctFamilies(); - } - const family = world.family || {}; - const version = String(world.familyVersion || 0); - if (uiCache.archiveVersion === version) { - world.familyTreeDirty = false; - uiCache.archivePendingDirtyAfterRun = false; - return; - } - const renderStarted = performance.now(); - lineageSetArchiveStaleStatus(false); - if (uiCache.archiveRenderTimer) { - clearTimeout(uiCache.archiveRenderTimer); - uiCache.archiveRenderTimer = 0; - } - uiCache.archiveVersion = version; - uiCache.archiveFamilyVersion = world.familyVersion || 0; - uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0; - uiCache.archiveLastRenderedAt = performance.now(); - uiCache.lastArchiveWindow = ""; - if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map(); - const activeCacheKeys = new Set(); - const token = `${version}:${Math.random().toString(36).slice(2)}`; - uiCache.archiveRenderToken = token; - uiCache.archiveRenderRunning = true; - ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026
    `; - lineageRestoreArchiveScroll(scrollState); - const status = ui.archiveContent.querySelector(".lineage-render-status"); - const forest = ui.archiveContent.querySelector(".lineage-forest"); - const renderComponents = (components) => { - if (uiCache.archiveRenderToken !== token || !forest) { - uiCache.archiveRenderRunning = false; - return; - } - const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length); - uiCache.archiveRows = splitComponents; - if (!splitComponents.length) { - ui.archiveContent.innerHTML = `
    \u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    `; - lineageRestoreArchiveScroll(scrollState); - uiCache.archiveRenderRunning = false; - world.familyTreeDirty = false; - uiCache.archivePendingDirtyAfterRun = false; - return; - } - if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`; - let index = 0; - const pump = () => { - if (uiCache.archiveRenderToken !== token || !forest) { - uiCache.archiveRenderRunning = false; - return; - } - const started = performance.now(); - let nodeBudget = 28; - let renderedThisSlice = 0; - while (index < splitComponents.length) { - const component = splitComponents[index]; - if (renderedThisSlice > 0 && component.length > nodeBudget) break; - const signature = lineageComponentSignature(component, family); - activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature)); - forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature)); - nodeBudget -= Math.max(1, component.length); - index += 1; - renderedThisSlice += 1; - if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break; - } - lineageRestoreArchiveScroll(scrollState); - if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`; - if (index < splitComponents.length) { - lineageIdleSchedule(pump, 220); - } else { - if (status) status.remove(); - for (const key of Array.from(uiCache.archiveHtmlCache.keys())) { - if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key); - } - lineageRestoreArchiveScroll(scrollState); - const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion; - uiCache.archiveRenderRunning = false; - world.familyTreeDirty = renderDirtiedAgain; - if (uiCache.archiveDiagnostics) { - uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1; - uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10; - uiCache.archiveDiagnostics.lastFamilies = splitComponents.length; - uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0); - } - if (renderDirtiedAgain) { - uiCache.archivePendingDirtyAfterRun = false; - scheduleArchiveWindowRender(); - } - } - }; - lineageIdleSchedule(pump, 160); - }; - lineageBuildComponentsIdle( - family, - token, - (label, done, total) => { - if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`; - }, - renderComponents - ); -} - -function resetArchiveRenderState() { - if (uiCache.archiveRenderTimer) { - clearTimeout(uiCache.archiveRenderTimer); - uiCache.archiveRenderTimer = 0; - } - uiCache.archiveRenderToken = ""; - uiCache.archiveRenderRunning = false; - uiCache.archiveScheduled = false; - uiCache.archivePendingOffscreen = false; - uiCache.archivePendingWhileRunning = false; - uiCache.archivePendingDirtyAfterRun = false; - uiCache.archiveLastHiddenCheckAt = -Infinity; - uiCache.archiveLastScheduleFamilyVersion = null; - uiCache.archiveVersion = ""; - uiCache.archiveFamilyVersion = null; - uiCache.archiveRelationSignature = ""; - uiCache.archiveRows = []; - uiCache.lastArchiveWindow = ""; - uiCache.archiveHtmlCache?.clear?.(); -} - -function validateFamilyTree() { - const issues = world.validateFamily ? [...world.validateFamily()] : []; - const rendered = new Map(); - document.querySelectorAll("[data-family-tarinai-id]").forEach(node => { - const id = node.getAttribute("data-family-tarinai-id"); - rendered.set(id, (rendered.get(id) || 0) + 1); - }); - for (const [id, count] of rendered.entries()) { - if (count > 1) issues.push({ type: "duplicate-render-node", id, count }); - } - for (const component of uiCache.archiveRows || []) { - for (const n of component || []) { - if (n?.id && !rendered.has(n.id)) issues.push({ type: "missing-render-node", id: n.id }); - } - } - document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => { - const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0); - const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0); - const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0); - const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0); - const pathCount = section.querySelectorAll(".lineage-links .child-link").length; - if (renderedEdges !== expectedEdges) { - issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges }); - } - if (expectedEdges > 0 && pathCount < 1) { - issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount }); - } - if (expectedGroups > 0 && pathCount < expectedGroups) { - issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount }); - } - if (renderedPaths !== pathCount) { - issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount }); - } - }); - return issues; -} - -function renderArchiveWindow() { - renderArchive(); -} - -function scheduleArchiveWindowRender() { - if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { - uiCache.archiveScheduled = false; - if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); - return; - } - const familyDirty = Boolean(world.familyTreeDirty); - if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return; - if (uiCache.archiveRenderRunning) { - if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; - return; - } - if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { - uiCache.archivePendingOffscreen = true; - const now = performance.now(); - if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return; - uiCache.archiveLastHiddenCheckAt = now; - } - if (uiCache.archiveRenderTimer) return; - if (uiCache.archiveScheduled) return; - uiCache.archiveScheduled = true; - const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); - schedule(() => { - renderArchive(); - }, { timeout: 1200 }); -} - -function schedulePendingArchiveRender() { - if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; - if (!uiCache.archivePendingOffscreen) return; - if (!lineageArchiveNearViewport()) return; - uiCache.archivePendingOffscreen = false; - scheduleArchiveWindowRender(); -} - -window.validateFamilyTree = validateFamilyTree; -window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) }); -window.resetArchiveRenderState = resetArchiveRenderState; diff --git a/js/ui_family_async.js b/js/ui_family_async.js new file mode 100644 index 0000000..60458e2 --- /dev/null +++ b/js/ui_family_async.js @@ -0,0 +1,189 @@ +"use strict"; + +function lineageIdleSchedule(fn, timeout = 180) { + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + schedule(fn, { timeout }); +} + +function lineageSameFamilyToken(token) { + return uiCache.archiveRenderToken === token; +} + +function lineageBuildComponentsIdle(family, token, onProgress, onDone) { + family = lineageEdgeFamily(family); + const entries = Object.values(family || {}).filter(n => n?.id); + const known = new Set(entries.map(n => n.id)); + const parentsByChild = new Map(); + const childrenByParent = new Map(); + const relatedIds = new Set(); + const graph = new Map(); + const comps = []; + let stage = "relations"; + let entryIndex = 0; + let nodeIndex = 0; + let nodes = []; + let sortedNodes = []; + let dfsIndex = 0; + const seen = new Set(); + + const addParent = (childId, parentId) => { + if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; + if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); + parentsByChild.get(childId).add(parentId); + childrenByParent.get(parentId).add(childId); + }; + const addGraphEdge = (a, b) => { + if (!a || !b || !graph.has(a) || !graph.has(b)) return; + graph.get(a).add(b); + graph.get(b).add(a); + }; + const validParentChild = (childId, parentId) => { + const child = family[childId]; + const parent = family[parentId]; + if (!child || !parent) return false; + const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId); + const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId); + return childListsParent || parentListsChild; + }; + const finishEmpty = () => { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + onDone([]); + }; + + const pump = () => { + if (!lineageSameFamilyToken(token)) return; + const started = performance.now(); + while (performance.now() - started < 6) { + if (stage === "relations") { + if (entryIndex >= entries.length) { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + stage = "related"; + entryIndex = 0; + onProgress?.("\u95a2\u4fc2\u3092\u6574\u7406\u4e2d", 0, entries.length); + continue; + } + const n = entries[entryIndex++]; + for (const parentId of n.parents || []) addParent(n.id, parentId); + for (const childId of n.children || []) addParent(childId, n.id); + } else if (stage === "related") { + if (entryIndex >= entries.length) { + nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); + for (const n of nodes) graph.set(n.id, new Set()); + if (!nodes.length) return finishEmpty(); + stage = "graph"; + nodeIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d", 0, nodes.length); + continue; + } + const n = entries[entryIndex++]; + const parents = parentsByChild.get(n.id) || new Set(); + const children = childrenByParent.get(n.id) || new Set(); + if (parents.size || children.size) relatedIds.add(n.id); + for (const id of parents) relatedIds.add(id); + for (const id of children) relatedIds.add(id); + } else if (stage === "graph") { + if (nodeIndex >= nodes.length) { + sortedNodes = nodes.slice().sort(lineageNodeSort); + stage = "components"; + dfsIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", 0, sortedNodes.length); + continue; + } + const n = nodes[nodeIndex++]; + const parentCandidates = new Set((n.parents || []).filter(Boolean)); + for (const parentId of parentsByChild.get(n.id) || []) parentCandidates.add(parentId); + for (const parentId of parentCandidates) { + if (parentId !== n.id && graph.has(parentId) && validParentChild(n.id, parentId)) addGraphEdge(n.id, parentId); + } + const childCandidates = new Set((n.children || []).filter(Boolean)); + for (const childId of childrenByParent.get(n.id) || []) childCandidates.add(childId); + for (const childId of childCandidates) { + if (childId !== n.id && graph.has(childId) && validParentChild(childId, n.id)) addGraphEdge(n.id, childId); + } + } else if (stage === "components") { + while (dfsIndex < sortedNodes.length && seen.has(sortedNodes[dfsIndex].id)) dfsIndex += 1; + if (dfsIndex >= sortedNodes.length) { + comps.sort((a, b) => { + const ag = Math.min(...a.map(n => n.generation || 1)); + const bg = Math.min(...b.map(n => n.generation || 1)); + const at = Math.min(...a.map(n => n.birthTime || 0)); + const bt = Math.min(...b.map(n => n.birthTime || 0)); + return ag - bg || at - bt || b.length - a.length; + }); + onDone(comps); + return; + } + const start = sortedNodes[dfsIndex++]; + const stack = [start.id]; + const comp = []; + seen.add(start.id); + while (stack.length && performance.now() - started < 6) { + const id = stack.pop(); + const item = family[id]; + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (stack.length) { + const continueComp = () => { + if (!lineageSameFamilyToken(token)) return; + const resumedStarted = performance.now(); + while (stack.length && performance.now() - resumedStarted < 6) { + const id = stack.pop(); + const item = family[id]; + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (stack.length) return lineageIdleSchedule(continueComp, 180); + if (comp.some(x => x.alive)) comps.push(comp); + lineageIdleSchedule(pump, 180); + }; + return lineageIdleSchedule(continueComp, 180); + } + if (comp.some(x => x.alive)) comps.push(comp); + } + } + onProgress?.( + stage === "relations" ? "\u95a2\u4fc2\u3092\u6574\u7406\u4e2d" : stage === "related" ? "\u8868\u793a\u5bfe\u8c61\u3092\u9078\u5225\u4e2d" : stage === "graph" ? "\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d" : "\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", + stage === "graph" ? nodeIndex : entryIndex, + stage === "graph" ? nodes.length : entries.length + ); + lineageIdleSchedule(pump, 180); + }; + lineageIdleSchedule(pump, 160); +} + +function familyStatusText(n) { + if (!n) return "\u4e0d\u660e"; + return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; +} + +function familyDeathReasonLabel(rawReason, entity = null) { + const text = String(rawReason || ""); + if (!text) return ""; + if (entity?.briefDeathReason) return entity.briefDeathReason(text); + if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text; + if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629"; + if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; + if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"; + if (/\u885d\u7a81|collision/.test(text)) return "\u885d\u7a81"; + if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e"; + if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5"; + if (/\u75c5/.test(text)) { + const disease = text.match(/([^\u3001\u3002\s]+\u75c5)/); + return disease ? disease[1] : "\u305a\u3093\u3061\u75c5"; + } + if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; + if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; + if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; + return "\u4e8b\u6545"; +} diff --git a/js/ui_family_data.js b/js/ui_family_data.js new file mode 100644 index 0000000..c7f883b --- /dev/null +++ b/js/ui_family_data.js @@ -0,0 +1,131 @@ +"use strict"; + +function buildArchiveRows(family) { + return liveFamilyComponents(family); +} + +function lineageEdgeFamily(family = world.family || {}) { + return family || {}; +} + +let lineageRelationIndexCache = { family: null, version: null, index: null }; + +function lineageBuildRelationIndex(family) { + const known = new Set(Object.keys(family || {})); + const parentsByChild = new Map(); + const childrenByParent = new Map(); + const addParent = (childId, parentId) => { + if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; + if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); + parentsByChild.get(childId).add(parentId); + childrenByParent.get(parentId).add(childId); + }; + for (const n of Object.values(family || {})) { + if (!n?.id) continue; + for (const parentId of n.parents || []) addParent(n.id, parentId); + for (const childId of n.children || []) addParent(childId, n.id); + } + return { parentsByChild, childrenByParent }; +} + +function lineageRelationIndex(family = world.family || {}) { + family = lineageEdgeFamily(family); + if (family !== (world.family || {})) return lineageBuildRelationIndex(family); + const version = world.familyVersion || 0; + if (lineageRelationIndexCache.family !== family || lineageRelationIndexCache.version !== version) { + lineageRelationIndexCache = { family, version, index: lineageBuildRelationIndex(family) }; + } + return lineageRelationIndexCache.index; +} + +function lineageMutualParentIds(n, family = world.family || {}, idSet = null) { + const seen = new Set(); + const parents = []; + if (!n?.id) return parents; + const relationIndex = lineageRelationIndex(family); + const candidates = new Set((n.parents || []).filter(Boolean)); + // Runtime updates can briefly touch one side first; recover the reciprocal + // edge from the relation index so the archive does not flicker. + for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId); + for (const id of candidates) { + if (!id || id === n.id || seen.has(id)) continue; + if (idSet && !idSet.has(id)) continue; + const parent = family[id]; + if (!parent) continue; + const childListsParent = Array.isArray(n.parents) && n.parents.includes(id); + const parentListsChild = Array.isArray(parent.children) && parent.children.includes(n.id); + if (!childListsParent && !parentListsChild) continue; + seen.add(id); + parents.push(id); + } + parents.sort((a, b) => lineageNodeSort(family[a], family[b])); + return parents; +} + +function lineageMutualChildIds(n, family = world.family || {}, idSet = null) { + const seen = new Set(); + const children = []; + if (!n?.id) return children; + const relationIndex = lineageRelationIndex(family); + const candidates = new Set((n.children || []).filter(Boolean)); + // Same recovery as above, in the opposite direction. + for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId); + for (const id of candidates) { + if (!id || id === n.id || seen.has(id)) continue; + if (idSet && !idSet.has(id)) continue; + const child = family[id]; + if (!child) continue; + const parentListsChild = Array.isArray(n.children) && n.children.includes(id); + const childListsParent = Array.isArray(child.parents) && child.parents.includes(n.id); + if (!parentListsChild && !childListsParent) continue; + seen.add(id); + children.push(id); + } + children.sort((a, b) => lineageNodeSort(family[a], family[b])); + return children; +} + +function lineageHasActualRelation(n, family = world.family || {}) { + return lineageMutualParentIds(n, family).length > 0 || lineageMutualChildIds(n, family).length > 0; +} + +function lineageRelatedIdSet(family) { + family = lineageEdgeFamily(family); + const relationIndex = lineageRelationIndex(family); + const ids = new Set(); + for (const n of Object.values(family || {}).filter(Boolean)) { + const parents = relationIndex.parentsByChild.get(n.id) || []; + const children = relationIndex.childrenByParent.get(n.id) || []; + if (parents.size || children.size) ids.add(n.id); + for (const id of parents) ids.add(id); + for (const id of children) ids.add(id); + } + return ids; +} + +function liveFamilyComponents(family) { + // Childless pairs, including pairs currently performing the birth ritual, are + // intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child + // relation exists; otherwise ritual participants become isolated pseudo-families. + family = lineageEdgeFamily(family); + const relatedIds = lineageRelatedIdSet(family); + const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); + const ids = new Set(nodes.map(n => n.id)); + const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, { + sort: lineageNodeSort, + edgesOf: (n) => [ + ...lineageMutualParentIds(n, family, ids), + ...lineageMutualChildIds(n, family, ids), + ], + filter: comp => comp.some(x => x.alive), + }); + comps.sort((a, b) => { + const ag = Math.min(...a.map(n => n.generation || 1)); + const bg = Math.min(...b.map(n => n.generation || 1)); + const at = Math.min(...a.map(n => n.birthTime || 0)); + const bt = Math.min(...b.map(n => n.birthTime || 0)); + return ag - bg || at - bt || b.length - a.length; + }); + return comps; +} diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js new file mode 100644 index 0000000..db238fd --- /dev/null +++ b/js/ui_family_layout.js @@ -0,0 +1,547 @@ +"use strict"; + +function lineageNodeSort(a, b) { + return (a.generation || 1) - (b.generation || 1) || + (a.birthTime || 0) - (b.birthTime || 0) || + String(a.name || "").localeCompare(String(b.name || "")) || + String(a.id || "").localeCompare(String(b.id || "")); +} + +function lineageTreeNodeHtml(n, x, y) { + const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null; + const live = entity && !entity.dead ? entity : null; + const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path; + const name = escapeHtml(n?.name || "\u4e0d\u660e"); + const status = familyStatusText(n); + const gen = n?.generation ? `G${n.generation}` : "G?"; + const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; + const briefReason = familyDeathReasonLabel(rawReason, entity); + const reason = briefReason ? ` / ${briefReason}` : ""; + const rawTags = live?.personalityTags ? live.personalityTags() : (Array.isArray(n?.personalityTags) ? n.personalityTags : []); + const tags = rawTags.slice(); + const tagText = tags.length ? ` / ${tags.join(" ")}` : ""; + const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`); + const deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; + const tagLine = tags.length ? `${tags.map(tag => `${escapeHtml(tag)}`).join("")}` : ""; + const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; + const childCls = (n?.parents || []).length ? " child" : ""; + const iconBaseScale = live + ? (live.effectiveScale ? live.effectiveScale() : (Number.isFinite(live.scale) ? live.scale : (Number.isFinite(live.adultScale) ? live.adultScale : 0.28))) + : (Number.isFinite(n?.scale) ? n.scale : (Number.isFinite(n?.adultScale) ? n.adultScale : 0.28)); + const iconScale = clamp((Number(iconBaseScale) || 0.28) / 0.28, 0.64, 1.42).toFixed(2); + const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : ""; + return `
    + +
    + ${name} + ${escapeHtml(gen)} / ${escapeHtml(status)} + ${deathLine} + ${tagLine} +
    +
    `; +} + +/* + * Family archive rendering is intentionally a top-down tree diagram. + * Do not replace this section with list/cards/readable grids: the archive must remain a \u5bb6\u7cfb\u56f3. + * The layout below may reorder siblings/parents inside the same generation only to reduce crossings. + */ + +function lineageParentIds(n, idSet, family = world.family || {}) { + return lineageMutualParentIds(n, family, idSet).slice(0, 2); +} + +function lineageParentKey(n, idSet) { + const parents = lineageParentIds(n, idSet); + if (!parents.length) return `root:${n?.id || Math.random()}`; + return parents.slice().sort().join("+"); +} + +function lineageMedian(values) { + if (!values.length) return Number.POSITIVE_INFINITY; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function lineageRankMap(rows) { + const rank = new Map(); + rows.forEach((row, rowIndex) => { + row.forEach((n, i) => rank.set(n.id, rowIndex * 10000 + i)); + }); + return rank; +} + +function lineageChildIds(n, idSet, family = world.family || {}) { + return lineageMutualChildIds(n, family, idSet); +} + +function lineageOrderChildGroups(row, idSet, rank) { + const groups = new Map(); + for (const n of row) { + const key = lineageParentKey(n, idSet); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + const records = Array.from(groups.entries()).map(([key, items]) => { + const parents = key.startsWith("root:") ? [] : key.split("+").filter(Boolean); + const parentRanks = parents.map(id => rank.get(id)).filter(v => Number.isFinite(v)); + const score = parentRanks.length ? lineageMedian(parentRanks) : Number.POSITIVE_INFINITY; + items.sort(lineageNodeSort); + return { + key, + items, + score, + fallback: Math.min(...items.map(n => n.birthTime || 0)), + generation: Math.min(...items.map(n => n.generation || 1)), + }; + }); + records.sort((a, b) => a.score - b.score || a.generation - b.generation || a.fallback - b.fallback || String(a.key).localeCompare(String(b.key))); + return records.flatMap(r => r.items); +} + +function lineageOrderParentsByChildren(row, idSet, childRank) { + if (!row.length || !childRank?.size) return row; + const records = row.map((n, index) => { + const childRanks = lineageChildIds(n, idSet).map(id => childRank.get(id)).filter(v => Number.isFinite(v)); + return { + node: n, + score: childRanks.length ? lineageMedian(childRanks) : Number.POSITIVE_INFINITY, + original: index, + }; + }); + records.sort((a, b) => a.score - b.score || a.original - b.original || lineageNodeSort(a.node, b.node)); + return records.map(r => r.node); +} + +function lineagePartnerEdgesByGeneration(nodes, idToNode, idSet) { + const byGeneration = new Map(); + const seen = new Set(); + for (const child of nodes) { + const parents = lineageParentIds(child, idSet); + if (parents.length < 2) continue; + const a = idToNode.get(parents[0]); + const b = idToNode.get(parents[1]); + if (!a || !b) continue; + const generation = Math.min(a.generation || 1, b.generation || 1); + const key = parents.slice().sort().join("+"); + if (seen.has(key)) continue; + seen.add(key); + if (!byGeneration.has(generation)) byGeneration.set(generation, []); + byGeneration.get(generation).push(parents.slice()); + } + return byGeneration; +} + +function lineageOrderPartnerComponent(ids, adjacency, rank, idToNode) { + if (ids.length <= 2) { + return ids.slice().sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); + } + const degree = id => (adjacency.get(id) || new Set()).size; + const start = ids.slice().sort((a, b) => degree(b) - degree(a) || (rank.get(a) ?? 0) - (rank.get(b) ?? 0))[0]; + const neighbors = Array.from(adjacency.get(start) || []).filter(id => ids.includes(id)); + const left = []; + const right = []; + neighbors.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); + neighbors.forEach((id, i) => (i % 2 ? left : right).push(id)); + const placed = new Set([start, ...neighbors]); + const rest = ids.filter(id => !placed.has(id)).sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b))); + return [...left.reverse(), start, ...right, ...rest]; +} + +function lineageOrderPartnerClusters(row, partnerEdges, rank, idToNode) { + if (!partnerEdges?.length) return row; + const rowIds = new Set(row.map(n => n.id)); + const adjacency = new Map(); + for (const [a, b] of partnerEdges) { + if (!rowIds.has(a) || !rowIds.has(b)) continue; + if (!adjacency.has(a)) adjacency.set(a, new Set()); + if (!adjacency.has(b)) adjacency.set(b, new Set()); + adjacency.get(a).add(b); + adjacency.get(b).add(a); + } + if (!adjacency.size) return row; + + const clusteredIds = new Set(adjacency.keys()); + const seen = new Set(); + const records = []; + for (const n of row) { + if (!clusteredIds.has(n.id)) { + records.push({ ids: [n.id], score: rank.get(n.id) ?? Number.POSITIVE_INFINITY, fallback: idToNode.get(n.id)?.birthTime || 0 }); + continue; + } + if (seen.has(n.id)) continue; + const stack = [n.id]; + const ids = []; + seen.add(n.id); + while (stack.length) { + const id = stack.pop(); + ids.push(id); + for (const next of adjacency.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + const orderedIds = lineageOrderPartnerComponent(ids, adjacency, rank, idToNode); + records.push({ + ids: orderedIds, + score: lineageMedian(orderedIds.map(id => rank.get(id)).filter(v => Number.isFinite(v))), + fallback: Math.min(...orderedIds.map(id => idToNode.get(id)?.birthTime || 0)), + }); + } + records.sort((a, b) => a.score - b.score || a.fallback - b.fallback || String(a.ids[0]).localeCompare(String(b.ids[0]))); + return records.flatMap(c => c.ids.map(id => idToNode.get(id)).filter(Boolean)); +} + +function lineageAdjacentCrossCost(parentRow, childRow, idSet) { + if (!parentRow?.length || !childRow?.length) return 0; + const parentPos = new Map(parentRow.map((n, i) => [n.id, i])); + const edges = []; + for (let childIndex = 0; childIndex < childRow.length; childIndex++) { + const child = childRow[childIndex]; + const parents = lineageParentIds(child, idSet).filter(id => parentPos.has(id)); + if (!parents.length) continue; + const from = lineageMedian(parents.map(id => parentPos.get(id))); + edges.push({ from, to: childIndex, weight: parents.length >= 2 ? 1.4 : 1 }); + } + let cost = 0; + for (let i = 0; i < edges.length; i++) { + for (let j = i + 1; j < edges.length; j++) { + const a = edges[i]; + const b = edges[j]; + const cross = (a.from - b.from) * (a.to - b.to) < 0; + if (cross) cost += a.weight * b.weight * 4.5; + } + } + return cost; +} + +function lineageRowCost(row, rowIndex, rows, idSet) { + const pos = new Map(row.map((n, i) => [n.id, i])); + let cost = 0; + for (const n of row) { + const i = pos.get(n.id); + const parents = lineageParentIds(n, idSet); + if (parents.length && rowIndex > 0) { + const parentRow = rows[rowIndex - 1] || []; + const parentPos = parents.map(id => parentRow.findIndex(x => x.id === id)).filter(v => v >= 0); + if (parentPos.length) cost += Math.abs(i - lineageMedian(parentPos)) * (parents.length >= 2 ? 1.5 : 1.2); + } + const childRow = rows[rowIndex + 1] || []; + const childPos = childRow.filter(x => lineageParentIds(x, idSet).includes(n.id)).map(x => childRow.findIndex(y => y.id === x.id)); + if (childPos.length) cost += Math.abs(i - lineageMedian(childPos)) * 1.0; + + const siblingPos = row.filter(x => x.id !== n.id && lineageParentKey(x, idSet) === lineageParentKey(n, idSet)).map(x => pos.get(x.id)); + if (siblingPos.length) cost += Math.abs(i - lineageMedian(siblingPos)) * 0.22; + } + + const seenPairs = new Set(); + for (const child of rows[rowIndex + 1] || []) { + const parents = lineageParentIds(child, idSet).filter(id => pos.has(id)); + if (parents.length < 2) continue; + const key = parents.slice().sort().join("+"); + if (seenPairs.has(key)) continue; + seenPairs.add(key); + const pair = parents.slice().sort((a, b) => pos.get(a) - pos.get(b)); + const span = pos.get(pair[1]) - pos.get(pair[0]); + cost += span * span * 3.4; + if (span > 1) cost += 12; + } + + return cost; +} + +function lineageTotalCost(rows, idSet) { + let cost = 0; + for (let i = 0; i < rows.length; i++) { + cost += lineageRowCost(rows[i], i, rows, idSet); + if (i < rows.length - 1) cost += lineageAdjacentCrossCost(rows[i], rows[i + 1], idSet); + } + return cost; +} + +function lineageMoveItem(row, fromIndex, toIndex) { + if (fromIndex === toIndex) return row.slice(); + const next = row.slice(); + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +} + +function lineageOptimizeRows(rows, idSet) { + const totalNodes = rows.reduce((sum, row) => sum + row.length, 0); + const maxRow = Math.max(0, ...rows.map(row => row.length)); + // The old exhaustive optimizer was cubic-to-quartic in practice and froze on + // large archives. Keep it only for small families; large trees use the + // barycentric ordering above, which is stable and linear-ish. + if (totalNodes > 90 || maxRow > 16) return rows.map(row => row.slice()); + let bestRows = rows.map(row => row.slice()); + let bestCost = lineageTotalCost(bestRows, idSet); + const passes = totalNodes > 54 || maxRow > 10 ? 3 : 6; + for (let pass = 0; pass < passes; pass++) { + let changed = false; + for (let rowIndex = 0; rowIndex < bestRows.length; rowIndex++) { + const row = bestRows[rowIndex]; + if (!row || row.length < 2) continue; + for (let from = 0; from < row.length; from++) { + const span = row.length > 10 ? 3 : row.length; + const minTo = Math.max(0, from - span); + const maxTo = Math.min(row.length - 1, from + span); + for (let to = minTo; to <= maxTo; to++) { + if (from === to) continue; + const moved = lineageMoveItem(bestRows[rowIndex], from, to); + const candidate = bestRows.slice(); + candidate[rowIndex] = moved; + const cost = lineageTotalCost(candidate, idSet); + if (cost + 0.001 < bestCost) { + bestRows = candidate; + bestCost = cost; + changed = true; + } + } + } + } + if (!changed) break; + } + return bestRows; +} + +function lineageRowsForLayout(nodes, generations, idToNode, idSet) { + const byGeneration = new Map(generations.map(g => [g, []])); + for (const n of nodes) byGeneration.get(n.generation || 1).push(n); + let rows = generations.map(g => (byGeneration.get(g) || []).slice().sort(lineageNodeSort)); + const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet); + const totalNodes = nodes.length; + const maxRow = Math.max(0, ...rows.map(row => row.length)); + const veryLarge = totalNodes > 220 || maxRow > 44; + const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6)); + + for (let pass = 0; pass < passes; pass++) { + let rank = lineageRankMap(rows); + rows = rows.map(row => lineageOrderChildGroups(row, idSet, rank)); + rank = lineageRankMap(rows); + for (let i = rows.length - 2; i >= 0; i--) { + const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index])); + rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank); + } + if (!veryLarge) { + rank = lineageRankMap(rows); + rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); + if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); + } + } + return rows; +} + +function lineageBuildRowGroups(row, idSet) { + const groups = []; + let i = 0; + while (i < row.length) { + const key = lineageParentKey(row[i], idSet); + const items = [row[i]]; + i += 1; + while (i < row.length && lineageParentKey(row[i], idSet) === key) { + items.push(row[i]); + i += 1; + } + groups.push({ key, items }); + } + return groups; +} + +function lineageLayoutRows(rows, idSet) { + const NODE_W = 132; + const NODE_H = 84; + const SIBLING_GAP = 32; + const GROUP_GAP = 52; + const PARTNER_GROUP_GAP = 24; + const ROW_GAP = 138; + const LEFT_PAD = 82; + const TOP_PAD = 44; + const RIGHT_PAD = 48; + const BOTTOM_PAD = 30; + + const rowGroups = rows.map(row => lineageBuildRowGroups(row, idSet).map(group => ({ + ...group, + groupW: group.items.length * NODE_W + Math.max(0, group.items.length - 1) * SIBLING_GAP, + }))); + + const pairedKeysByRow = rows.map((row) => { + const set = new Set(); + const rowIds = new Set(row.map(n => n.id)); + for (const futureRow of rows) { + for (const child of futureRow) { + const parents = lineageParentIds(child, rowIds); + if (parents.length >= 2) set.add(parents.slice().sort().join("+")); + } + } + return set; + }); + + let positions = new Map(); + let width = 420; + + const placePass = (existingPositions) => { + const nextPositions = new Map(); + let nextWidth = 420; + const rowLayouts = []; + + rows.forEach((row, rowIndex) => { + const groups = rowGroups[rowIndex] || []; + const desiredLefts = []; + const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); + const nextRow = rows[rowIndex + 1] || []; + + for (let gIndex = 0; gIndex < groups.length; gIndex++) { + const group = groups[gIndex]; + const first = group.items[0]; + const parentCenters = lineageParentIds(first, idSet) + .map(id => existingPositions.get(id) || nextPositions.get(id)) + .filter(Boolean) + .map(p => p.x + NODE_W / 2); + const groupIds = new Set(group.items.map(n => n.id)); + const childCenters = nextRow + .filter(child => lineageParentIds(child, idSet).some(id => groupIds.has(id))) + .map(child => existingPositions.get(child.id)) + .filter(Boolean) + .map(p => p.x + NODE_W / 2); + const desiredCenterValues = []; + if (parentCenters.length) desiredCenterValues.push(...parentCenters, ...parentCenters); + if (childCenters.length) desiredCenterValues.push(...childCenters); + const fallbackCenter = LEFT_PAD + group.groupW / 2 + gIndex * (group.groupW + GROUP_GAP); + const desiredCenter = Number.isFinite(lineageMedian(desiredCenterValues)) ? lineageMedian(desiredCenterValues) : fallbackCenter; + desiredLefts[gIndex] = desiredCenter - group.groupW / 2; + } + + const lefts = []; + const SOFT_EXTRA_GAP = 68; + for (let gIndex = 0; gIndex < groups.length; gIndex++) { + const prevGroup = groups[gIndex - 1]; + const pairGap = prevGroup && prevGroup.items.length === 1 && groups[gIndex].items.length === 1 + && pairedKeysByRow[rowIndex].has([prevGroup.items[0].id, groups[gIndex].items[0].id].sort().join("+")); + const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; + const minLeft = gIndex === 0 ? LEFT_PAD : lefts[gIndex - 1] + groups[gIndex - 1].groupW + gap; + const desired = Number.isFinite(desiredLefts[gIndex]) ? desiredLefts[gIndex] : minLeft; + lefts[gIndex] = Math.max(minLeft, Math.min(desired, minLeft + SOFT_EXTRA_GAP)); + } + for (let gIndex = groups.length - 2; gIndex >= 0; gIndex--) { + const nextGroup = groups[gIndex + 1]; + const pairGap = nextGroup && groups[gIndex].items.length === 1 && nextGroup.items.length === 1 + && pairedKeysByRow[rowIndex].has([groups[gIndex].items[0].id, nextGroup.items[0].id].sort().join("+")); + const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; + const maxLeft = lefts[gIndex + 1] - groups[gIndex].groupW - gap; + lefts[gIndex] = Math.min(lefts[gIndex], maxLeft); + if (gIndex === 0) lefts[gIndex] = Math.max(lefts[gIndex], LEFT_PAD); + } + + const contentW = groups.length ? (lefts[lefts.length - 1] + groups[groups.length - 1].groupW - LEFT_PAD) : 0; + nextWidth = Math.max(nextWidth, LEFT_PAD + contentW + RIGHT_PAD); + groups.forEach((group, gIndex) => { + const left = lefts[gIndex]; + group.items.forEach((n, i) => { + const x = left + i * (NODE_W + SIBLING_GAP); + nextPositions.set(n.id, { x, y, rowIndex, node: n }); + }); + }); + rowLayouts.push({ rowIndex, groups, lefts, contentW, y }); + }); + + return { positions: nextPositions, width: nextWidth, rowLayouts }; + }; + + for (let pass = 0; pass < 5; pass++) { + const result = placePass(positions); + positions = result.positions; + width = result.width; + } + + // Re-run once more and center the entire family as one block to avoid left-heavy layouts + // without breaking parent/child alignment between rows. + const finalResult = placePass(positions); + positions = new Map(finalResult.positions); + let minX = Number.POSITIVE_INFINITY; + let maxX = 0; + for (const pos of positions.values()) { + minX = Math.min(minX, pos.x); + maxX = Math.max(maxX, pos.x + NODE_W); + } + const contentW = Number.isFinite(minX) ? Math.max(0, maxX - minX) : 0; + width = Math.max(finalResult.width, LEFT_PAD + contentW + RIGHT_PAD); + if (Number.isFinite(minX)) { + const targetLeft = LEFT_PAD + Math.max(0, (width - LEFT_PAD - RIGHT_PAD - contentW) / 2); + const shift = targetLeft - minX; + if (shift) { + for (const [id, prev] of positions.entries()) positions.set(id, { ...prev, x: prev.x + shift }); + } + } + + const groupBounds = (group) => { + const xs = group.items.map(n => positions.get(n.id)?.x).filter(Number.isFinite); + if (!xs.length) return null; + return { left: Math.min(...xs), right: Math.max(...xs) + NODE_W, width: group.groupW }; + }; + const groupGap = (leftGroup, rightGroup, rowIndex) => { + const pairGap = leftGroup && rightGroup && leftGroup.items.length === 1 && rightGroup.items.length === 1 + && pairedKeysByRow[rowIndex].has([leftGroup.items[0].id, rightGroup.items[0].id].sort().join("+")); + return pairGap ? PARTNER_GROUP_GAP : GROUP_GAP; + }; + const shiftGroup = (group, dx) => { + if (!dx) return; + for (const n of group.items) { + const prev = positions.get(n.id); + if (prev) positions.set(n.id, { ...prev, x: prev.x + dx }); + } + }; + for (let pass = 0; pass < 3; pass++) { + for (let rowIndex = 1; rowIndex < rowGroups.length; rowIndex++) { + const groups = rowGroups[rowIndex] || []; + for (let gIndex = 0; gIndex < groups.length; gIndex++) { + const group = groups[gIndex]; + const first = group.items[0]; + const parentIds = lineageParentIds(first, idSet); + if (!parentIds.length) continue; + const parentPositions = parentIds.map(id => positions.get(id)).filter(Boolean); + if (!parentPositions.length) continue; + const parentCenter = parentPositions.reduce((sum, p) => sum + p.x + NODE_W / 2, 0) / parentPositions.length; + const bounds = groupBounds(group); + if (!bounds) continue; + const idealLeft = parentCenter - bounds.width / 2; + let minShift = LEFT_PAD - bounds.left; + let maxShift = Number.POSITIVE_INFINITY; + const prevGroup = groups[gIndex - 1]; + if (prevGroup) { + const pb = groupBounds(prevGroup); + if (pb) minShift = Math.max(minShift, pb.right + groupGap(prevGroup, group, rowIndex) - bounds.left); + } + const nextGroup = groups[gIndex + 1]; + if (nextGroup) { + const nb = groupBounds(nextGroup); + if (nb) maxShift = nb.left - groupGap(group, nextGroup, rowIndex) - bounds.right; + } + const desiredShift = idealLeft - bounds.left; + const shift = clamp(desiredShift, minShift, maxShift); + if (Math.abs(shift) > 0.2) shiftGroup(group, shift); + } + } + } + width = Math.max(width, ...Array.from(positions.values()).map(p => p.x + NODE_W + RIGHT_PAD)); + + // Fallback placement for any node that somehow missed layout. + rows.forEach((row, rowIndex) => { + const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); + let fallbackX = LEFT_PAD; + for (const n of row) { + if (positions.has(n.id)) { + fallbackX = Math.max(fallbackX, positions.get(n.id).x + NODE_W + GROUP_GAP); + continue; + } + positions.set(n.id, { x: fallbackX, y, rowIndex, node: n }); + fallbackX += NODE_W + GROUP_GAP; + width = Math.max(width, fallbackX + RIGHT_PAD); + } + }); + + const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; + return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP }; +} diff --git a/js/ui_family_paths.js b/js/ui_family_paths.js new file mode 100644 index 0000000..6cc8158 --- /dev/null +++ b/js/ui_family_paths.js @@ -0,0 +1,364 @@ +"use strict"; + +function lineageSegmentKind(x1, y1, x2, y2) { + if (Math.abs(y2 - y1) < 0.01) return 'h'; + if (Math.abs(x2 - x1) < 0.01) return 'v'; + return 'd'; +} + +function lineageSegmentsFromPoints(points) { + const segments = []; + for (let i = 1; i < points.length; i++) { + const [x1, y1] = points[i - 1]; + const [x2, y2] = points[i]; + segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 }); + } + return segments; +} + +function lineagePathFromPoints(points) { + if (!points.length) return ""; + return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); +} + +function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) { + const ax = a.x + nodeW / 2; + const bx = b.x + nodeW / 2; + const ay = a.y + nodeH; + const by = b.y + nodeH; + const y = Math.max(unionY, ay + 12, by + 12); + const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2; + const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]]; + return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y }; +} + +function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { + const left = a.x <= b.x ? a : b; + const right = left === a ? b : a; + const midYLeft = left.y + nodeH / 2 + attachOffsetA; + const midYRight = right.y + nodeH / 2 + attachOffsetB; + const x1 = left.x + nodeW; + const x2 = right.x; + if (x2 <= x1) return { d: "", segments: [] }; + const direct = left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10 + && (!Number.isFinite(unionY) || unionY <= Math.max(midYLeft, midYRight) + 16); + if (direct) { + const y = (midYLeft + midYRight) / 2; + return { d: `M ${x1.toFixed(1)} ${y.toFixed(1)} L ${x2.toFixed(1)} ${y.toFixed(1)}`, segments: [{ kind: "h", x1, y1: y, x2, y2: y }] }; + } + const bend = 10; + const y = Number.isFinite(unionY) ? unionY : Math.max(midYLeft, midYRight); + const points = [ + [x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight] + ]; + return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y }; +} + +function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { + if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY); + return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB); +} + +function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) { + if (!Number.isFinite(x) || !Number.isFinite(y1) || !Number.isFinite(y2)) return ''; + const down = y2 >= y1; + const sign = down ? 1 : -1; + const sorted = bridges.slice().sort((a, b) => down ? a - b : b - a).filter(y => Math.abs(y - y1) > radius + 1 && Math.abs(y - y2) > radius + 1); + let d = `M ${x.toFixed(1)} ${y1.toFixed(1)}`; + let cursor = y1; + for (const by of sorted) { + const before = by - sign * radius; + const after = by + sign * radius; + d += ` L ${x.toFixed(1)} ${before.toFixed(1)}`; + d += ` Q ${(x + radius * 1.55).toFixed(1)} ${by.toFixed(1)} ${x.toFixed(1)} ${after.toFixed(1)}`; + cursor = after; + } + if (Math.abs(cursor - y2) > 0.01) d += ` L ${x.toFixed(1)} ${y2.toFixed(1)}`; + return d; +} + +function lineageIntersectionYsForVertical(x, y1, y2, horizontalSegments) { + const top = Math.min(y1, y2); + const bottom = Math.max(y1, y2); + const ys = []; + for (const seg of horizontalSegments || []) { + if (seg.kind !== 'h') continue; + const minX = Math.min(seg.x1, seg.x2); + const maxX = Math.max(seg.x1, seg.x2); + const y = seg.y1; + if (x > minX + 3 && x < maxX - 3 && y > top + 3 && y < bottom - 3) ys.push(y); + } + return ys; +} + +function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) { + const children = group.children; + if (!children.length) return { d: '' }; + const joinX = group.joinX; + const fromY = group.fromY; + const laneY = group.busY; + const childPoints = children + .map(c => ({ x: c.x + nodeW / 2, y: c.y })) + .sort((a, b) => a.x - b.x || a.y - b.y); + if (childPoints.length === 1 && Math.abs(childPoints[0].x - joinX) <= 0.5) { + const p = childPoints[0]; + const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals); + return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) }; + } + if (childPoints.length === 1) { + const p = childPoints[0]; + const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals); + const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); + const parentLane = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges); + const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`; + const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).replace(/^M\s+[-\d.]+\s+[-\d.]+/, ""); + return { d: `${parentLane}${horizontal}${childLane}` }; + } + const minX = Math.min(joinX, ...childPoints.map(p => p.x)); + const maxX = Math.max(joinX, ...childPoints.map(p => p.x)); + let d = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals)); + d += ` M ${minX.toFixed(1)} ${laneY.toFixed(1)} L ${maxX.toFixed(1)} ${laneY.toFixed(1)}`; + for (const p of childPoints) { + const bridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); + const branch = lineageVerticalBridgePath(p.x, laneY, p.y, bridges); + d += ` ${branch}`; + } + return { d }; +} + +function lineageComponentSignature(component, family) { + family = lineageEdgeFamily(family); + const idSet = new Set(component.map(x => x?.id).filter(Boolean)); + return component + .filter(Boolean) + .map(n => { + const parents = lineageMutualParentIds(n, family, idSet).join(","); + const children = lineageMutualChildIds(n, family, idSet).join(","); + return [ + n.id, + n.name || "", + n.type || "", + n.generation || 1, + n.birthTime || 0, + Number(n.scale || 0).toFixed(3), + Number(n.adultScale || 0).toFixed(3), + Number(n.growth || 0).toFixed(2), + n.hasPaired ? 1 : 0, + n.alive === false ? 0 : 1, + n.deathReason || "", + parents, + children, + ].join(":"); + }) + .sort() + .join("|"); +} + +function lineageComponentIdentity(component) { + return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(","); +} + +function lineageFamilyCacheKey(component, family, signature = "") { + const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); + return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`; +} + +function lineageHydrateFamilyHtml(html, index) { + return html + .split("{{LINEAGE_INDEX}}").join(String(index + 1)) + .split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`); +} + +function lineageCaptureArchiveScroll() { + const root = ui.archiveContent; + if (!root) return null; + const familyScroll = {}; + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (key) familyScroll[key] = el.scrollLeft || 0; + } + return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll }; +} + +function lineageRestoreArchiveScroll(state) { + const root = ui.archiveContent; + if (!root || !state) return; + const maxTop = Math.max(0, root.scrollHeight - root.clientHeight); + const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth); + root.scrollTop = Math.min(state.top || 0, maxTop); + root.scrollLeft = Math.min(state.left || 0, maxLeft); + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue; + el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth)); + } +} + +function lineageExpectedChildEdgeCount(component, family) { + family = lineageEdgeFamily(family); + const idSet = new Set(component.map(x => x?.id).filter(Boolean)); + let count = 0; + for (const n of component || []) count += lineageParentIds(n, idSet, family).length; + return count; +} + +function lineageVisibleRelationSignature(family) { + family = lineageEdgeFamily(family); + const relationIndex = lineageRelationIndex(family); + const relatedIds = new Set(); + for (const [childId, parents] of relationIndex.parentsByChild.entries()) { + if (!parents.size) continue; + relatedIds.add(childId); + for (const parentId of parents) relatedIds.add(parentId); + } + return Array.from(relatedIds) + .sort() + .map(id => { + const n = family[id] || {}; + const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(","); + const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(","); + return [ + id, + n.name || "", + n.type || "", + n.generation || 1, + n.birthTime || 0, + n.alive === false ? 0 : 1, + n.deathReason || "", + parents, + children, + ].join(":"); + }) + .join("|"); +} + +function lineageArchiveNearViewport() { + if (!ui.archiveContent?.getBoundingClientRect) return true; + const rect = ui.archiveContent.getBoundingClientRect(); + const h = window.innerHeight || document.documentElement?.clientHeight || 0; + return rect.top < h + 260 && rect.bottom > -260; +} + +function lineageSetArchiveStaleStatus(show) { + if (!ui.archiveContent) return; + const existing = ui.archiveContent.querySelector(".lineage-stale-status"); + if (!show) { + existing?.remove(); + return; + } + if (existing) return; + ui.archiveContent.insertAdjacentHTML("afterbegin", `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002
    `); +} + +function lineageAssignPartnerAttachOffsets(linkGroups) { + const buckets = new Map(); + const pushBucket = (nodeId, side, group) => { + const key = `${nodeId}:${side}`; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(group); + }; + for (const group of linkGroups) { + if ((group.parents || []).length < 2) continue; + const left = group.parents[0]; + const right = group.parents[group.parents.length - 1]; + if (!group.attachOffsets) group.attachOffsets = new Map(); + pushBucket(left.node.id, 'right', group); + pushBucket(right.node.id, 'left', group); + } + for (const [key, groups] of buckets.entries()) { + groups.sort((a, b) => a.joinX - b.joinX || a.childTop - b.childTop); + const n = groups.length; + const step = 9; + groups.forEach((group, i) => { + const offset = n <= 1 ? 0 : (i - (n - 1) / 2) * step; + const [nodeId] = key.split(':'); + group.attachOffsets.set(nodeId, offset); + }); + } +} + +function lineageBuildLinks(nodes, idSet, layout) { + const { positions, nodeW, nodeH } = layout; + const groups = new Map(); + for (const child of nodes) { + const parents = lineageParentIds(child, idSet).filter(id => positions.has(id)); + if (parents.length < 1 || !positions.has(child.id)) continue; + const key = parents.slice().sort().join('+'); + if (!groups.has(key)) groups.set(key, { parentIds: parents.slice().sort(), childIds: [] }); + groups.get(key).childIds.push(child.id); + } + + const linkGroups = []; + for (const group of groups.values()) { + const parents = group.parentIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); + const children = group.childIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); + if (!parents.length || !children.length) continue; + const parentBottom = Math.max(...parents.map(p => p.y + nodeH)); + const childTop = Math.min(...children.map(c => c.y)); + const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; + const directCouple = parents.length >= 2 && parents[0].rowIndex === parents[1].rowIndex && (parents[1].x - (parents[0].x + nodeW) <= 168); + linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() }); + } + lineageAssignPartnerAttachOffsets(linkGroups); + const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0); + + const byGap = new Map(); + for (const group of linkGroups) { + const key = `${group.parentBottom.toFixed(1)}:${group.childTop.toFixed(1)}`; + if (!byGap.has(key)) byGap.set(key, []); + byGap.get(key).push(group); + } + + const partnerLinks = []; + const childLinks = []; + const horizontalSegments = []; + + for (const groupsInGap of byGap.values()) { + groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length); + const top = Math.min(...groupsInGap.map(g => g.parentBottom)) + 44; + const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 42; + const span = Math.max(16, bottom - top); + groupsInGap.forEach((group, i) => { + const laneY = top + span * ((i + 1) / (groupsInGap.length + 1)); + if (group.parents.length >= 2) { + const left = group.parents[0]; + const right = group.parents[group.parents.length - 1]; + // Marriage line stays at the parent-node center. The child trunk drops + // to a visible point below the parents before branching, so the fork is + // never hidden behind a node. + const attachA = 0; + const attachB = 0; + const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; + const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB); + if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX; + const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY; + const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36); + group.fromY = clearY; + group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3)); + if (partner.d) { + partnerLinks.push(partner.d); + horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h')); + } + const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []); + if (trunk) childLinks.push(trunk); + } else { + group.fromY = group.parentBottom; + group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, group.fromY + 30) : Math.min(group.childTop - 24, Math.max(group.fromY + 28, laneY + 18 + i * 3)); + } + }); + } + + for (const groupsInGap of byGap.values()) { + groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length); + for (const group of groupsInGap) { + const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b); + if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) { + horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY }); + } + const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments); + if (child.d) childLinks.push(child.d); + } + } + + return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges }; +} diff --git a/js/ui_family_render.js b/js/ui_family_render.js new file mode 100644 index 0000000..227e3cf --- /dev/null +++ b/js/ui_family_render.js @@ -0,0 +1,405 @@ +"use strict"; + +function lineageSplitByActualEdges(component) { + const family = lineageEdgeFamily(world.family || {}); + const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); + const idSet = new Set(nodes.map(n => n.id)); + const parts = window.TarinaiFamilyGraph.connectedComponents(nodes, { + sort: lineageNodeSort, + edgesOf: (n) => [ + ...lineageMutualParentIds(n, family, idSet), + ...lineageMutualChildIds(n, family, idSet), + ], + filter: part => part.length && part.some(x => x.alive), + }); + return parts; +} + +function lineageExpandComponent(component, family) { + family = lineageEdgeFamily(family); + const ids = new Set(component.filter(Boolean).map(n => n.id)); + let changed = true; + while (changed) { + changed = false; + for (const id of Array.from(ids)) { + const n = family[id]; + if (!n) continue; + for (const p of lineageMutualParentIds(n, family)) { + if (!ids.has(p)) { ids.add(p); changed = true; } + } + for (const c of lineageMutualChildIds(n, family)) { + if (!ids.has(c)) { ids.add(c); changed = true; } + } + } + } + return Array.from(ids).map(id => family[id]).filter(Boolean); +} + +function lineageCleanComponent(component, family) { + family = lineageEdgeFamily(family); + const expanded = lineageExpandComponent(component, family); + const idSet = new Set(expanded.map(n => n.id)); + return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length); +} + +function lineageFastLayoutNeeded(nodes, idSet, family) { + const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0); + const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length)); + return { + edgeCount, + fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34, + }; +} + +function lineageLayoutRowsFast(rows, idSet) { + const NODE_W = 132; + const NODE_H = 84; + const SIBLING_GAP = 22; + const ROW_GAP = 116; + const LEFT_PAD = 82; + const TOP_PAD = 44; + const RIGHT_PAD = 48; + const BOTTOM_PAD = 30; + const positions = new Map(); + let width = 420; + rows.forEach((row, rowIndex) => { + const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); + let x = LEFT_PAD; + for (const n of row) { + positions.set(n.id, { x, y, rowIndex, node: n }); + x += NODE_W + SIBLING_GAP; + } + width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD); + }); + const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; + return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true }; +} + +function lineageBuildLinksFast(nodes, idSet, layout) { + const { positions, nodeW, nodeH } = layout; + const partnerLinks = []; + const childLinks = []; + const seenPartners = new Set(); + let childLinkGroups = 0; + let childLinkEdges = 0; + for (const child of nodes) { + const childPos = positions.get(child.id); + if (!childPos) continue; + const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); + if (!parents.length) continue; + childLinkGroups += 1; + childLinkEdges += parents.length; + const childX = childPos.x + nodeW / 2; + const childY = childPos.y; + const fromY = Math.max(...parents.map(p => p.y + nodeH)); + const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; + if (parents.length >= 2) { + const left = parents[0]; + const right = parents[parents.length - 1]; + const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+"); + if (partnerKey && !seenPartners.has(partnerKey)) { + seenPartners.add(partnerKey); + const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; + partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`); + } + } + const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48)); + childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`); + } + return { partnerLinks, childLinks, childLinkGroups, childLinkEdges }; +} + +function buildLineageTreeLayout(component, family) { + family = lineageEdgeFamily(family); + const cleaned = lineageCleanComponent(component, family); + const nodes = Array.from(new Map(cleaned.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); + const idToNode = new Map(nodes.map(n => [n.id, n])); + const idSet = new Set(idToNode.keys()); + const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b); + const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet); + const perf = lineageFastLayoutNeeded(nodes, idSet, family); + const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet); + const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout); + + const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 })); + const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y)); + return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount }; +} + +function lineageFamilyHtml(component, index, family, signature = "") { + const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); + const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature); + if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index); + const layout = buildLineageTreeLayout(uniqueComponent, family); + const aliveCount = uniqueComponent.filter(n => n.alive).length; + const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family); + const maskId = "{{LINEAGE_MASK_ID}}"; + const nodeMaskRects = layout.nodes + .map(p => ``) + .join(""); + const labels = layout.labels.map(label => `
    \u4e16\u4ee3 ${label.generation}
    `).join(""); + const partnerPaths = layout.partnerLinks.map(d => ``).join(""); + const childPaths = layout.childLinks.map(d => ``).join(""); + const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); + const empty = !layout.childLinks.length ? `
    \u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    ` : ""; + + const html = `
    +

    \u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

    +
    + + ${labels}${nodeHtml}${empty} +
    +
    `; + uiCache.archiveHtmlCache?.set(cacheKey, html); + return lineageHydrateFamilyHtml(html, index); +} + +function renderArchive() { + if (!ui.archiveContent) return; + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (!uiCache.archiveVersion) { + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u63cf\u753b\u3057\u307e\u3059\u3002
    `; + } else if (world.familyTreeDirty) { + lineageSetArchiveStaleStatus(true); + } + return; + } + if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return; + uiCache.deferArchiveUntil = 0; + uiCache.archiveScheduled = false; + const scrollState = lineageCaptureArchiveScroll(); + const familyVersion = world.familyVersion || 0; + const familyDirty = Boolean(world.familyTreeDirty); + if (uiCache.archiveRenderRunning) { + if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; + return; + } + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return; + if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { + uiCache.archivePendingOffscreen = true; + const hiddenInterval = 30000; + const hiddenNow = performance.now(); + if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return; + uiCache.archiveLastHiddenCheckAt = hiddenNow; + } + const now = performance.now(); + const minUpdateGap = 4800; + if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) { + if (!uiCache.archiveRenderTimer) { + uiCache.archiveRenderTimer = setTimeout(() => { + uiCache.archiveRenderTimer = 0; + uiCache.archiveRenderForce = true; + renderArchive(); + uiCache.archiveRenderForce = false; + }, Math.max(80, minUpdateGap - (now - (uiCache.archiveLastRenderedAt || 0)))); + } + return; + } + if (world.familyPrunePending && world.pruneExtinctFamilies) { + world.familyPrunePending = false; + world.pruneExtinctFamilies(); + } + const family = world.family || {}; + const version = String(world.familyVersion || 0); + if (uiCache.archiveVersion === version) { + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; + return; + } + const renderStarted = performance.now(); + lineageSetArchiveStaleStatus(false); + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + uiCache.archiveVersion = version; + uiCache.archiveFamilyVersion = world.familyVersion || 0; + uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0; + uiCache.archiveLastRenderedAt = performance.now(); + uiCache.lastArchiveWindow = ""; + if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map(); + const activeCacheKeys = new Set(); + const token = `${version}:${Math.random().toString(36).slice(2)}`; + uiCache.archiveRenderToken = token; + uiCache.archiveRenderRunning = true; + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026
    `; + lineageRestoreArchiveScroll(scrollState); + const status = ui.archiveContent.querySelector(".lineage-render-status"); + const forest = ui.archiveContent.querySelector(".lineage-forest"); + const renderComponents = (components) => { + if (uiCache.archiveRenderToken !== token || !forest) { + uiCache.archiveRenderRunning = false; + return; + } + const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length); + uiCache.archiveRows = splitComponents; + if (!splitComponents.length) { + ui.archiveContent.innerHTML = `
    \u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    `; + lineageRestoreArchiveScroll(scrollState); + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; + return; + } + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`; + let index = 0; + const pump = () => { + if (uiCache.archiveRenderToken !== token || !forest) { + uiCache.archiveRenderRunning = false; + return; + } + const started = performance.now(); + let nodeBudget = 28; + let renderedThisSlice = 0; + while (index < splitComponents.length) { + const component = splitComponents[index]; + if (renderedThisSlice > 0 && component.length > nodeBudget) break; + const signature = lineageComponentSignature(component, family); + activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature)); + forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature)); + nodeBudget -= Math.max(1, component.length); + index += 1; + renderedThisSlice += 1; + if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break; + } + lineageRestoreArchiveScroll(scrollState); + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`; + if (index < splitComponents.length) { + lineageIdleSchedule(pump, 220); + } else { + if (status) status.remove(); + for (const key of Array.from(uiCache.archiveHtmlCache.keys())) { + if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key); + } + lineageRestoreArchiveScroll(scrollState); + const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion; + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = renderDirtiedAgain; + if (uiCache.archiveDiagnostics) { + uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1; + uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10; + uiCache.archiveDiagnostics.lastFamilies = splitComponents.length; + uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0); + } + if (renderDirtiedAgain) { + uiCache.archivePendingDirtyAfterRun = false; + scheduleArchiveWindowRender(); + } + } + }; + lineageIdleSchedule(pump, 160); + }; + lineageBuildComponentsIdle( + family, + token, + (label, done, total) => { + if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`; + }, + renderComponents + ); +} + +function resetArchiveRenderState() { + if (uiCache.archiveRenderTimer) { + clearTimeout(uiCache.archiveRenderTimer); + uiCache.archiveRenderTimer = 0; + } + uiCache.archiveRenderToken = ""; + uiCache.archiveRenderRunning = false; + uiCache.archiveScheduled = false; + uiCache.archivePendingOffscreen = false; + uiCache.archivePendingWhileRunning = false; + uiCache.archivePendingDirtyAfterRun = false; + uiCache.archiveLastHiddenCheckAt = -Infinity; + uiCache.archiveLastScheduleFamilyVersion = null; + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + uiCache.archiveRelationSignature = ""; + uiCache.archiveRows = []; + uiCache.lastArchiveWindow = ""; + uiCache.archiveHtmlCache?.clear?.(); +} + +function validateFamilyTree() { + const issues = world.validateFamily ? [...world.validateFamily()] : []; + const rendered = new Map(); + document.querySelectorAll("[data-family-tarinai-id]").forEach(node => { + const id = node.getAttribute("data-family-tarinai-id"); + rendered.set(id, (rendered.get(id) || 0) + 1); + }); + for (const [id, count] of rendered.entries()) { + if (count > 1) issues.push({ type: "duplicate-render-node", id, count }); + } + for (const component of uiCache.archiveRows || []) { + for (const n of component || []) { + if (n?.id && !rendered.has(n.id)) issues.push({ type: "missing-render-node", id: n.id }); + } + } + document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => { + const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0); + const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0); + const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0); + const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0); + const pathCount = section.querySelectorAll(".lineage-links .child-link").length; + if (renderedEdges !== expectedEdges) { + issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges }); + } + if (expectedEdges > 0 && pathCount < 1) { + issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount }); + } + if (expectedGroups > 0 && pathCount < expectedGroups) { + issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount }); + } + if (renderedPaths !== pathCount) { + issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount }); + } + }); + return issues; +} + +function renderArchiveWindow() { + renderArchive(); +} + +function scheduleArchiveWindowRender() { + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { + uiCache.archiveScheduled = false; + if (world.familyTreeDirty) lineageSetArchiveStaleStatus(true); + return; + } + const familyDirty = Boolean(world.familyTreeDirty); + if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return; + if (uiCache.archiveRenderRunning) { + if (familyDirty) uiCache.archivePendingDirtyAfterRun = true; + return; + } + if (uiCache.archiveVersion && !lineageArchiveNearViewport()) { + uiCache.archivePendingOffscreen = true; + const now = performance.now(); + if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return; + uiCache.archiveLastHiddenCheckAt = now; + } + if (uiCache.archiveRenderTimer) return; + if (uiCache.archiveScheduled) return; + uiCache.archiveScheduled = true; + const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); + schedule(() => { + renderArchive(); + }, { timeout: 1200 }); +} + +function schedulePendingArchiveRender() { + if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) return; + if (!uiCache.archivePendingOffscreen) return; + if (!lineageArchiveNearViewport()) return; + uiCache.archivePendingOffscreen = false; + scheduleArchiveWindowRender(); +} + +window.validateFamilyTree = validateFamilyTree; +window.familyTreeDiagnostics = () => ({ ...(uiCache.archiveDiagnostics || {}) }); +window.resetArchiveRenderState = resetArchiveRenderState; diff --git a/js/ui_helpers.js b/js/ui_helpers.js new file mode 100644 index 0000000..af3e660 --- /dev/null +++ b/js/ui_helpers.js @@ -0,0 +1,33 @@ +"use strict"; + +(function (global) { + function htmlEscape(value = "") { + return String(value ?? "").replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch] || ch)); + } + + function ensureStyle(id, cssText = "") { + if (!id) return null; + let style = document.getElementById(id); + if (style) return style; + style = document.createElement("style"); + style.id = id; + style.textContent = cssText; + document.head.appendChild(style); + return style; + } + + function createPanel({ id, className = "", html = "", after = null, parent = document.body } = {}) { + if (!id) return null; + let panel = document.getElementById(id); + if (panel) return panel; + panel = document.createElement("section"); + panel.id = id; + if (className) panel.className = className; + panel.innerHTML = html; + if (after?.parentNode) after.parentNode.insertBefore(panel, after.nextSibling); + else parent.appendChild(panel); + return panel; + } + + global.TarinaiUIHelpers = { htmlEscape, ensureStyle, createPanel }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js new file mode 100644 index 0000000..1f150f2 --- /dev/null +++ b/js/ui_layout_dialogs.js @@ -0,0 +1,211 @@ +"use strict"; + +function resizeCanvas() { + const rect = canvas.getBoundingClientRect(); + const perf = world?.performanceLevel ? world.performanceLevel() : 0; + const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2); + const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr)); + const nextW = Math.max(1, Math.floor(rect.width * dpr)); + const nextH = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return; + uiCache.canvasDpr = dpr; + canvas.width = nextW; + canvas.height = nextH; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = "#cfe3d3"; + ctx.fillRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height)); + world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height)); + uiCache.lastChartDraw = ""; + if (typeof render === "function") render(); +} + +function applyFieldLayout(fieldType = world.fieldType || "garden") { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + const root = document.documentElement; + root.style.setProperty("--field-height", field.height); + root.style.setProperty("--field-min-height", field.minHeight || field.height); + root.style.setProperty("--field-mobile-height", field.mobileHeight || field.height); + root.style.setProperty("--field-mobile-min-height", field.mobileMinHeight || field.mobileHeight || "460px"); + world.fieldType = field.id; + const rect = canvas.getBoundingClientRect(); + world.setViewportSize(rect.width, rect.height); + resizeCanvas(); +} + +function openFieldDialog() { + ui.fieldDialog?.classList.remove("hidden"); +} + +function closeFieldDialog() { + ui.fieldDialog?.classList.add("hidden"); +} + +function hydrateLazyImages(root) { + const scope = root || document; + for (const img of scope.querySelectorAll("img[data-src]")) { + if (!img.getAttribute("src")) img.setAttribute("src", img.dataset.src || ""); + } +} + +function applyToolTips() { + const tips = window.TEXT_CATALOG?.toolTips || {}; + if (!ui.toolPalette) return; + let pop = document.getElementById("toolTipPopover"); + if (!pop) { + pop = document.createElement("div"); + pop.id = "toolTipPopover"; + pop.className = "tool-tip-popover hidden"; + document.body.appendChild(pop); + } + const showTip = (btn) => { + const baseTip = btn?.dataset?.tip || ""; + if (!baseTip) return; + const tip = baseTip; + pop.textContent = tip; + pop.classList.remove("hidden"); + const rect = btn.getBoundingClientRect(); + const popW = pop.offsetWidth || 220; + const popH = pop.offsetHeight || 36; + const left = rect.left - popW - 12; + pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`; + pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`; + }; + const hideTip = () => pop.classList.add("hidden"); + for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) { + const tip = tips[btn.dataset.tool]; + if (!tip) continue; + btn.dataset.tip = tip; + if (btn.dataset.tipBound === "1") continue; + btn.dataset.tipBound = "1"; + btn.addEventListener("mouseenter", () => showTip(btn)); + btn.addEventListener("focus", () => showTip(btn)); + btn.addEventListener("mouseleave", hideTip); + btn.addEventListener("blur", hideTip); + } +} + +function renderEcologyCards() { + if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return; + const cards = window.TEXT_CATALOG?.ecologyCards || []; + ui.ecologyGrid.innerHTML = cards.map(card => ` +
    + +
    ${escapeHtml(card.title || "")}

    ${escapeHtml(card.text || "")}

    +
    `).join(""); + ui.ecologyGrid.dataset.ready = "1"; +} + +function openEcologyDialog() { + renderEcologyCards(); + hydrateLazyImages(ui.ecologyDialog); + ui.ecologyDialog?.classList.remove("hidden"); +} + +function closeEcologyDialog() { + ui.ecologyDialog?.classList.add("hidden"); +} + +function openArchiveResetDialog() { + ui.archiveResetDialog?.classList.remove("hidden"); +} + +function closeArchiveResetDialog() { + ui.archiveResetDialog?.classList.add("hidden"); +} + +function resetWithField(fieldType) { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + closeFieldDialog(); + applyFieldLayout(field.id); + world.reset(null, field.id); + uiCache.archiveVersion = ""; + renderLog(world.logs); + renderArchive(); + renderStats(); + render(); +} + +function screenToWorld(e) { + const rect = canvas.getBoundingClientRect(); + const touch = e.touches ? e.touches[0] : e; + const sx = touch.clientX - rect.left; + const sy = touch.clientY - rect.top; + return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; +} + +function resizeFieldFromWheel(e) { + if (!world.setFieldZoom) return; + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; + const factor = e.deltaY > 0 ? 1 / 1.08 : 1.08; + if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const scale = world.viewScale ? world.viewScale() : 1; + world.cameraX = focus.x - (sx - off.x) / scale; + world.cameraY = focus.y - (sy - off.y) / scale; + world.clampCamera?.(); + render(); +} + +function selectTool(tool) { + world.tool = tool; + for (const btn of uiCache.toolButtons) { + btn.classList.toggle("selected", btn.dataset.tool === tool); + } + syncToolSizeBadges(); + canvas.style.cursor = tool === "observe" ? "default" : (tool === "pinch" ? "grab" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair")); +} + +function showToast(text) { + ui.toast.textContent = text; + ui.toast.classList.remove("hidden"); + clearTimeout(showToast._timer); + showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400); +} + +function openCreditsDialog() { + ui.creditsDialog?.classList.remove("hidden"); +} + +function closeCreditsDialog() { + ui.creditsDialog?.classList.add("hidden"); +} + +function syncTopButtons() { + ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62"; + ui.speedBtn.textContent = `\u901f\u5ea6 x${world.speed}`; + syncAudioControls(); + syncArchiveUpdateButton(); +} + +function archiveAutoUpdateEnabled() { + return Boolean(uiCache.archiveUpdateEnabled); +} + +function syncArchiveUpdateButton() { + const btn = ui.archiveUpdateToggleBtn; + if (!btn) return; + const on = archiveAutoUpdateEnabled(); + btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF"; + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", on ? "true" : "false"); +} + +function setArchiveUpdateEnabled(on) { + uiCache.archiveUpdateEnabled = Boolean(on); + syncArchiveUpdateButton(); + if (uiCache.archiveUpdateEnabled) { + uiCache.archiveVersion = ""; + uiCache.archiveFamilyVersion = null; + world.familyTreeDirty = true; + scheduleArchiveWindowRender?.(); + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002"); + } else { + if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; } + uiCache.archiveScheduled = false; + showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002"); + } +} diff --git a/js/ui_log.js b/js/ui_log.js new file mode 100644 index 0000000..8a3211e --- /dev/null +++ b/js/ui_log.js @@ -0,0 +1,181 @@ +"use strict"; + +function loadLogPushSettings() { + try { + const raw = localStorage.getItem("tarinai_log_push_settings_v2"); + if (!raw) return; + const parsed = JSON.parse(raw); + uiCache.logPushEnabled = Boolean(parsed.enabled); + if (Array.isArray(parsed.kinds)) uiCache.logPushKinds = new Set(parsed.kinds); + } catch (_) {} +} + +function saveLogPushSettings() { + try { + localStorage.setItem("tarinai_log_push_settings_v2", JSON.stringify({ + enabled: Boolean(uiCache.logPushEnabled), + kinds: Array.from(uiCache.logPushKinds || []), + })); + } catch (_) {} +} + +function syncLogPushControls() { + if (ui.logPushEnabled) ui.logPushEnabled.checked = Boolean(uiCache.logPushEnabled); + for (const input of ui.logPushControls?.querySelectorAll("[data-push-kind]") || []) { + input.checked = uiCache.logPushKinds?.has(input.dataset.pushKind || "") ?? false; + } +} + +function syncAudioControls() { + if (!window.TarinaiAudio) return; + for (const input of ui.soundCategoryControls?.querySelectorAll("[data-sound-category]") || []) { + const category = input.dataset.soundCategory || ""; + input.checked = audio.categoryOn ? audio.categoryOn(category) : true; + input.disabled = !audio.enabled; + } + if (ui.soundMasterToggle) ui.soundMasterToggle.checked = Boolean(audio.enabled); + if (ui.soundBtn) { + ui.soundBtn.textContent = audio.enabled ? "\u97f3\u8a2d\u5b9a ON" : "\u97f3\u8a2d\u5b9a OFF"; + ui.soundBtn.classList.toggle("active", Boolean(audio.enabled)); + ui.soundBtn.setAttribute("aria-expanded", ui.soundPanel && !ui.soundPanel.classList.contains("hidden") ? "true" : "false"); + } +} + +function closeSoundPanel() { + ui.soundPanel?.classList.add("hidden"); + syncAudioControls(); +} + +function toggleSoundPanel() { + if (audio.enabled) audio.uiClick?.(); + ui.soundPanel?.classList.toggle("hidden"); + syncAudioControls(); +} + +function pushLogNotification(entry = {}) { + if (!uiCache.logPushEnabled || !entry || entry.hiddenFromObservation) return; + const kind = entry.kind || "note"; + if (!uiCache.logPushKinds?.has(kind)) return; + const overlay = ui.logPushOverlay; + audio.notify?.(); + if (!overlay) return; + const card = document.createElement("div"); + card.className = `log-push-toast log-push-${kind}`; + const tag = document.createElement("span"); + tag.className = "log-push-tag"; + tag.textContent = logKindLabel(kind); + const time = document.createElement("span"); + time.className = "log-push-time"; + time.textContent = formatLogTime(entry.time || 0); + const text = document.createElement("div"); + text.className = "log-push-text"; + text.textContent = formatLogDisplayText(entry.text || ""); + card.appendChild(tag); + card.appendChild(time); + card.appendChild(text); + overlay.prepend(card); + while (overlay.children.length > 5) overlay.lastElementChild?.remove(); + window.setTimeout(() => { + card.classList.add("leaving"); + window.setTimeout(() => card.remove(), 220); + }, 5200); +} + +window.pushLogNotification = pushLogNotification; + +function renderLog(logs) { + ui.log.innerHTML = ""; + const frag = document.createDocumentFragment(); + for (const raw of logs.slice(0, 32)) { + const entry = typeof raw === "string" ? { time: world.time, text: raw, kind: "note" } : raw; + const kind = entry.kind || "note"; + const div = document.createElement("div"); + div.className = `log-entry log-${kind}`; + const tag = document.createElement("span"); + tag.className = "log-tag"; + tag.textContent = logKindLabel(kind); + const time = document.createElement("span"); + time.className = "time"; + time.textContent = formatLogTime(entry.time || 0); + div.appendChild(tag); + div.appendChild(time); + div.append(document.createTextNode(formatLogDisplayText(entry.text || ""))); + frag.appendChild(div); + } + ui.log.appendChild(frag); +} + +function formatLogTime(timeValue) { + const t = Math.max(0, Number(timeValue) || 0); + const day = Math.floor(t / CONFIG.dayLength) + 1; + const dayT = ((t % CONFIG.dayLength) + CONFIG.dayLength) % CONFIG.dayLength; + const totalMinutes = Math.floor(dayT / CONFIG.dayLength * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${day}\u65e5 ${hh}\u6642${mm}\u5206`; +} + +function logKindLabel(kind) { + return { + birth: "\u8a95\u751f", death: "\u6b7b\u4ea1", fight: "\u55a7\u5629", food: "\u98df\u4e8b", grass: "\u8349", + weather: "\u74b0\u5883", relation: "\u95a2\u4fc2", accident: "\u4e8b\u6545", observe: "\u89b3\u5bdf", note: "\u8a18\u9332" + }[kind] || "\u8a18\u9332"; +} + +function tarinaiSpritePathForId(id) { + const live = world?.tarinai?.find(t => (t.id === id || t.familyKey === id) && !t.dead); + const sid = live?.spriteId ? live.spriteId() : live?.type; + const fam = world?.family?.[id]; + const fallback = fam?.type; + return (SPRITES.find(s => s.id === (sid || fallback)) || SPRITES[0]).path; +} + +function relationIconHtml(id) { + if (!id) return ""; + return ``; +} + +function relationEntries(t, kind = "friend") { + const rows = []; + const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id)); + for (const [id, rel] of Object.entries(t?.relationships || {})) { + if (!liveIds.has(id)) continue; + const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0); + const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD; + if (score <= threshold) continue; + rows.push({ id, score, name: relationDisplayName(world, id) }); + } + rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "ja")); + return rows; +} + +function relationListHtml(t, kind = "friend") { + const rows = relationEntries(t, kind); + if (!rows.length) return "\u306a\u3057"; + const sign = kind === "friend" ? "+" : ""; + return `
    ${rows.map(r => ` + `).join("")}
    `; +} + +function focusTarinaiById(id) { + const t = world?.tarinai?.find(o => o && !o.dead && (o.id === id || o.familyKey === id)); + if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } + world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); + selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } + uiCache.selectedSnapshot = ""; + renderSelected(); + renderStats(); + render?.(); + showToast(`\u300c${t.name}\u300d\u3078\u79fb\u52d5\u3057\u307e\u3057\u305f\u3002`); + return true; +} diff --git a/js/ui_selected.js b/js/ui_selected.js new file mode 100644 index 0000000..f6c9ed9 --- /dev/null +++ b/js/ui_selected.js @@ -0,0 +1,374 @@ +"use strict"; + +function quoteNameInRecordText(text, name) { + const raw = String(text || ""); + const n = String(name || "").trim(); + if (!n) return raw; + const parts = raw.split(n); + if (parts.length <= 1) return raw; + let out = ""; + for (let i = 0; i < parts.length - 1; i++) { + const before = parts[i].slice(-1); + const after = parts[i + 1].charAt(0); + out += parts[i] + ((before === "\u300c" && after === "\u300d") ? n : `\u300c${n}\u300d`); + } + return out + parts[parts.length - 1]; +} + +function renameSelectedTarinai(rawName) { + const t = world.selected; + if (!t || t.dead || !world.tarinai.includes(t)) return false; + const next = String(rawName || "").replace(/\s+/g, " ").trim().slice(0, 18); + if (!next) { + uiCache.selectedSnapshot = ""; + renderSelected(); + return false; + } + if (next === t.name) return false; + const oldName = t.name; + t.name = next; + if (t.isZunchiSlave) t.formerName = next; + world.recordFamily?.(t); + world.markFamilyTreeDirty?.("rename"); + uiCache.archiveFamilyVersion = null; + uiCache.selectedSnapshot = ""; + renderSelected(); + renderArchive?.(); + render?.(); + showToast(`\u300c${oldName}\u300d\u3092\u300c${next}\u300d\u306b\u6539\u540d\u3057\u307e\u3057\u305f\u3002`); + return true; +} + +function personalityValueText(value) { + const n = clamp(Number(value) || 0, -1, 1); + return `${n >= 0 ? "+" : ""}${n.toFixed(2)}`; +} + +function personalityRowsHtml(personality = {}) { + const p = normalizePersonality(personality); + return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); +} + +function personalityShortLabel(key) { + return ({ + aggression: "\u653b\u6483", + openness: "\u958b\u653e", + sociability: "\u793e\u4ea4", + neuroticism: "\u795e\u7d4c", + })[key] || (PERSONALITY_LABELS[key]?.label || key); +} + + +function personalityDisplayCauseText(cause = "") { + return String(cause || "").replace(/(?:\u306e)?\u5f71\u97ff\u3067$/g, "").trim(); +} + +function geneticPercentText(t, key = "") { + const ratio = t?.geneticStatRatio ? t.geneticStatRatio(key) : 1; + const pct = Math.round((ratio - 1) * 100); + return `${pct >= 0 ? "+" : ""}${pct}%`; +} + +function geneticSizeText(t) { + const adult = Number.isFinite(t?.adultScale) ? t.adultScale : (t?.scale || 0.28); + const now = Number.isFinite(t?.scale) ? t.scale : adult; + const growth = Math.round((t?.growth ?? 1) * 100); + return `${geneticPercentText(t, "size")} / ${now.toFixed(2)}\u2192${adult.toFixed(2)} (${growth}%)`; +} + +function personalityDailyChangeHtml(t) { + const state = typeof personalityDailyState === "function" ? personalityDailyState(t) : (t?.personalityDaily || { day: world?.day || 1, byKey: {}, byCause: {} }); + const dayLabel = `D${state.day || world?.day || 1}`; + const causeMap = new Map(); + const addCauseChange = (cause, key, up, down) => { + const cleanCause = personalityDisplayCauseText(cause || "") || "\u8981\u56e0\u4e0d\u660e"; + if (!causeMap.has(cleanCause)) causeMap.set(cleanCause, new Map()); + const byKey = causeMap.get(cleanCause); + const current = byKey.get(key) || { up: 0, down: 0 }; + current.up += Math.max(0, Number(up) || 0); + current.down += Math.max(0, Number(down) || 0); + byKey.set(key, current); + }; + for (const [cause, entry] of Object.entries(state.byCause || {})) { + for (const key of PERSONALITY_KEYS) { + const v = entry?.byKey?.[key] || {}; + const up = Math.max(0, Number(v.up) || 0); + const down = Math.max(0, Number(v.down) || 0); + if (up > 0.0001 || down > 0.0001) addCauseChange(cause, key, up, down); + } + } + if (!causeMap.size) { + for (const key of PERSONALITY_KEYS) { + const entry = state.byKey?.[key] || {}; + const up = Math.max(0, Number(entry.up) || 0); + const down = Math.max(0, Number(entry.down) || 0); + if (up <= 0.0001 && down <= 0.0001) continue; + const causes = Array.isArray(entry.causes) ? [...new Set(entry.causes.map(c => personalityDisplayCauseText(c)).filter(Boolean))].slice(0, 4) : []; + for (const cause of causes.length ? causes : ["\u8981\u56e0\u4e0d\u660e"]) addCauseChange(cause, key, up, down); + } + } + const rows = []; + for (const [cause, byKey] of causeMap.entries()) { + const parts = []; + for (const key of PERSONALITY_KEYS) { + const entry = byKey.get(key); + if (!entry) continue; + const up = Math.max(0, Number(entry.up) || 0); + const down = Math.max(0, Number(entry.down) || 0); + if (up > 0.0001) parts.push(`${personalityShortLabel(key)} +${up.toFixed(3)}`); + if (down > 0.0001) parts.push(`${personalityShortLabel(key)} -${down.toFixed(3)}`); + } + if (parts.length) rows.push(`
  • ${escapeHtml(cause)}${escapeHtml(parts.join(" / "))}${escapeHtml(dayLabel)}
  • `); + } + return rows.join("") || `
  • \u8981\u56e0\u5909\u5316\u306a\u3057${escapeHtml(dayLabel)}
  • `; +} + +function personalityDailySnapshot(t) { + const state = typeof personalityDailyState === "function" ? personalityDailyState(t) : (t?.personalityDaily || {}); + const keyPart = PERSONALITY_KEYS.map(key => { + const entry = state.byKey?.[key] || {}; + const causes = Array.isArray(entry.causes) ? entry.causes.join("/") : ""; + return `${key}:${Number(entry.up || 0).toFixed(4)}:${Number(entry.down || 0).toFixed(4)}:${causes}`; + }).join(","); + const causePart = Object.entries(state.byCause || {}).map(([cause, entry]) => { + const values = PERSONALITY_KEYS.map(key => { + const v = entry?.byKey?.[key] || {}; + return `${key}:${Number(v.up || 0).toFixed(4)}:${Number(v.down || 0).toFixed(4)}`; + }).join(";"); + return `${cause}=${values}`; + }).join("|"); + return `${keyPart}#${causePart}`; +} + +function recentChangeCausesHtml(t) { + const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : []; + if (!rows.length) return `
  • -\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057
  • `; + return rows.map(entry => { + const timeText = world.clockStringFromTime ? world.clockStringFromTime(entry.time || 0) : formatRecordTime(entry.time || 0); + const reason = String(entry.reason || "\u5909\u5316"); + const target = String(entry.target || "\u72b6\u614b"); + const value = entry.value === "" || entry.value == null ? "" : ` ${entry.value}`; + return `
  • ${escapeHtml(timeText)}${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}
  • `; + }).join(""); +} + +function selectedDataCategoryHtml(id, label, bodyHtml) { + if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); + const collapsed = uiCache.selectedCollapsedCategories.has(id); + return `
    + +
    ${bodyHtml}
    +
    `; +} + +function selectedInfoRowHtml(label, valueHtml, metric = "") { + const metricAttr = metric ? ` data-metric="${escapeHtml(metric)}"` : ""; + return `
    ${escapeHtml(label)}${valueHtml}
    `; +} + +function selectedNeedRowsHtml(t) { + const system = window.TarinaiNeedSystem; + const keys = system?.NEED_KEYS || ["food", "sleep", "health", "safety", "social", "fulfill"]; + const labels = system?.NEED_LABELS || { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", + }; + const active = t?.intent?.need || t?.need || ""; + return keys.map(key => { + const value = system?.getNeedDisplayValue ? system.getNeedDisplayValue(t?.needs?.[key] || 0) : Math.round((t?.needs?.[key] || 0) / 10); + const mark = key === active ? ` \u25b2` : ""; + return `
    ${escapeHtml(labels[key] || key)}${escapeHtml(value)}${mark}
    `; + }).join(""); +} + +function renderSelected() { + const t = world.selected; + if (!t || t.dead || !world.tarinai.includes(t)) { + ui.selectedCard?.classList.add("hidden"); + if (uiCache.selectedEmpty) return; + uiCache.selectedSnapshot = ""; + uiCache.selectedEmpty = true; + ui.selectedInfo.className = "selected-info empty"; + ui.selectedInfo.textContent = "\u672a\u9078\u629e"; + return; + } + + ui.selectedCard?.classList.remove("hidden"); + uiCache.selectedEmpty = false; + const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id; + if (activeNameInput) return; + const rel = t.relationSummary ? t.relationSummary() : {}; + const generationDisplay = t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : ""); + ensurePersonality(t); + const personalityTags = t.personalityTags ? t.personalityTags() : getPersonalityTraitTags(t); + const personalitySnapshot = [ + ...PERSONALITY_KEYS.map(key => personalityValueText(t.birthPersonality?.[key])), + ...PERSONALITY_KEYS.map(key => personalityValueText(t.currentPersonality?.[key])), + personalityTags.join(",") + ].join(","); + const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : []; + const snapshot = [ + t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), + JSON.stringify(t.needs || {}), JSON.stringify(t.intent || {}), + fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), + fmt(t.energy), fmt(t.stress), fmt(t.mood), + t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), + relationEntries(t, "friend").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), relationEntries(t, "enemy").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), fightWinRateLabel(t), + t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0), + t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0), + t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0, + JSON.stringify(t.needs || {}), t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", + personalitySnapshot, personalityDailySnapshot(t), + ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), + itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), + (t.recentChangeCauses || []).slice(0, 8).map(c => `${Math.round(c.time || 0)}:${c.reason || ""}:${c.target || ""}:${c.value ?? ""}`).join("~"), + (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") + ].join("|"); + if (uiCache.selectedSnapshot === snapshot) return; + uiCache.selectedSnapshot = snapshot; + + ui.selectedInfo.className = "selected-info"; + const effectBadges = []; + if (t.isZunchiSlave) effectBadges.push(`\u305a\u3093\u3061\u3069\u308c\u3044`); + if (t.favorite) effectBadges.push(`\u2605`); + if (t.zunchiDisease) effectBadges.push(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); + if (t.sleepDisease) effectBadges.push(`\u306d\u3080\u308a\u75c5`); + if (t.explosionDisease) effectBadges.push(`\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}`); + if (t.fightDisease) effectBadges.push(`\u304d\u305a\u3064\u304d\u75c5`); + if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); + if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); + const personalityTagHtml = personalityTags.length + ? personalityTags.map(tag => `${escapeHtml(tag)}`).join("") + : `\u4e2d\u7acb`; + const recentChangeHtml = personalityDailyChangeHtml(t); + const personalityHtml = ` +
    \u6027\u683c\u30bf\u30b0${personalityTagHtml}
    +
    \u8a95\u751f\u6642\u6027\u683c${personalityRowsHtml(t.birthPersonality)}
    +
    \u73fe\u5728\u6027\u683c${personalityRowsHtml(t.currentPersonality)}
    +
    \u4eca\u65e5\u306e\u5909\u5316
      ${recentChangeHtml}
    + `; + const recordHtml = (t.records || []).slice(0, 8).map(r => { + const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); + const recordText = quoteNameInRecordText(r.text || "", t.name); + return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; + }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; + const recentCauseHtml = recentChangeCausesHtml(t); + const basicHtml = ` +
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    +
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    +
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    +
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    +
    \u6700\u5927\u5bff\u547d${geneticPercentText(t, "life")}
    +
    \u653b\u6483\u529b${geneticPercentText(t, "attack")}
    +
    \u79fb\u52d5\u901f\u5ea6${geneticPercentText(t, "speed")}
    +
    \u30b5\u30a4\u30ba${geneticSizeText(t)}
    + `; + const needsHtml = ` + ${selectedNeedRowsHtml(t)} +
    \u3044\u307e${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}
    +
    \u7406\u7531${escapeHtml(t.intent?.reasonText || reasonLabel(t))}
    + `; + const itemEffectHtml = itemEffectSummary.length + ? itemEffectSummary.map(e => `
    ${escapeHtml(e.label)}${escapeHtml(e.detail)}
    `).join("") + : `
    \u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u306a\u3057
    `; + const relationHtml = ` +
    \u53cb\u9054${relationListHtml(t, "friend")}
    +
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    +
    \u3051\u3093\u304b\u52dd\u7387${escapeHtml(fightWinRateLabel(t))}
    + `; + const familyHtml = ` +
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    +
    \u5b50${t.children?.length || 0}
    + `; + ui.selectedInfo.innerHTML = ` +
    + +
    + ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} +
    + ${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)} + ${selectedDataCategoryHtml("needs", "\u30cb\u30fc\u30ba", needsHtml)} + ${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)} + ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} + ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} + ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} + ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

    \u6700\u8fd1\u306e\u5909\u5316\u539f\u56e0

      ${recentCauseHtml}

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    `)} +
    +
    + `; +} + +function formatRecordTime(time) { + const totalMinutes = Math.floor(((time || 0) % (CONFIG.dayLength || 120)) / (CONFIG.dayLength || 120) * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${hh}:${mm}`; +} + +function stateLabel(s) { + return window.TEXT_CATALOG?.stateLabel?.(s) || s || ""; +} + +function reasonLabel(t) { + return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし"; +} + +function targetLabel(target) { + return window.TEXT_CATALOG?.targetLabel?.(target) || "なし"; +} + +function fightWinRateLabel(t) { + const won = Number(t.totalFightWins || 0); + const lost = Number(t.totalFightLosses || 0); + const total = won + lost; + if (!total) return "\u306a\u3057"; + return `${Math.round((won / total) * 100)}% (${won}\u52dd${lost}\u6557)`; +} + +function setTextIfChanged(el, key, value) { + if (!el) return; + const next = String(value); + if (uiCache.stats[key] === next) return; + uiCache.stats[key] = next; + el.textContent = next; +} + +function setValueIfChanged(el, key, value) { + if (!el) return; + if (uiCache.stats[key] === value) return; + uiCache.stats[key] = value; + el.value = value; +} + +function escapeHtml(value) { + const helper = window.TarinaiUIHelpers?.htmlEscape; + if (typeof helper === "function" && helper !== escapeHtml) return helper(value); + return String(value ?? "").replace(/[&<>"']/g, c => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[c] || c)); +} + +function escapeRegExp(value) { + return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function formatLogDisplayText(text) { + let out = String(text ?? ""); + const names = new Set(); + for (const t of world?.tarinai || []) if (t?.name) names.add(t.name); + for (const n of Object.values(world?.family || {})) if (n?.name) names.add(n.name); + const sorted = [...names].filter(Boolean).sort((a, b) => b.length - a.length); + for (const name of sorted) { + const re = new RegExp(`(^|[^\u300c])(${escapeRegExp(name)})(?=[\u3068\u306f\u304c\u3092\u306b\u306e\u3082\u3078\u304b\u3089\u3067\u3001\u3002]|$)`, "g"); + out = out.replace(re, (m, prefix, found) => `${prefix}\u300c${found}\u300d`); + } + return out; +} diff --git a/js/ui_tools.js b/js/ui_tools.js new file mode 100644 index 0000000..a320d1e --- /dev/null +++ b/js/ui_tools.js @@ -0,0 +1,77 @@ +"use strict"; + +const SCALABLE_TOOLS = new Set(scalableToolIds()); + +const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; + +const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" }; + +function renderToolPalette() { + if (!ui?.toolPalette || typeof toolCategories !== "function") return; + ui.toolPalette.innerHTML = ""; + const frag = document.createDocumentFragment(); + for (const category of toolCategories()) { + const section = document.createElement("section"); + section.className = `tool-category ${category.themeClass || ""}`.trim(); + section.dataset.toolCategory = category.id; + const toggle = document.createElement("button"); + toggle.className = "tool-category-toggle"; + toggle.type = "button"; + toggle.setAttribute("aria-expanded", "true"); + toggle.textContent = category.label || category.id; + const body = document.createElement("div"); + body.className = "tool-grid tool-category-body"; + for (const toolId of category.toolIds || []) { + const def = toolDefinition(toolId); + if (!def || def.simulationOnly) continue; + const btn = document.createElement("button"); + btn.className = "tool"; + btn.dataset.tool = toolId; + btn.textContent = def.label || toolId; + if (toolId === world?.tool) btn.classList.add("selected"); + body.appendChild(btn); + } + section.append(toggle, body); + frag.appendChild(section); + } + ui.toolPalette.appendChild(frag); +} + +function toolSizeFor(tool) { + if (!SCALABLE_TOOLS.has(tool)) return ""; + return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(world, tool) : ((world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium"); +} + +function cycleToolSize(tool) { + if (!SCALABLE_TOOLS.has(tool)) return false; + if (!world.toolSizes) world.toolSizes = {}; + const current = toolSizeFor(tool); + const idx = SIZE_ORDER.indexOf(current); + const next = SIZE_ORDER[(idx + 1 + SIZE_ORDER.length) % SIZE_ORDER.length]; + world.toolSizes[tool] = next; + world.toolSize = next; + syncToolSizeBadges(); + showToast(`\u30b5\u30a4\u30ba: ${SIZE_LABELS[next] || next}`); + return true; +} + +function syncToolSizeBadges() { + for (const btn of uiCache.toolButtons || []) { + const tool = btn.dataset.tool || ""; + const old = btn.querySelector(".tool-size-badge"); + if (!SCALABLE_TOOLS.has(tool)) { + btn.removeAttribute("data-size"); + if (old) old.remove(); + continue; + } + const size = toolSizeFor(tool); + btn.dataset.size = size; + let badge = old; + if (!badge) { + badge = document.createElement("b"); + badge.className = "tool-size-badge"; + btn.appendChild(badge); + } + badge.textContent = SIZE_LABELS[size] || size; + } +} diff --git a/js/version.js b/js/version.js new file mode 100644 index 0000000..73fbb90 --- /dev/null +++ b/js/version.js @@ -0,0 +1,24 @@ +"use strict"; + +(function () { + const APP_VERSION = "15.35.0"; + const APP_BUILD = "hash-v10-shorter-current-only"; + const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; + const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; + + const config = Object.freeze({ + version: APP_VERSION, + build: APP_BUILD, + cacheName: APP_CACHE_NAME, + staticVersionParam: STATIC_VERSION_PARAM, + withVersion(path) { + const p = String(path || ""); + if (!p) return p; + return p.includes("?") ? `${p}&${STATIC_VERSION_PARAM}` : `${p}?${STATIC_VERSION_PARAM}`; + }, + }); + + window.TARINAI_VERSION = APP_VERSION; + window.TARINAI_APP = config; +})(); + diff --git a/js/weather_system.js b/js/weather_system.js new file mode 100644 index 0000000..ea67d8c --- /dev/null +++ b/js/weather_system.js @@ -0,0 +1,49 @@ +"use strict"; + +(function (global) { + const WEATHER_STATES = Object.freeze(["sunny", "cloudy", "light_rain"]); + + const WeatherSystem = Object.freeze({ + states: WEATHER_STATES, + + chooseNext(current = "sunny") { + const choices = current === "light_rain" + ? ["sunny", "cloudy"] + : ["sunny", "cloudy", "light_rain"]; + return pick(choices); + }, + + resetTimer() { + return rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + }, + + shouldDropRainWater(worldRef, dt = 0) { + return Boolean( + worldRef?.weather === "light_rain" && + (worldRef.itemCounts?.water || 0) < 24 && + Math.random() < Math.max(0, Number(dt) || 0) * 0.12 + ); + }, + + createRainWaterItem(worldRef) { + const drop = new Item("water", rand(58, worldRef.w - 58), rand(58, worldRef.h - 58)); + drop.amount = rand(24, 58); + return drop; + }, + + applyNextWeather(worldRef) { + if (!worldRef) return null; + const previous = worldRef.weather || "sunny"; + const next = this.chooseNext(previous); + worldRef.weather = next; + worldRef.nextWeatherChange = this.resetTimer(); + if (next !== previous) { + worldRef.emit?.("weather:changed", { previous, next }); + worldRef.log?.(`\u5929\u6c17: ${weatherLabel(next)}`); + } + return next; + }, + }); + + global.TarinaiWeatherSystem = WeatherSystem; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world.js b/js/world.js index ec04b10..a37b0be 100644 --- a/js/world.js +++ b/js/world.js @@ -1,7 +1,6 @@ "use strict"; -class World { - constructor() { +class World { constructor() { this.w = 1000; this.h = 720; this.viewportW = 1000; @@ -14,15 +13,18 @@ class World { this.toolSize = "medium"; this.toolSizes = {}; this.tarinai = []; + this.frozenTarinai = []; this.items = []; this.ants = []; this.effects = []; this.logs = []; + this.events = window.TarinaiEvents || null; this.eventCounters = {}; this.countsTimer = 0; this.compactTimer = 0; this.drawSortTimer = 0; this.drawListDirty = true; + this.antUpdatePhase = 0; this.selected = null; this.deadCount = 0; this.liveIdNext = 1; @@ -44,3030 +46,37 @@ class World { this.fieldZoom = 1; this.cameraX = 0; this.cameraY = 0; + this.shakeTimer = 0; + this.shakeDuration = 0; + this.shakeStrength = 0; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); this.spatial = new SpatialGrid(96); this.spatialItemScratch = []; this.spatialTarinaiScratch = []; + this.spatialAntScratch = []; + this.spatialObstacleScratch = []; + this.spatialFoodScratch = []; + this.spatialHazardScratch = []; + this.spatialVersion = 0; + this.spatialDirty = true; + this.terrainDirty = true; + this.terrainVersion = 1; + this.terrainDirtyReason = "init"; + this.terrainLastDirtyAt = 0; + this.lastTerrainSignature = ""; this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; - this.countsTimer = 0; - this.compactTimer = 0; - this.drawSortTimer = 0; - this.drawListDirty = true; + this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } }; + this.lastColonyMoodDay = 0; + this.foodSpoilagePenalty = 0; + this.foodSpoilageEvents = 0; this.updateItemCounts(); this.updateEffectCounts(); - this.rebuildSpatial(); + this.markTerrainDirty?.("reset"); + this.rebuildSpatial(true); } - - dayProgress() { - return (this.time % CONFIG.dayLength) / CONFIG.dayLength; - } - - lightLevel() { - return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2; - } - - phaseName() { - const p = this.dayProgress(); - if (p < 0.20) return "\u671d"; - if (p < 0.48) return "\u663c"; - if (p < 0.68) return "\u5915\u65b9"; - return "\u591c"; - } - - clockString() { - const totalMinutes = Math.floor(this.dayProgress() * 24 * 60); - const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); - const mm = String(totalMinutes % 60).padStart(2, "0"); - return `${hh}:${mm}`; - } - - fieldDefinition() { - return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden; - } - - fieldWorldScale() { - const field = this.fieldDefinition(); - // Field type defines the logical simulation area. Wheel zoom must not - // rewrite this size; it is a camera/display transform only. - return Math.max(0.22, field.worldScale || 1); - } - - grassLimit() { - const field = this.fieldDefinition(); - if (Number.isFinite(field?.grassLimit)) return Math.max(0, Math.round(field.grassLimit)); - const base = CONFIG.grassLimit ?? 96; - const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); - const ratio = this.fieldWorldScale() / mediumScale; - // \u4e2d\u30b5\u30a4\u30ba\u3092 CONFIG.grassLimit \u306e\u57fa\u6e96\u306b\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8ad6\u7406\u30b5\u30a4\u30ba\u306b\u5fdc\u3058\u3066\u5897\u6e1b\u3059\u308b\u3002 - return Math.max(12, Math.round(base * ratio)); - } - - fenceLimit() { - const base = CONFIG.fenceLimit ?? 72; - const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); - const ratio = Math.max(0.24, this.fieldWorldScale() / mediumScale); - return clamp(Math.round(base * Math.sqrt(ratio)), 28, 140); - } - - isFenceType(type = "") { - return type === "fence_v" || type === "fence_h"; - } - - liveFenceCount() { - let count = 0; - for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1; - return count; - } - - baseZoomMin() { - return CONFIG.fieldZoomMin || 0.42; - } - - baseZoomMax() { - return CONFIG.fieldZoomMax || 2.45; - } - - fitFieldZoom() { - const vw = Math.max(1, this.viewportW || this.w || 1000); - const vh = Math.max(1, this.viewportH || this.h || 720); - const ww = Math.max(1, this.w || vw); - const wh = Math.max(1, this.h || vh); - // The minimum visible zoom is field-size dependent. Small fields must not - // be allowed to shrink below the viewport, otherwise empty margins appear. - return Math.max(vw / ww, vh / wh); - } - - zoomLimits() { - const min = Math.max(this.baseZoomMin(), this.fitFieldZoom()); - // Keep the old upper bound for normal/large fields, but give small fields - // proportional zoom-in headroom because their no-margin minimum is higher. - const max = Math.max(this.baseZoomMax(), min * this.baseZoomMax()); - return { min, max }; - } - - normalizedFieldZoom(value = this.fieldZoom) { - const limits = this.zoomLimits(); - return clamp(Number(value) || limits.min, limits.min, limits.max); - } - - viewScale() { - return this.normalizedFieldZoom(this.fieldZoom); - } - - visibleWorldW() { - return (this.viewportW || this.w) / this.viewScale(); - } - - visibleWorldH() { - return (this.viewportH || this.h) / this.viewScale(); - } - - screenSizeToWorld(size) { - // Kept as a compatibility shim for older hit-test call sites. The values - // currently passed here are already logical world units, not CSS pixels. - return size; - } - - fieldScreenOffset() { - const scale = this.viewScale(); - return { - x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2), - y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2), - }; - } - - maxCameraX() { - return Math.max(0, this.w - this.visibleWorldW()); - } - - maxCameraY() { - return Math.max(0, this.h - this.visibleWorldH()); - } - - clampCamera() { - this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX()); - this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY()); - } - - panCamera(dx, dy) { - const scale = this.viewScale(); - this.cameraX = (this.cameraX || 0) + dx / scale; - this.cameraY = (this.cameraY || 0) + dy / scale; - this.clampCamera(); - } - - screenToWorld(x, y) { - const scale = this.viewScale(); - const off = this.fieldScreenOffset(); - const rawX = (x - off.x) / scale + (this.cameraX || 0); - const rawY = (y - off.y) / scale + (this.cameraY || 0); - return { - x: clamp(rawX, 0, this.w), - y: clamp(rawY, 0, this.h), - inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h, - }; - } - - worldToScreen(x, y) { - const scale = this.viewScale(); - const off = this.fieldScreenOffset(); - return { - x: (x - (this.cameraX || 0)) * scale + off.x, - y: (y - (this.cameraY || 0)) * scale + off.y, - }; - } - - setViewportSize(width, height, opts = {}) { - const oldW = this.w || width; - const oldH = this.h || height; - this.viewportW = Math.max(1, width || this.viewportW || 1000); - this.viewportH = Math.max(1, height || this.viewportH || 720); - const requestedScale = this.fieldWorldScale(); - const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH); - this.w = this.viewportW * scale; - this.h = this.viewportH * scale; - this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom); - this.clampCamera(); - if (opts.scaleContents && oldW > 0 && oldH > 0) { - const sx = this.w / oldW; - const sy = this.h / oldH; - const scalePoint = (p) => { - if (!p) return; - if (Number.isFinite(p.x)) p.x *= sx; - if (Number.isFinite(p.y)) p.y *= sy; - }; - for (const t of this.tarinai || []) scalePoint(t); - for (const it of this.items || []) scalePoint(it); - for (const ef of this.effects || []) scalePoint(ef); - scalePoint(this.pointer); - this.cameraX *= sx; - this.cameraY *= sy; - this.clampCamera(); - this.rebuildSpatial(); - this.drawListDirty = true; - } - } - - setFieldZoom(nextZoom) { - const current = this.normalizedFieldZoom(this.fieldZoom); - const next = this.normalizedFieldZoom(nextZoom); - if (Math.abs(next - current) < 0.001) { - this.fieldZoom = current; - this.clampCamera(); - return false; - } - this.fieldZoom = next; - this.clampCamera(); - this.drawListDirty = true; - return true; - } - - edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) { - const w = Math.max(this.w || 1000, margin * 2 + 1); - const h = Math.max(this.h || 720, margin * 2 + 1); - let side; - if (Number.isFinite(targetX) && Number.isFinite(targetY)) { - const distances = [targetY, w - targetX, h - targetY, targetX]; - side = distances.indexOf(Math.min(...distances)); - } else { - side = Math.floor(Math.random() * 4); - } - const p = outside ? -(margin + 24) : margin; - if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: p, vx: rand(-10, 10), vy: rand(28, 48), angle: Math.PI / 2 }; - if (side === 1) return { x: outside ? w - p : w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-48, -28), vy: rand(-10, 10), angle: Math.PI }; - if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: outside ? h - p : h - margin, vx: rand(-10, 10), vy: rand(-48, -28), angle: -Math.PI / 2 }; - return { x: p, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(28, 48), vy: rand(-10, 10), angle: 0 }; - } - - toolSizeFor(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium"; - return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; - } - - toolSizeScale(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1; - return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; - } - - applyToolSize(item) { - if (!item) return item; - const scale = this.toolSizeScale(item.type); - const size = this.toolSizeFor(item.type); - item.toolSize = size; - this.toolSize = size; - item.r *= scale; - if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { - const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5; - item.foodServingScale = scale; - item.foodServingsMax = servings; - item.foodServingsRemaining = servings; - item.amount = servings; - } else if (["grass", "zunchi"].includes(item.type)) { - item.amount *= scale * scale; - item.foodServingScale = scale; - } - if (item.type === "firecracker") item.blastScale = scale; - return item; - } - - reset(seedPopulation = null, fieldType = this.fieldType || "garden") { - const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; - this.fieldType = field.id; - this.fieldZoom = 1; - this.setViewportSize(this.viewportW, this.viewportH); - this.time = 0; - this.day = 1; - this.paused = false; - this.speed = 1; - this.toolSize = this.toolSize || "medium"; - this.toolSizes = this.toolSizes || {}; - this.tarinai = []; - this.items = []; - this.ants = []; - this.effects = []; - this.logs = []; - this.eventCounters = {}; - this.countsTimer = 0; - this.compactTimer = 0; - this.drawSortTimer = 0; - this.drawListDirty = true; - this.selected = null; - this.deadCount = 0; - this.liveIdNext = 1; - this.liveIdFree = []; - this.liveIdSerial = 0; - this.liveTarinai = new Map(); - this.lastBirthAt = -999; - this.maxGeneration = 1; - this.family = {}; - this.familyVersion = 0; - this.relationNotices = {}; - this.resolvedFightIds = {}; - this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 }; - this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2); - this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2); - this.clampCamera(); - this.lastPhase = this.phaseName(); - this.weather = "sunny"; - this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation; - const grassCount = field.grass ?? 18; - const types = spawnableSprites().map(s => s.id); - for (let i = 0; i < seedPopulation; i++) { - this.addTarinai({ - type: types[i % types.length], - x: rand(80, this.w - 80), - y: rand(80, this.h - 80), - generation: 1, - }); - } - for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); - this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); - this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22)); - this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78)); - this.updateItemCounts(); - this.updateEffectCounts(); - this.rebuildSpatial(); - this.log("\u305f\u308a\u306a\u3044\u3092\u89b3\u5bdf\u7bb1\u306b\u914d\u7f6e\u3057\u307e\u3057\u305f\u3002"); - showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002"); - syncTopButtons(); - } - - - tarinaiFamilyKey(t) { - return t?.familyKey || t?.archiveKey || t?.id || ""; - } - - assignTarinaiLiveId(t) { - if (!t) return ""; - if (!this.liveTarinai) this.liveTarinai = new Map(); - // Live IDs are never recycled. Relationships and transient targets are keyed by - // live ID, so reuse can make dead friends/enemies leak onto new individuals. - this.liveIdFree = []; - if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1; - if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0; - const id = `t${this.liveIdNext++}`; - t.id = id; - t.liveToken = ++this.liveIdSerial; - this.liveTarinai.set(id, { token: t.liveToken, target: t }); - return id; - } - - releaseTarinaiLiveId(t) { - if (!t?.id || !this.liveTarinai) return false; - const current = this.liveTarinai.get(t.id); - if (!current || current.target !== t || current.token !== t.liveToken) return false; - const id = t.id; - this.liveTarinai.delete(id); - // Do not recycle live IDs. Relationships are keyed by live ID; recycling can - // make a dead friend's/enemy's relationship appear on a newly spawned individual. - t.releasedLiveId = id; - t.id = ""; - t.liveToken = 0; - return true; - } - - liveTarinaiById(id, token = null) { - if (!id || !this.liveTarinai) return null; - const entry = this.liveTarinai.get(id); - if (!entry || (token !== null && entry.token !== token)) return null; - const t = entry.target; - return t && !t.dead ? t : null; - } - - recordFamily(t) { - if (!this.family) this.family = {}; - const familyKey = this.tarinaiFamilyKey(t); - if (!familyKey) return; - const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); - const sameList = (a, b) => { - const aa = uniqueIds(a); - const bb = uniqueIds(b); - return aa.length === bb.length && aa.every((id, i) => id === bb[i]); - }; - let changed = false; - const nextParents = uniqueIds(t.parents || []); - const nextChildren = uniqueIds(t.children || []); - const nextPersonalityTags = getPersonalityTraitTags(t); - if (!this.family[familyKey]) { - this.family[familyKey] = { - id: familyKey, - name: t.name, - type: t.type, - personalityTags: nextPersonalityTags, - generation: t.generation, - hasPaired: !!t.hasPaired, - parents: nextParents, - parentNames: [...(t.parentNames || [])], - children: nextChildren, - birthTime: t.birthTime, - alive: !t.dead, - deathReason: t.deathReason || "", - }; - changed = true; - } else { - const entry = this.family[familyKey]; - const nextAlive = !t.dead; - if (entry.name !== t.name) changed = true; - if (entry.type !== t.type) changed = true; - if (!sameList(entry.personalityTags, nextPersonalityTags)) changed = true; - if (entry.hasPaired !== !!t.hasPaired) changed = true; - if (entry.generation !== t.generation) changed = true; - if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; - if (entry.alive !== nextAlive) changed = true; - if (!sameList(entry.parents, nextParents)) changed = true; - if (!sameList(entry.children, nextChildren)) changed = true; - entry.name = t.name; - entry.type = t.type; - delete entry.personality; - entry.personalityTags = nextPersonalityTags; - entry.alive = nextAlive; - entry.generation = t.generation; - entry.hasPaired = !!t.hasPaired; - entry.deathReason = t.deathReason || entry.deathReason || ""; - entry.parents = nextParents; - entry.parentNames = [...(t.parentNames || entry.parentNames || [])]; - entry.children = nextChildren; - } - if (changed) { - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("family-record"); - } - } - - markFamilyTreeDirty(reason = "family") { - this.familyTreeDirty = true; - this.familyTreeDirtyReason = reason; - this.familyTreeDirtyAt = this.time || 0; - } - - normalizeFamily() { - if (!this.family) this.family = {}; - if (this.familyCleanVersion === (this.familyVersion || 0)) return false; - const family = this.family; - const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); - let changed = false; - const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); - const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); - for (const t of this.tarinai || []) { - const familyKey = this.tarinaiFamilyKey(t); - if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue; - const before = this.familyVersion || 0; - this.recordFamily(t); - changed = changed || before !== (this.familyVersion || 0); - } - const known = new Set(Object.keys(family)); - for (const id of Object.keys(family)) { - const n = family[id] || {}; - const live = liveById.get(id); - const parents = uniqueIds(n.parents, known); - const children = uniqueIds(n.children, known); - if (live) { - const liveParents = uniqueIds(live.parents, known); - const liveChildren = uniqueIds(live.children, known); - for (const p of liveParents) if (!parents.includes(p)) parents.push(p); - for (const c of liveChildren) if (!children.includes(c)) children.push(c); - } - const next = { - id, - name: live?.name || n.name || id, - type: live?.type || n.type || "smile", - personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])], - generation: Math.max(1, live?.generation || n.generation || 1), - hasPaired: !!(live?.hasPaired || n.hasPaired), - parents, - parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])], - children, - birthTime: live?.birthTime ?? n.birthTime ?? 0, - alive: live ? !live.dead : n.alive !== false, - deathReason: live?.deathReason || n.deathReason || "", - }; - for (const key of Object.keys(next)) { - const a = family[id]?.[key]; - const b = next[key]; - const equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b; - if (!equal) changed = true; - } - family[id] = next; - } - for (const child of Object.values(family)) { - for (const parentId of child.parents || []) { - const parent = family[parentId]; - if (!parent) continue; - if (!parent.children.includes(child.id)) { - parent.children.push(child.id); - changed = true; - } - } - } - for (const parent of Object.values(family)) { - const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id)); - if (!sameList(parent.children || [], nextChildren)) { - parent.children = nextChildren; - changed = true; - } - } - for (const id of Object.keys(family)) { - const n = family[id]; - if (!n?.hasPaired && !(n?.parents || []).length && !(n?.children || []).length) { - delete family[id]; - changed = true; - } - } - for (const [id, live] of liveById.entries()) { - const n = family[id]; - if (!n) continue; - if (!sameList(live.parents || [], n.parents || [])) { - live.parents = [...(n.parents || [])]; - changed = true; - } - if (!sameList(live.children || [], n.children || [])) { - live.children = [...(n.children || [])]; - changed = true; - } - if (live.hasPaired !== !!n.hasPaired) { - live.hasPaired = !!n.hasPaired; - changed = true; - } - if (live.generation !== n.generation) { - live.generation = n.generation; - changed = true; - } - } - if (changed) { - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("family-normalize"); - } - this.familyCleanVersion = this.familyVersion || 0; - return changed; - } - - resetFamilyTree() { - this.family = {}; - this.familyCleanVersion = 0; - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("family-reset"); - this.maxGeneration = 1; - for (const t of this.tarinai || []) { - t.parents = []; - t.parentNames = []; - t.children = []; - t.generation = 1; - t.hasPaired = false; - } - } - - validateFamily() { - this.normalizeFamily(); - const family = this.family || {}; - const issues = []; - const seenRenderIds = new Set(); - for (const n of Object.values(family)) { - if (!n?.id) continue; - const childSeen = new Set(); - for (const p of n.parents || []) { - if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p }); - else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p }); - } - for (const c of n.children || []) { - if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c }); - childSeen.add(c); - if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c }); - else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c }); - } - if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id }); - seenRenderIds.add(n.id); - } - return issues; - } - - clearLiveReferencesTo(dead) { - const deadId = dead?.id || dead?.releasedLiveId || ""; - if (!deadId) return false; - let changed = false; - for (const other of this.tarinai || []) { - if (!other || other === dead || other.dead) continue; - if (other.target === dead || other.target?.id === deadId) { - other.target = null; - if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.state = "idle"; - changed = true; - } - if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } - if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; } - if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) { - other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId); - changed = true; - } - for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) { - if (other[key] === deadId) { other[key] = null; changed = true; } - } - } - return changed; - } - - markDead(t, reason) { - this.clearLiveReferencesTo(t); - if (!this.family) this.family = {}; - const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; - const familyKey = this.tarinaiFamilyKey(t); - if (familyKey && this.family[familyKey]) { - if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) { - this.family[familyKey].alive = false; - this.family[familyKey].deathReason = finalReason; - delete this.family[familyKey].personality; - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("death"); - } - } - this.familyPrunePending = true; - } - - pruneExtinctFamilies(opts = {}) { - if (opts.normalize !== false) this.normalizeFamily(); - const family = this.family || {}; - const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); - if (!nodes.length) return; - const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(); - const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); }; - const link = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - add(a); add(b); - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - add(n.id); - for (const p of n.parents || []) link(n.id, p); - for (const c of n.children || []) link(n.id, c); - } - const seen = new Set(); - const remove = []; - for (const n of nodes) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - comp.push(id); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (!comp.some(id => family[id]?.alive)) remove.push(...comp); - } - if (!remove.length) return; - for (const id of remove) delete family[id]; - this.familyVersion = (this.familyVersion || 0) + 1; - this.markFamilyTreeDirty("family-prune"); - this.familyCleanVersion = this.familyVersion || 0; - } - - addTarinai(opts = {}) { - const t = new Tarinai(this, opts); - this.assignTarinaiLiveId(t); - this.tarinai.push(t); - this.drawListDirty = true; - if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t); - this.maxGeneration = Math.max(this.maxGeneration, t.generation); - return t; - } - - startBirthRitual(a, b) { - if (!a || !b || a.dead || b.dead) return false; - if (a.isZunchiSlave || b.isZunchiSlave) return false; - if (this.areParentChild(a, b)) return false; - if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false; - if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; - const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); - const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length); - if (aJoined && !bJoined) b.generation = a.generation; - else if (bJoined && !aJoined) a.generation = b.generation; - else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; } - // Do not mark either parent as part of a family during the ritual. - // The archive should only gain nodes after a child has actually been created; - // otherwise childless ritual participants appear as isolated family trees. - const duration = 10.0; - a.birthRitualTimer = b.birthRitualTimer = duration; - a.birthRitualMax = b.birthRitualMax = duration; - a.birthPartnerId = b.id; - b.birthPartnerId = a.id; - a.birthRitualRole = -1; - b.birthRitualRole = 1; - a.birthRitualLeader = true; - b.birthRitualLeader = false; - a.state = b.state = "birth_ritual"; - a.target = b; - b.target = a; - a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); - b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); - a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); - b.surpriseTimer = Math.max(b.surpriseTimer, 0.35); - return true; - } - - finishBirthRitual(a, b) { - if (!a || !b || a.dead || b.dead) return null; - const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; - a.birthRitualTimer = b.birthRitualTimer = 0; - a.birthPartnerId = b.birthPartnerId = null; - a.birthRitualLeader = b.birthRitualLeader = false; - a.state = b.state = "idle"; - if (reproductionBlockedByDisease) { - a.birthRitualRole = b.birthRitualRole = 0; - return null; - } - a.fightTimer = b.fightTimer = 0; - a.fightTargetIds = []; b.fightTargetIds = []; - a.fightTargetId = b.fightTargetId = null; - a.defeatedById = b.defeatedById = null; - a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18; - a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0); - const children = []; - const countRoll = Math.random(); - const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1); - for (let i = 0; i < childCount; i++) { - const child = this.spawnChild(a, b, i, childCount); - if (child) { child.postBirthPeaceTimer = 18; children.push(child); } - } - if (children.length > 0) this.lastBirthAt = this.time; - if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] }); - return children[0] || null; - } - - areParentChild(a, b) { - if (!a || !b) return false; - const aId = this.tarinaiFamilyKey(a) || a; - const bId = this.tarinaiFamilyKey(b) || b; - if (!aId || !bId) return false; - return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId)); - } - - areCoParents(a, b) { - if (!a || !b) return false; - const aLiveId = a.id || a; - const bLiveId = b.id || b; - if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false; - if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true; - if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true; - const aId = this.tarinaiFamilyKey(a) || aLiveId; - const bId = this.tarinaiFamilyKey(b) || bLiveId; - if (!aId || !bId || aId === bId) return false; - const aChildren = new Set((a.children || []).filter(Boolean)); - if (!aChildren.size) return false; - for (const id of (b.children || [])) { - if (aChildren.has(id)) return true; - } - return false; - } - - canStartFight(t) { - if (!t || t.dead) return false; - if (t.sleepDisease) return false; - // Disease fight rule: explosion, zunchi, and fight disease can lash out; - // sleep disease cannot fight or be challenged, and other diseases cannot start fights. - if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true; - const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value); - const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease); - if (hasOtherDisease) return false; - return true; - } - - canBeFightTarget(t) { - return Boolean(t && !t.dead && !t.sleepDisease); - } - - canFightPair(actor, target) { - return this.canStartFight(actor) && this.canBeFightTarget(target); - } - - spawnChild(a, b, index = 0, total = 1) { - const inherited = Math.random() < 0.56 ? a.type : b.type; - const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; - const childBirthPersonality = personalityFromParents(a, b); - const child = this.addTarinai({ - type: mutation, - birthPersonality: childBirthPersonality, - currentPersonality: childBirthPersonality, - name: makeName(), - age: 0, - x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, - y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0), - scale: rand(0.17, 0.23), - hunger: rand(20, 40), - loneliness: rand(30, 56), - energy: rand(55, 95), - stress: rand(0, 14), - need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), - generation: Math.max(a.generation, b.generation) + 1, - lifeSpan: rand(1500, 2400), - parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)], - parentNames: [a.name, b.name], - birthTime: this.time, - }); - this.maxGeneration = Math.max(this.maxGeneration, child.generation); - a.hasPaired = true; - b.hasPaired = true; - a.affection = 0; b.affection = 0; - a.loneliness *= 0.65; b.loneliness *= 0.65; - a.adjustRelation?.(b, 0.65, -0.20, "co_parent"); - b.adjustRelation?.(a, 0.65, -0.20, "co_parent"); - a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28); - b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28); - const childKey = this.tarinaiFamilyKey(child); - const aKey = this.tarinaiFamilyKey(a); - const bKey = this.tarinaiFamilyKey(b); - a.children.push(childKey); - b.children.push(childKey); - this.recordFamily(a); - this.recordFamily(b); - this.recordFamily(child); - if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey); - if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey); - this.familyCleanVersion = null; - audio.birth(); - this.lastBirthAt = this.time; - if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] }); - return child; - } - - startForcedFight(a, b) { - if (!a || !b || a === b || a.dead || b.dead) return false; - if (!this.canFightPair(a, b)) return false; - const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id); - if (alreadyFighting) return true; - for (const t of [a, b]) { - t.birthRitualTimer = 0; - t.birthRitualMax = 0; - t.birthPartnerId = null; - t.birthRitualLeader = false; - t.birthRitualRole = 0; - t.postBirthPeaceTimer = 0; - t.fightCooldown = 0; - t.intimidateTimer = 0; - t.intimidateTargetId = null; - t.intimidatedTimer = 0; - t.defeatedTimer = 0; - t.defeatedById = null; - t.fightWinnerId = null; - t.target = null; - t.state = "idle"; - } - this.startFightMochiIntimidation(a, b); - this.startFight(a, b); - return true; - } - - startFightMochiIntimidation(a, b) { - if (!a || !b || a.dead || b.dead) return false; - const aDrug = (a.fightMochiTimer || 0) > 0.04; - const bDrug = (b.fightMochiTimer || 0) > 0.04; - const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); - const target = actor === a ? b : a; - if (!this.canFightPair(actor, target)) return false; - actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); - actor.intimidateTargetId = target.id; - actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25)); - target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear); - actor.adjustRelation(target, -0.05, 0.06, "intimidate"); - target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate"); - this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { - life: 0.34, - size: actor.radius * 0.62, - color: "rgba(154, 88, 42, 0.74)", - })); - this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)"); - return true; - } - - startConflict(a, b) { - if (!a || !b || a.dead || b.dead) return; - if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; } - if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; - const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); - const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); - const actor = scoreA >= scoreB ? a : b; - const target = actor === a ? b : a; - if (!this.canFightPair(actor, target)) return; - const actorScore = actor === a ? scoreA : scoreB; - const targetScore = actor === a ? scoreB : scoreA; - const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); - const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04; - const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90); - if (canIntimidate && Math.random() < intimidationChance) { - this.startIntimidation(actor, target, actorScore, targetScore); - return; - } - this.startFight(a, b); - } - - startIntimidation(actor, target, actorScore = 0, targetScore = 0) { - if (!actor || !target || actor.dead || target.dead) return false; - if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; - const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; - const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + rand(-5, 5); - const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96); - actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); - target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); - actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); - actor.intimidateTargetId = target.id; - actor.state = "intimidate"; - this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { - life: 0.42, - size: actor.radius * 0.55, - 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)"); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45)); - target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); - if (Math.random() >= successChance) { - target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); - return true; - } - target.defeatedById = actor.id; - target.fightWinnerId = actor.id; - target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); - target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); - target.state = "panic"; - target.target = actor; - this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); - target.vy += rand(-18, 18); - actor.adjustRelation(target, -0.08, 0.06, "intimidate"); - target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); - if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { - this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] }); - actor.lastLog = target.lastLog = this.time; - } - return true; - } - - startFight(a, b) { - if (!a || !b || a.dead || b.dead) return; - if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - a.state = "fight"; b.state = "fight"; - a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); - b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); - a.nextHeadbutt = this.time + rand(0.08, 0.18); - b.nextHeadbutt = a.nextHeadbutt; - a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); - b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); - if (!a.fightTargetIds) a.fightTargetIds = []; - if (!b.fightTargetIds) b.fightTargetIds = []; - if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); - if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id); - a.fightTargetIds = a.fightTargetIds.slice(-4); - b.fightTargetIds = b.fightTargetIds.slice(-4); - a.fightTargetId = a.fightTargetIds[0]; - b.fightTargetId = b.fightTargetIds[0]; - const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + aAggressiveIntent * 5 + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + bAggressiveIntent * 5 + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const loser = aScore <= bScore ? a : b; - const winner = loser === a ? b : a; - loser.defeatedById = winner.id; - loser.fightWinnerId = winner.id; - winner.fightWinnerId = winner.id; - winner.defeatedById = null; - winner.defeatedTimer = 0; - a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); - b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); - a.damage(rand(2, 7), "\u55a7\u5629"); - b.damage(rand(2, 7), "\u55a7\u5629"); - const dx = b.x - a.x, dy = b.y - a.y; - const d = Math.hypot(dx, dy) || 1; - a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); - b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); - 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, { - size: rand(12, 20), - life: rand(0.32, 0.52), - color: "rgba(116, 73, 38, 0.82)", - })); - audio.fight(); - 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] }); - a.lastLog = b.lastLog = this.time; - } - } - - damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) { - let hit = 0; - for (const ant of this.ants || []) { - if (!ant || ant.dead) continue; - const d = Math.max(1, distXY(ant.x, ant.y, x, y)); - if (d > radius + (ant.r || 4)) continue; - const p = clamp(1 - d / Math.max(1, radius), 0, 1); - if (p <= 0 && d > radius) continue; - const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0); - if (damage > 0) { - ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage); - ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25); - } - if (opts.push) { - let nx = (ant.x - x) / d; - let ny = (ant.y - y) / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); - nx = Math.cos(a); - ny = Math.sin(a); - } - const push = typeof opts.push === "function" ? opts.push(p, ant, d) : Number(opts.push || 0); - ant.vx = (ant.vx || 0) + nx * push; - ant.vy = (ant.vy || 0) + ny * push; - ant.x = clamp(ant.x + nx * Math.min(18, push * 0.018), CONFIG.worldPadding, this.w - CONFIG.worldPadding); - ant.y = clamp(ant.y + ny * Math.min(18, push * 0.018), CONFIG.worldPadding, this.h - CONFIG.worldPadding); - } - hit += 1; - if (ant.hp <= 0 && ant.dieAsCorpse) ant.dieAsCorpse(ant.homeNest?.()); - } - if (hit > 0) this.drawListDirty = true; - return hit; - } - - explodeFirecracker(it) { - if (!it || it.dead || it.type !== "firecracker") return; - it.amount = 0; - const x = it.x, y = it.y; - 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.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" })); - 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.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1); - for (let i = 0; i < 34; i++) { - const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); - const speed = rand(90, 260); - this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { - vx: Math.cos(a) * speed, - vy: Math.sin(a) * speed, - size: rand(8, i % 3 === 0 ? 24 : 20), - life: rand(0.26, 0.72), - color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", - })); - } - for (const t of this.tarinai) { - if (t.dead) continue; - const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed"; - t.sleeping = false; - if (wasSleeping) { - t.state = "panic"; - t.target = { x, y, dead: false }; - t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); - t.fearTimer = Math.max(t.fearTimer, 1.2); - t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f"; - } - t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); - t.fearTimer = Math.max(t.fearTimer, 0.58); - const dx = t.x - x; - const dy = t.y - y; - const d = Math.hypot(dx, dy) || 1; - if (d > blastRadius) continue; - const p = clamp(1 - d / blastRadius, 0, 1); - t.damage(5 + p * 18, "\u7206\u7af9"); - t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); - t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); - t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); - t.fearTimer = Math.max(t.fearTimer, 1.7 + p); - t.state = "panic"; - t.target = { x, y }; - let nx = dx / d; - let ny = dy / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); - nx = Math.cos(a); - ny = Math.sin(a); - } - const distanceBoost = p * p * 0.55 + p * 0.45; - const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); - this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { - panic: true, - target: { x, y }, - fearTimer: 1.7 + p, - }); - t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); - t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); - t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); - t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); - this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); - if (!t.dead && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it); - if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - } - this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { - push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), - }); - let blastedBalls = 0; - for (const ball of this.items) { - if (!ball || ball.dead || ball.type !== "ball") continue; - let dx = ball.x - x; - let dy = ball.y - y; - let d = Math.hypot(dx, dy) || 1; - if (d > blastRadius * 1.18) continue; - const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); - if (d < 0.001) { - const a = rand(0, Math.PI * 2); - dx = Math.cos(a); dy = Math.sin(a); d = 1; - } - const blast = 430 + p * 960; - this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); - const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); - ball.lastPokedAt = this.time || 0; - 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)" })); - blastedBalls += 1; - } - audio.explode(); - this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); - } - - explodeDiseaseTarinai(t) { - if (!t || t.dead) return; - const x = t.x, y = t.y; - const blastRadius = 190; - t.explosionDisease = false; - t.explosionDiseaseTimer = 0; - this.effects.push(new Effect("explosion", x, y, { size: 72, life: 0.82, color: "rgba(255,176,68,0.88)" })); - this.effects.push(new Effect("ring", x, y, { size: 58, life: 0.52, color: "rgba(255,238,132,0.82)" })); - this.blastZunchiFrom(x, y, blastRadius * 0.92, 0.82); - for (const o of this.tarinai) { - if (!o || o.dead || o === t) continue; - const dx = o.x - x; - const dy = o.y - y; - const d = Math.hypot(dx, dy) || 1; - if (d > blastRadius) continue; - const q = clamp(1 - d / blastRadius, 0, 1); - o.damage(4 + q * 15, "\u7206\u767a\u75c5"); - o.addStress ? o.addStress(16 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 16 * q, 0, 130)); - let nx = dx / d; - let ny = dy / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); - nx = Math.cos(a); - ny = Math.sin(a); - } - const diseaseBlast = (190 + q * 620) * rand(0.80, 1.22); - this.applyImpulse(o, nx * diseaseBlast + rand(-64, 64) * (0.5 + q), ny * diseaseBlast + rand(-64, 64) * (0.5 + q), { - panic: true, - target: { x, y }, - fearTimer: 1.0 + q, - }); - o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q); - o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q); - if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t); - } - t.die("\u7206\u767a\u75c5"); - audio.explode(); - this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] }); - } - - applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) { - const radius = 96; - const speed = Math.hypot(dx, dy) || 1; - const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1); - const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7); - let cleaned = 0; - for (const it of this.nearbyItems(x, y, radius + 80)) { - if (!it || it.dead) continue; - const d = distXY(it.x, it.y, x, y); - if (d > radius + (it.r || 12)) continue; - const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1); - if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") { - it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p); - cleaned += p; - } - } - for (const ef of this.effects || []) { - if (!ef || ef.dead) continue; - if (ef.type !== "bleed" && ef.type !== "splat") continue; - const d = distXY(ef.x || 0, ef.y || 0, x, y); - if (d > radius + 22) continue; - const p = clamp(1 - d / (radius + 22), 0, 1); - ef.life = Math.min(ef.life || 0.1, Math.max(0.01, (ef.life || 0.1) - dt * (1.6 + p * 5.0))); - cleaned += p * 0.55; - } - for (const t of this.nearbyTarinai(x, y, radius + 70)) { - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - const d = distXY(t.x, t.y, x, y); - if (d > radius + t.radius) continue; - const p = clamp(1 - d / (radius + t.radius), 0, 1); - t.vx += nx * (95 + 165 * p) + rand(-10, 10); - t.vy += ny * (95 + 165 * p) + rand(-10, 10); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); - t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose"); - } - if (Math.random() < 0.72) { - this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" })); - } - if (cleaned > 0.25) { - this.drawListDirty = true; - this.updateItemCounts(); - } - } - - spawnZunchi(x, y) { - const zunchiLimit = CONFIG.zunchiLimit ?? 56; - if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { - let oldest = null; - for (const it of this.items) { - if (it.type !== "zunchi" || it.dead) continue; - if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it; - } - if (oldest) oldest.amount = 0; - } - const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); - it.amount = 260; - this.items.push(it); - this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; - this.spawnBubble(it.x, it.y - 16, "\u3076\u308a\u3085\u3063", "rgba(62,84,45,0.78)"); - } - - spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { - if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; - if (text === "z") text = "Zzz..."; - audio.bubble(text); - this.effects.push(new Effect("bubble", x, y, { - vx: rand(-2, 2), - vy: rand(-5, -2), - life: rand(2.2, 3.2), - size: 8, - text, - color, - })); - this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1; - } - - spawnHeadbuttEffect(x, y) { - this.effects.push(new Effect("fight", x, y, { - size: rand(10, 16), - life: rand(0.20, 0.34), - color: "rgba(105, 62, 34, 0.86)", - })); - if (Math.random() < 0.5) audio.fight(); - } - - spawnFallEffect(x, y, scale = 1) { - if ((this.effectCounts.fall || 0) > 18) return; - this.effects.push(new Effect("fall", x, y, { - vx: rand(-12, 12), - vy: rand(-4, 3), - size: rand(20, 32) * scale, - life: rand(0.48, 0.72), - color: "rgba(154, 124, 80, 0.58)", - })); - } - - spawnEatEffect(x, y, color = "#f1dfb7") { - if (Math.random() < 0.70) { - this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { - vx: rand(-10, 10), - vy: rand(-18, -3), - size: rand(2.2, 4.2), - life: rand(0.18, 0.32), - color, - })); - } - if (Math.random() < 0.08) { - this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); - } - } - - spawnBleedEffect(x, y) { - this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { - vx: rand(-8, 8), - vy: rand(3, 14), - size: rand(2.0, 3.8), - life: rand(0.34, 0.62), - color: "rgba(80, 172, 55, 0.78)", - })); - } - - isSleepFurniture(it) { - return Boolean(it && (it.type === "bed" || it.type === "nest_box")); - } - - isTarinaiHiddenInNestBox(t) { - return Boolean(t && !t.dead && t.insideNestBoxId); - } - - bedOccupancy(bed) { - if (!this.isSleepFurniture(bed)) return 0; - if (bed.type === "nest_box") { - return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 0; - } - let n = 0; - const radius = Math.max(86, bed.r * 2.45); - for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { - if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; - } - return n; - } - - bedComfort(bed) { - if (!this.isSleepFurniture(bed)) return 0.7; - const occ = this.bedOccupancy(bed); - return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); - } - - bestBedFor(t, maxDist = 360) { - const choose = (kind) => { - let best = null; - let bestScore = Infinity; - for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { - if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue; - if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { - const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; - if (occupants.length >= this.nestBoxCapacity(bed)) continue; - } - if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; - const d = dist(t, bed); - const occ = this.bedOccupancy(bed); - const comfort = this.bedComfort(bed); - const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34; - const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty; - if (score < bestScore) { best = bed; bestScore = score; } - } - return best; - }; - // Prefer an available nest box, then fall back to a hay bed. - return choose("nest_box") || choose("bed"); - } - - relationNotice(aId, bId, kind, cooldown = 30) { - const key = [aId, bId].sort().join(":") + `:${kind}`; - const last = this.relationNotices?.[key] || -Infinity; - if (this.time - last < cooldown) return false; - if (!this.relationNotices) this.relationNotices = {}; - this.relationNotices[key] = this.time; - return true; - } - - applyImpulse(entity, vx = 0, vy = 0, options = {}) { - if (!entity || entity.dead) return false; - if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; - if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { - entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; - entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; - } else { - entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx; - entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; - } - if (options.panic && entity instanceof Tarinai) { - entity.state = "panic"; - entity.target = options.target || null; - entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); - } - return true; - } - - applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { - if (!entity || !Number.isFinite(strength)) return false; - let dx = (entity.x || 0) - sourceX; - let dy = (entity.y || 0) - sourceY; - let d = Math.hypot(dx, dy); - if (!Number.isFinite(d) || d < 0.001) { - const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); - dx = Math.cos(a); - dy = Math.sin(a); - d = 1; - } - const random = options.random || 0; - return this.applyImpulse( - entity, - dx / d * strength + rand(-random, random), - dy / d * strength + rand(-random, random), - options, - ); - } - - applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { - if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; - const normalized = options.cause || cause || "\u885d\u7a81"; - if (target.damage) target.damage(amount, normalized); - else if (Number.isFinite(target.hp)) target.hp -= amount; - return true; - } - - resolveFightOutcome(winner, loser) { - if (!winner || !loser || winner.dead || loser.dead) return; - const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; - if (!this.resolvedFightIds) this.resolvedFightIds = {}; - if (this.resolvedFightIds[key]) return; - this.resolvedFightIds[key] = true; - winner.defeatedById = null; - winner.defeatedTimer = 0; - winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); - winner.fightTargetIds = []; - winner.fightTargetId = null; - winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f"); - loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f"); - winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1; - loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1; - winner.totalFightWins = (winner.totalFightWins || 0) + 1; - loser.totalFightLosses = (loser.totalFightLosses || 0) + 1; - winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); - loser.adjustPersonality?.("aggression", -0.018, "after losing fights."); - if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) { - const oldName = loser.formerName || loser.name; - this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); - this.log(`${oldName}\u306f5\u56de\u55a7\u5629\u306b\u8ca0\u3051\u3001\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: [loser] }); - } - loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); - this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); - winner.affection = clamp(winner.affection + 0.8, 0, 80); - winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); - if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { - this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); - } - } - - itemDropImpact(it) { - if (!it || it.dead || it.type === "water") return; - const isStone = it.type === "stone"; - const isGenkotsu = it.type === "genkotsu"; - const radius = isGenkotsu ? Math.max(118, (it.r || 72) * 1.72) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); - const damage = isGenkotsu ? rand(34, 54) : (isStone ? rand(20, 34) : rand(3, 7)); - const push = isGenkotsu ? rand(330, 470) : (isStone ? rand(145, 210) : rand(54, 92)); - const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064\u304c\u843d\u3061\u305f" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); - if (isGenkotsu) it.impactFlash = 1; - let hit = 0; - for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); - const p = clamp(1 - d / (radius + t.radius), 0, 1); - if (p <= 0) continue; - let nx = (t.x - it.x) / d; - let ny = (t.y - it.y) / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const a = rand(0, Math.PI * 2); - nx = Math.cos(a); - ny = Math.sin(a); - } - t.vx += nx * push * (0.35 + p); - t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 92 : (isStone ? 42 : 18)) * p; - t.damage(damage * (0.35 + p * 0.65), reason); - t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); - t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); - t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - hit += 1; - } - const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { - push: p => push * (0.35 + p), - }); - if (isGenkotsu) { - for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); - for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); - } else { - this.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)" })); - } - if (hit > 0 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.22 : (isStone ? 0.9 : 0.45)); - if ((isStone || isGenkotsu) && (hit > 0 || antHit > 0)) audio.poke(); - if (hit > 0 || antHit > 0) { - const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); - this.log(`${label}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u3068${antHit}\u5339\u306e\u30a2\u30ea\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); - } - } - - countBallChasers(ball, except = null) { - if (!ball) return 0; - let count = 0; - for (const t of this.tarinai || []) { - if (!t || t === except || t.dead) continue; - if (t.state === "play_ball" && t.target === ball) count += 1; - } - return count; - } - - limitBallChasers() { - if (!this.itemCounts?.ball) return; - const groups = new Map(); - for (const t of this.tarinai || []) { - if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue; - const arr = groups.get(t.target) || []; - arr.push(t); - groups.set(t.target, arr); - } - for (const [ball, arr] of groups) { - if (arr.length <= 5) continue; - arr.sort((a, b) => { - const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; - const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; - return dist(a, ball) + ap - (dist(b, ball) + bp); - }); - for (const t of arr.slice(5)) { - t.state = "idle"; - t.target = null; - t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"; - t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); - } - } - } - - pokeBall(ball, x, y) { - if (!ball || ball.dead || ball.type !== "ball") return false; - const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0); - let dx = ball.x - x; - let dy = ball.y - y; - let d = Math.hypot(dx, dy); - if (d < 0.001) { - const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55); - dx = Math.cos(a); - dy = Math.sin(a); - d = 1; - } - const nx = dx / d; - const ny = dy / d; - const recent = now - (ball.lastPokedAt || -999) < 0.82; - ball.pokeCombo = recent ? Math.min(10, (ball.pokeCombo || 0) + 1) : 1; - ball.lastPokedAt = now; - ball.lastPokeAngle = Math.atan2(ny, nx); - const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24); - ball.vx = (ball.vx || 0) + nx * impulse; - ball.vy = (ball.vy || 0) + ny * impulse; - const speed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38); - ball.amount = Math.max(ball.amount || 999, 999); - audio.poke(); - 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: "rgba(255,255,255,0.58)" })); - this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true }); - return true; - } - - resolveBallBallCollisions(dt) { - if ((this.itemCounts?.ball || 0) < 2) return; - const now = this.time || 0; - if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); - const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball"); - for (const a of balls) { - const ar = a.r || 18; - const avx = a.vx || 0; - const avy = a.vy || 0; - const aSpeed = Math.hypot(avx, avy); - const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; - for (const b of this.nearbyItems(a.x, a.y, range) || []) { - if (!b || b === a || b.dead || b.type !== "ball") continue; - const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; - if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; - const br = b.r || 18; - const hitRadius = ar + br; - let dx = b.x - a.x; - let dy = b.y - a.y; - let d = Math.hypot(dx, dy); - let hitX = a.x; - let hitY = a.y; - if (d > hitRadius) { - const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; - const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; - const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; - const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; - const rvx = (a.x - ax0) - (b.x - bx0); - const rvy = (a.y - ay0) - (b.y - by0); - const rx = ax0 - bx0; - const ry = ay0 - by0; - const aa = rvx * rvx + rvy * rvy; - if (aa <= 0.0001) continue; - const bb = 2 * (rx * rvx + ry * rvy); - const cc = rx * rx + ry * ry - hitRadius * hitRadius; - const disc = bb * bb - 4 * aa * cc; - if (disc < 0) continue; - const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); - const axHit = ax0 + (a.x - ax0) * u; - const ayHit = ay0 + (a.y - ay0) * u; - const bxHit = bx0 + (b.x - bx0) * u; - const byHit = by0 + (b.y - by0) * u; - dx = bxHit - axHit; - dy = byHit - ayHit; - d = Math.hypot(dx, dy); - hitX = (axHit + bxHit) * 0.5; - hitY = (ayHit + byHit) * 0.5; - if (d > hitRadius + 0.5) continue; - } else { - hitX = (a.x + b.x) * 0.5; - hitY = (a.y + b.y) * 0.5; - } - if (d < 0.001) { - const rvx = (b.vx || 0) - (a.vx || 0); - const rvy = (b.vy || 0) - (a.vy || 0); - const rs = Math.hypot(rvx, rvy); - if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } - else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } - } - const nx = dx / d; - const ny = dy / d; - const rvx = (b.vx || 0) - (a.vx || 0); - const rvy = (b.vy || 0) - (a.vy || 0); - const relNormal = rvx * nx + rvy * ny; - if (relNormal > 0 && d >= hitRadius - 0.5) continue; - this.ballCollisionMemo.set(key, now); - const restitution = 0.86; - const impulse = -(1 + restitution) * relNormal / 2; - if (Number.isFinite(impulse)) { - a.vx = (a.vx || 0) - impulse * nx; - a.vy = (a.vy || 0) - impulse * ny; - b.vx = (b.vx || 0) + impulse * nx; - b.vy = (b.vy || 0) + impulse * ny; - } - const overlap = Math.max(0, hitRadius - d + 0.6); - if (overlap > 0) { - a.x -= nx * overlap * 0.5; - a.y -= ny * overlap * 0.5; - b.x += nx * overlap * 0.5; - b.y += ny * overlap * 0.5; - } - const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); - a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); - b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); - this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); - } - } - } - - resolveBallInteractions(dt) { - if (!this.itemCounts?.ball) return; - for (const ball of this.items) { - if (!ball || ball.dead || ball.type !== "ball") continue; - const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; - const range = (ball.r || 18) + 72 + sweep; - const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; - const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; - const sx = ball.x - px; - const sy = ball.y - py; - const segLenSq = sx * sx + sy * sy; - for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { - if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; - let hitX = ball.x; - let hitY = ball.y; - if (segLenSq > 1) { - const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1); - hitX = px + sx * u; - hitY = py + sy * u; - } - let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y)); - const hitDistance = (ball.r || 18) + t.radius * 0.74; - if (d > hitDistance) continue; - const now = this.time || 0; - const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; - if (repeat && ballSpeed < 300) continue; - let nx = (hitX - t.x) / d; - let ny = (hitY - t.y) / d; - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - if (ballSpeed > 0.01) { - nx = (ball.vx || 0) / ballSpeed; - ny = (ball.vy || 0) / ballSpeed; - } else { - nx = t.facingDir ? t.facingDir() : 1; - ny = rand(-0.22, 0.22); - } - } - - const isDangerous = ballSpeed >= 320; - if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { - const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; - const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; - const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); - t.lastBallDamageAt = now; - this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { - panic: true, - target: { x: ball.x, y: ball.y }, - fearTimer: 0.95, - }); - this.applyImpactDamage(t, damage, "\u885d\u7a81"); - 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); - t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); - t.state = "panic"; - t.target = { x: ball.x, y: ball.y }; - 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.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); - t.fallDir = bvx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer, 0.95); - t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44); - t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); - this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); - this.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)" })); - if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); - ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); - ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); - continue; - } - - const relVx = (t.vx || 0) - (ball.vx || 0); - const relVy = (t.vy || 0) - (ball.vy || 0); - const relativeSpeed = Math.hypot(relVx, relVy); - const playIntent = t.state === "play_ball" || t.target === ball; - const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); - const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); - ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; - ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; - const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); - ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); - ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); - ball.lastKickedAt = now; - ball.lastKickerId = t.id; - t.vx -= nx * Math.min(22, impulse * 0.10); - t.vy -= ny * Math.min(22, impulse * 0.10); - t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); - const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; - const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; - t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); - if (playful && playIntent) { - t.loneliness = clamp(t.loneliness - 0.9, 0, 100); - t.affection = clamp(t.affection + 0.18, 0, 100); - } - t.goodMode = playIntent || playful ? "smile" : t.goodMode; - if (playIntent && now > (t.nextPlayBubbleAt || 0)) { - t.nextPlayBubbleAt = now + rand(2.0, 3.8); - this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); - } - if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); - this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); - } - } - } - - resolveTarinaiHighSpeedCollisions(dt) { - const threshold = 150; - const now = this.time || 0; - if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); - for (const a of this.tarinai || []) { - if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; - const avx = a.vx || 0; - const avy = a.vy || 0; - const aSpeed = Math.hypot(avx, avy); - if (aSpeed < threshold) continue; - const px = Number.isFinite(a.prevX) ? a.prevX : a.x; - const py = Number.isFinite(a.prevY) ? a.prevY : a.y; - const sx = a.x - px; - const sy = a.y - py; - const segLenSq = sx * sx + sy * sy; - const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; - for (const b of this.nearbyTarinai(a.x, a.y, range)) { - if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; - const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; - if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; - const bvx = b.vx || 0; - const bvy = b.vy || 0; - const relVx = avx - bvx; - const relVy = avy - bvy; - const relSpeed = Math.hypot(relVx, relVy); - if (relSpeed < threshold) continue; - let hitX = a.x; - let hitY = a.y; - if (segLenSq > 1) { - const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); - hitX = px + sx * u; - hitY = py + sy * u; - } - const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); - const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; - if (d > hitDistance) continue; - this.tarinaiCollisionMemo.set(key, now); - const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; - const target = impactor === a ? b : a; - const ivx = impactor.vx || 0; - const ivy = impactor.vy || 0; - const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; - let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); - let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); - if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { - const aa = rand(0, Math.PI * 2); - nx = Math.cos(aa); - ny = Math.sin(aa); - } - const damage = clamp((relSpeed - 118) / 26, 1.2, 22); - this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { - panic: true, - target: { x: impactor.x, y: impactor.y }, - fearTimer: 0.55, - }); - impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); - impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); - this.applyImpactDamage(target, damage, "\u885d\u7a81"); - if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); - for (const t of [target, impactor]) { - if (!t || t.dead) continue; - t.state = "panic"; - t.target = { x: impactor.x, y: impactor.y }; - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); - t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); - t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); - t.fallDir = nx >= 0 ? 1 : -1; - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - } - this.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)" })); - if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.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] }); - } - } - } - - nearest(entity, types, maxDist = Infinity) { - let best = null, bestD = maxDist; - const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; - for (const it of candidates) { - if (it.dead || !types.includes(it.type)) continue; - if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue; - const d = dist(entity, it); - if (d < bestD) { best = it; bestD = d; } - } - return best; - } - - nearestOther(entity, maxDist = Infinity, predicate = null) { - let best = null, bestD = maxDist; - const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; - for (const o of candidates) { - if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue; - if (predicate && !predicate(o)) continue; - if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; - const d = dist(entity, o); - if (d < bestD) { best = o; bestD = d; } - } - return best; - } - - temperatureAt(x, y) { - let temp = 0.54 + Math.sin(this.time / 55) * 0.05; - for (const it of this.nearbyItems(x, y, 190)) { - if (it.type === "stone") { - const d = distXY(x, y, it.x, it.y); - temp += clamp(1 - d / 190, 0, 1) * 0.35; - } - } - return clamp(temp, 0, 1); - } - - - fenceRect(it) { - const vertical = it?.type === "fence_v"; - const horizontal = it?.type === "fence_h"; - if (!vertical && !horizontal) return null; - const len = Math.max(112, (it.r || 42) * 3.55); - const thick = Math.max(10, (it.r || 42) * 0.31); - const halfW = vertical ? thick / 2 : len / 2; - const halfH = vertical ? len / 2 : thick / 2; - return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal }; - } - - nestBoxCapacity() { - return 5; - } - - nestBoxBaseRect(it) { - if (!it || it.dead || it.type !== "nest_box") return null; - const r = it.r || 42; - return { - left: it.x - r * 1.28, - right: it.x + r * 1.28, - top: it.y - r * 0.80, - bottom: it.y + r * 0.72, - type: "nest_box", - item: it, - }; - } - - nestBoxSolidRects(it) { - const base = this.nestBoxBaseRect(it); - if (!base) return []; - const w = base.right - base.left; - const h = base.bottom - base.top; - const colW = w / 3; - const rowH = h / 3; - const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it }); - return [ - mk(base.left, base.right, base.top, base.top + rowH, "top"), - mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"), - mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"), - ]; - } - - nestBoxTopRect(it) { - return this.nestBoxSolidRects(it)[0] || null; - } - - nestBoxEntryPoint(box) { - const base = this.nestBoxBaseRect(box); - if (!base) return { x: box?.x || 0, y: box?.y || 0 }; - const r = box.r || 42; - // Move the approach point to the center of the open middle cell. The - // collision resolver also ignores this nest box for active sleepers near - // the door, so tarinai can cross the threshold instead of sliding off posts. - return { - x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), - y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18), - }; - } - - nestBoxExitPoint(box, occupant = null) { - const base = this.nestBoxBaseRect(box); - if (!base) return this.nestBoxEntryPoint(box); - const r = box.r || 42; - const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; - return { - x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), - y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding), - }; - } - - nestBoxInnerPoint(box, occupant = null) { - const base = this.nestBoxBaseRect(box); - if (!base) return { x: box?.x || 0, y: box?.y || 0 }; - const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : []; - let index = occupants.indexOf(occupant); - if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); - const r = box.r || 42; - const slots = [ - [0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16], - ]; - const slot = slots[index % slots.length]; - return { - x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), - y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), - }; - } - - solidObstacleRects(it) { - if (!it || it.dead) return []; - if (this.isFenceType(it.type)) { - const rect = this.fenceRect(it); - return rect ? [{ ...rect, type: it.type, item: it }] : []; - } - if (it.type === "nest_box") return this.nestBoxSolidRects(it); - return []; - } - - shouldIgnoreNestBoxCollisionFor(t, box) { - if (!t || !box || box.dead || box.type !== "nest_box") return false; - if (t.insideNestBoxId) return true; - if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false; - const entry = this.nestBoxEntryPoint(box); - const base = this.nestBoxBaseRect(box); - const r = box.r || 42; - const entryD = distXY(t.x, t.y, entry.x, entry.y); - const centerD = distXY(t.x, t.y, box.x, box.y); - const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0); - const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58; - return Boolean(aroundDoor || inDoorColumn); - } - - nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { - const rects = []; - let checked = 0; - for (const it of this.nearbyItems(x, y, radius)) { - if (!it || it === exclude || it.dead) continue; - if (include && !include(it)) continue; - const partRects = this.solidObstacleRects(it); - if (!partRects.length) continue; - checked += 1; - for (const rect of partRects) rects.push(rect); - if (checked >= maxChecks) break; - } - return rects; - } - - pointInRect(x, y, r, padding = 0) { - return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding); - } - - pushTarinaiOutOfRect(t, r, rr) { - if (!t || !r) return false; - const cx = clamp(t.x, r.left, r.right); - const cy = clamp(t.y, r.top, r.bottom); - let dx = t.x - cx; - let dy = t.y - cy; - let d = Math.hypot(dx, dy); - if (d >= rr) return false; - if (d < 0.001) { - const left = Math.abs(t.x - r.left); - const right = Math.abs(r.right - t.x); - const top = Math.abs(t.y - r.top); - const bottom = Math.abs(r.bottom - t.y); - const m = Math.min(left, right, top, bottom); - if (m === left) { dx = -1; dy = 0; d = 1; } - else if (m === right) { dx = 1; dy = 0; d = 1; } - else if (m === top) { dx = 0; dy = -1; d = 1; } - else { dx = 0; dy = 1; d = 1; } - } - const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); - t.x += dx / d * push; - t.y += dy / d * push; - if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; - else t.vy *= -0.18; - return true; - } - - resolveSolidObstacleCollision(t) { - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; - const rr = Math.max(8, t.radius * 0.74); - let pushed = false; - for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { - if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; - if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true; - } - if (pushed) { - const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); - t.x = clamp(t.x, pad, this.w - pad); - t.y = clamp(t.y, pad, this.h - pad); - t.vx = clamp(t.vx || 0, -130, 130); - t.vy = clamp(t.vy || 0, -130, 130); - } - } - - resolveFenceCollision(t) { - this.resolveSolidObstacleCollision(t); - } - - - - segmentIntersectsRect(x1, y1, x2, y2, r) { - if (!r) return false; - if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true; - const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => { - const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px); - return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); - }; - return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top) - || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom) - || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom) - || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); - } - - pathBlockedByFence(x1, y1, x2, y2, padding = 16) { - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; - const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; - for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) { - const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; - if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; - } - return false; - } - - grassBlockedAt(x, y, self = null) { - for (const it of this.nearbyItems(x, y, 96)) { - if (it === self || it.dead) continue; - for (const r of this.solidObstacleRects(it)) { - if (this.pointInRect(x, y, r, 10)) return true; - } - if (it.type !== "zunchi") continue; - const dx = (x - it.x) / Math.max(22, it.r * 1.35); - const dy = (y - it.y) / Math.max(12, it.r * 0.82); - if (dx * dx + dy * dy < 1) return true; - } - return false; - } - - grassCrowdedAt(x, y, minGap = 30, self = null) { - for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) { - if (it === self || it.dead || it.type !== "grass") continue; - if (distXY(x, y, it.x, it.y) < minGap) return true; - } - return false; - } - - grassOnTarinaiAt(x, y, minGap = 24) { - for (const t of this.nearbyTarinai(x, y, minGap + 36)) { - if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; - } - return false; - } - - grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) { - if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false; - if (this.grassBlockedAt(x, y, self)) return false; - if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false; - if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false; - return true; - } - - findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) { - const cx = clamp(x, 44, this.w - 44); - const cy = clamp(y, 44, this.h - 44); - if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy }; - const blockedAtCenter = this.grassBlockedAt(cx, cy); - const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius; - const outer = Math.max(maxRadius, inner + 42); - const golden = Math.PI * (3 - Math.sqrt(5)); - for (let i = 0; i < attempts; i++) { - const t = attempts <= 1 ? 1 : i / (attempts - 1); - const radius = lerp(inner, outer, Math.sqrt(t)); - const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2; - const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44); - const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44); - if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py }; - } - return null; - } - - findGrassSproutSpot(source, fertile = false) { - const attempts = fertile ? 24 : 10; - const minR = fertile ? Math.max(30, source.r * 1.9) : 18; - const maxR = fertile ? 125 : 72; - for (let i = 0; i < attempts; i++) { - const angle = rand(0, Math.PI * 2); - const radius = rand(minR, maxR); - const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44); - const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44); - if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y }; - } - return null; - } - - performanceLevel() { - const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60; - if (!fps || !Number.isFinite(fps)) return 0; - if (fps < 22) return 3; - if (fps < 34) return 2; - if (fps < 48) return 1; - return 0; - } - - grassLoadLevel() { - const grass = this.itemCounts?.grass || 0; - const perf = this.performanceLevel(); - if (perf >= 3 || grass >= 180) return 3; - if (perf >= 2 || grass >= 120) return 2; - if (perf >= 1 || grass >= 72) return 1; - return 0; - } - - grassUpdateFactor() { - return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0; - } - - sortedDrawList() { - if (!this.drawList) this.drawList = []; - const antCount = (this.ants || []).filter(a => a && !a.dead).length; - const tarinaiCount = (this.tarinai || []).filter(t => t && !t.dead).length; - if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) { - this.drawList.length = 0; - for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t); - for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a); - this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0)); - this.drawListDirty = false; - } - return this.drawList; - } - - nearbyItems(x, y, radius) { - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch); - } - - nearbyTarinai(x, y, radius) { - return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch); - } - - rebuildSpatial() { - this.spatial.rebuild(this.items, this.tarinai); - } - - updateItemCounts() { - const counts = this.itemCounts; - for (const key of Object.keys(counts)) counts[key] = 0; - for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1; - } - - updateEffectCounts() { - const counts = this.effectCounts; - for (const key of Object.keys(counts)) counts[key] = 0; - for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1; - } - - compactItems() { - this.enforceItemBudget(); - const before = this.items.length; - let write = 0; - for (let read = 0; read < this.items.length; read++) { - const it = this.items[read]; - if (it.type === "mirror") continue; - if (!it.dead) this.items[write++] = it; - } - this.items.length = write; - return before !== this.items.length; - } - - enforceItemBudget() { - const pruneOldest = (type, limit) => { - if (!Number.isFinite(limit) || limit < 0) return; - let count = 0; - for (const it of this.items) if (it.type === type && !it.dead) count += 1; - if (count <= limit) return; - const victims = this.items - .filter(it => it.type === type && !it.dead) - .sort((a, b) => (b.age || 0) - (a.age || 0)) - .slice(0, count - limit); - for (const it of victims) it.amount = 0; - }; - pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56); - pruneOldest("trace", CONFIG.traceLimit ?? 64); - pruneOldest("splat", CONFIG.splatLimit ?? 48); - pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)); - const fenceLimit = this.fenceLimit ? this.fenceLimit() : (CONFIG.fenceLimit ?? 72); - const liveFences = this.liveFenceCount ? this.liveFenceCount() : 0; - if (liveFences > fenceLimit) { - const victims = this.items - .filter(it => this.isFenceType?.(it.type) && !it.dead) - .sort((a, b) => (b.age || 0) - (a.age || 0)) - .slice(0, liveFences - fenceLimit); - for (const it of victims) it.amount = 0; - } - - const limit = CONFIG.itemLimit ?? Infinity; - if (!Number.isFinite(limit) || this.items.length <= limit) return; - const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7, fence_v: 8, fence_h: 8 }; - const removable = this.items - .filter(it => !it.dead && (priority[it.type] || 99) < 99) - .sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0)); - let extra = this.items.length - limit; - for (const it of removable) { - if (extra <= 0) break; - it.amount = 0; - extra -= 1; - } - } - - compactEffects() { - const limit = CONFIG.effectLimit ?? 96; - if (Number.isFinite(limit) && this.effects.length > limit) { - this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001))); - const remove = this.effects.length - limit; - for (let i = 0; i < remove; i++) this.effects[i].life = 0; - } - let write = 0; - for (let read = 0; read < this.effects.length; read++) { - const ef = this.effects[read]; - if (!ef.dead) this.effects[write++] = ef; - } - this.effects.length = write; - } - - compactTarinai() { - let write = 0; - for (let read = 0; read < this.tarinai.length; read++) { - const t = this.tarinai[read]; - if (!t.dead) this.tarinai[write++] = t; - } - if (this.tarinai.length !== write) this.drawListDirty = true; - this.tarinai.length = write; - } - - compactAnts() { - if (!this.ants) this.ants = []; - let write = 0; - for (let read = 0; read < this.ants.length; read++) { - const a = this.ants[read]; - if (a && !a.dead) this.ants[write++] = a; - } - if (this.ants.length !== write) this.drawListDirty = true; - this.ants.length = write; - } - - updateAnts(dt) { - if (!this.ants) this.ants = []; - const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest"); - for (const nest of nests) { - if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); - if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6; - if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); - nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt); - const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; - const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); - const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0); - if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) { - const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32); - const a = new AntActor(this, { - kind: "worker", - homeId: nest.id, - x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18), - y: nest.y - nest.r * 1.00 + rand(-3, 3), - hp, - maxHp: ANT_WORKER_HP || 32, - }); - this.ants.push(a); - if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); - nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45; - this.drawListDirty = true; - } - } - for (const a of this.ants) a.update(dt); - } - - completeAntHaul(home, target) { - if (!home || home.dead || home.type !== "ant_nest" || !target || target.dead) return; - if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(home); - const before = typeof syncAntNestCount === "function" - ? syncAntNestCount(this, home) - : clamp(Math.round(home.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); - if (before >= (ANT_NEST_MAX_COUNT || 10)) { - this.spawnQueenAnt(home); - } else { - if (Array.isArray(home.antWorkers)) home.antWorkers.push(ANT_WORKER_HP || 32); - home.antCount = clamp(before + 1, 0, ANT_NEST_MAX_COUNT || 10); - if (typeof syncAntNestCount === "function") syncAntNestCount(this, home); - } - target.x = home.x; - target.y = home.y; - target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f"); - this.drawListDirty = true; - } - - spawnQueenAnt(home) { - if (!home || home.dead || home.type !== "ant_nest") return null; - if ((home.queenSpawnAt || -999) + (CONFIG.dayLength || 120) > (this.time || 0)) return null; - if ((this.ants || []).some(a => a && !a.dead && a.kind === "queen" && a.homeId === home.id)) return null; - const spot = this.findAntNestFoundingSpot(home) || { - x: clamp((home.x || this.w * 0.5) + rand(-220, 220), 56, this.w - 56), - y: clamp((home.y || this.h * 0.5) + rand(-160, 160), 56, this.h - 56), - }; - home.queenSpawnAt = this.time || 0; - const queen = new AntActor(this, { - kind: "queen", - homeId: home.id, - x: home.x, - y: home.y - (home.r || 34) * 0.9, - foundingX: spot.x, - foundingY: spot.y, - foundingDelayUntil: (this.time || 0) + 0.35, - }); - this.ants.push(queen); - this.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u5de3\u5834\u6240\u3092\u63a2\u3057\u59cb\u3081\u305f\u3002", "event"); - this.drawListDirty = true; - return queen; - } - - findAntNestFoundingSpot(source) { - const baseX = Number.isFinite(source?.x) ? source.x : this.w * 0.5; - const baseY = Number.isFinite(source?.y) ? source.y : this.h * 0.5; - let best = null; - let bestD = -Infinity; - for (let i = 0; i < 70; i++) { - const a = rand(0, Math.PI * 2); - const r = rand(150, Math.min(430, Math.max(this.w, this.h) * 0.52)); - const x = clamp(baseX + Math.cos(a) * r, 56, this.w - 56); - const y = clamp(baseY + Math.sin(a) * r * 0.74, 56, this.h - 56); - const nest = new Item("ant_nest", x, y); - const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y }; - if (!spot) continue; - let nearest = Infinity; - for (const it of this.items || []) { - if (!it || it.dead || it.type !== "ant_nest") continue; - nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y)); - } - if (nearest > bestD) { best = spot; bestD = nearest; } - if (nearest > 280) break; - } - return best; - } - - - environmentScores() { - const alive = this.tarinai || []; - const avgStress = alive.length ? alive.reduce((sum, t) => sum + (t.stress || 0), 0) / alive.length : 0; - let fightPressure = 0; - let diseaseCount = 0; - for (const t of alive) { - if (!t || t.dead) continue; - if (t.state === "fight") fightPressure += 16; - if (t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04 || (t.intimidatedTimer || 0) > 0.04) fightPressure += 7; - if ((t.hurtTimer || 0) > 0.04 || (t.lastBleedAt || -999) + 30 > (this.time || 0)) fightPressure += 5; - if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) diseaseCount += 1; - } - let blood = 0; - for (const ef of this.effects || []) if (ef && !ef.dead && (ef.type === "bleed" || ef.type === "splat")) blood += 1; - const zunchi = this.itemCounts?.zunchi || 0; - const splat = this.itemCounts?.splat || 0; - const trace = this.itemCounts?.trace || 0; - const antCorpse = this.itemCounts?.ant_corpse || 0; - const security = clamp(100 - fightPressure - blood * 1.6, 0, 100); - const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - antCorpse * 0.65 - blood * 1.8 - diseaseCount * 8.0, 0, 100); - const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100); - return { security, hygiene, happiness }; - } - - observeHighlightKind(t) { - const s = this.selected; - if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; - const tFamilyKey = this.tarinaiFamilyKey(t); - if ((s.parents || []).includes(tFamilyKey)) return "parent"; - if ((s.children || []).includes(tFamilyKey)) return "child"; - const rel = s.relationTo ? s.relationTo(t.id) : null; - if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend"; - if (rel && (rel.fear || 0) >= 8) return "enemy"; - return ""; - } - - nestBoxOccupants(box, limit = 5) { - if (!box || box.dead || box.type !== "nest_box") return []; - const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id); - return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; - } - - pointerNestBoxInfo() { - const p = this.pointer; - if (!p?.inside) return null; - let best = null, bestD = Infinity; - for (const it of this.nearbyItems(p.x, p.y, 140)) { - if (!it || it.dead || it.type !== "nest_box") continue; - 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 (!best) return null; - const occupants = this.nestBoxOccupants(best, Infinity); - const capacity = this.nestBoxCapacity(best); - return { box: best, occupants, capacity }; - } - - nestBoxToolTipCount() { - const pointed = this.pointerNestBoxInfo?.(); - if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) }; - const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box"); - if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() }; - let current = 0, max = 0; - for (const box of boxes) { - current += this.nestBoxOccupants(box, Infinity).length; - max += this.nestBoxCapacity(box); - } - return { current, max }; - } - - blastZunchiFrom(x, y, radius, strength = 1) { - let moved = 0; - for (const it of this.nearbyItems(x, y, radius + 60)) { - if (!it || it.dead || it.type !== "zunchi") continue; - let dx = it.x - x; - let dy = it.y - y; - let d = Math.hypot(dx, dy) || 1; - if (d > radius) continue; - if (d < 0.001) { - const a = rand(0, Math.PI * 2); - dx = Math.cos(a); dy = Math.sin(a); d = 1; - } - const p = clamp(1 - d / radius, 0, 1); - it.vx = (it.vx || 0) + dx / d * (130 + p * 430) * strength + rand(-45, 45); - it.vy = (it.vy || 0) + dy / d * (130 + p * 430) * strength + rand(-45, 45); - it.amount = Math.max(10, (it.amount || 80) - p * 8); - it.stage = "fresh"; - moved += 1; - if (moved < 12) 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) this.drawListDirty = true; - return moved; - } - - update(dt) { - if (this.paused) return; - dt *= this.speed; - dt = Math.min(dt, 0.12 * this.speed); - - this.time += dt; - const previousDay = this.day || 1; - this.day = Math.floor(this.time / CONFIG.dayLength) + 1; - if (this.day !== previousDay) { - for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {} }; - } - if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4); - this.updateWeather(dt); - this.rebuildSpatial(); - - const perf = this.performanceLevel(); - const itemCountBefore = this.items.length; - for (const it of this.items) it.update(dt, this); - if (this.itemCounts?.ball) { - this.rebuildSpatial(); - this.resolveBallBallCollisions(dt); - this.rebuildSpatial(); - } - this.updateAnts?.(dt); - - for (const ef of this.effects) ef.update(dt); - - for (const t of this.tarinai) { - t.update(dt); - } - this.rebuildSpatial(); - this.limitBallChasers(); - this.resolveBallInteractions(dt); - this.resolveTarinaiHighSpeedCollisions(dt); - - this.compactTimer += dt; - const compactInterval = perf >= 2 ? 0.95 : 0.58; - let itemsCompacted = false; - if (this.compactTimer >= compactInterval) { - itemsCompacted = this.compactItems(); - this.compactEffects(); - this.compactTarinai(); - this.compactAnts?.(); - this.compactTimer = 0; - } - - this.countsTimer += dt; - const countsInterval = perf >= 2 ? 0.85 : 0.42; - if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) { - this.updateItemCounts(); - this.updateEffectCounts(); - this.countsTimer = 0; - } - - if ((this.outOfBoundsCheckAt || -999) + 1.0 <= this.time) { - this.outOfBoundsCheckAt = this.time; - this.sanitizeOutOfBounds(); - } - - this.drawSortTimer += dt; - const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07; - if (this.drawSortTimer >= sortInterval) { - this.drawListDirty = true; - this.drawSortTimer = 0; - } - - const phase = this.phaseName(); - if (phase !== this.lastPhase) { - this.lastPhase = phase; - audio.phase(); - } - - if (this.tarinai.length > 0 && this.tarinai.length <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { - const spot = this.edgeSpawnPoint(null, null, 42, true); - const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 }); - t.wanderAngle = spot.angle; - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); - this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); - } - - - const grassCount = this.itemCounts.grass || 0; - if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) { - const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { - allowOriginal: true, - minRadius: 28, - maxRadius: 140, - attempts: 24, - }); - if (spot) { - const sprout = new Item("grass", spot.x, spot.y); - sprout.amount = rand(22, 48); - sprout.growth = rand(0.12, 0.30); - this.items.push(sprout); - this.itemCounts.grass = grassCount + 1; - } - } - - if (this.weather === "light_rain" && (this.itemCounts.water || 0) < 24 && Math.random() < dt * 0.12) { - const drop = new Item("water", rand(58, this.w - 58), rand(58, this.h - 58)); - drop.amount = rand(24, 58); - this.items.push(drop); - this.itemCounts.water = (this.itemCounts.water || 0) + 1; - } - - if (Math.random() < dt * 0.004) { - const entries = [ - "\u4f55\u5339\u304b\u304c\u3001\u5168\u54e1\u3067\u306f\u306a\u3044\u304c\u3001\u540c\u3058\u65b9\u5411\u3092\u898b\u305f\u3002", - "\u98df\u3079\u7269\u306f\u3042\u308b\u3002\u8db3\u308a\u3066\u3044\u308b\u304b\u306f\u5225\u554f\u984c\u3002", - "\u4e00\u5339\u304c\u7720\u308a\u3001\u8fd1\u304f\u306e\u5225\u306e\u4e00\u5339\u3082\u7720\u308b\u3053\u3068\u3092\u8003\u3048\u305f\u3002", - "\u8349\u306e\u8fd1\u304f\u306b\u3001\u77ed\u304f\u3066\u610f\u5473\u306e\u306a\u3044\u5217\u304c\u3067\u304d\u305f\u3002", - `${this.phaseName()}\u3002\u6c17\u5206\u306f\u3086\u3063\u304f\u308a\u5909\u308f\u3063\u3066\u3044\u308b\u3002` - ]; - this.log(pick(entries)); - } - } - - - sanitizeOutOfBounds() { - const pad = Math.max(28, CONFIG.worldPadding || 30); - const far = Math.max(360, Math.max(this.w || 1000, this.h || 720) * 0.75); - let changedItems = false; - const repairPoint = (obj, radius = 12) => { - if (!obj) return "delete"; - if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return "delete"; - const minX = pad - radius; - const maxX = (this.w || 1000) - pad + radius; - const minY = pad - radius; - const maxY = (this.h || 720) - pad + radius; - if (obj.x >= minX && obj.x <= maxX && obj.y >= minY && obj.y <= maxY) return "ok"; - if (obj.x < -far || obj.x > (this.w || 1000) + far || obj.y < -far || obj.y > (this.h || 720) + far) return "delete"; - obj.x = clamp(obj.x, pad, (this.w || 1000) - pad); - obj.y = clamp(obj.y, pad, (this.h || 720) - pad); - if (Number.isFinite(obj.vx)) obj.vx *= -0.18; - if (Number.isFinite(obj.vy)) obj.vy *= -0.18; - return "moved"; - }; - for (const it of this.items || []) { - const result = repairPoint(it, it.r || 12); - if (result === "delete") { it.amount = 0; changedItems = true; } - else if (result === "moved") changedItems = true; - } - for (const ef of this.effects || []) { - const result = repairPoint(ef, ef.size || 12); - if (result === "delete") ef.life = 0; - } - for (const t of this.tarinai || []) { - if (!t || t.dead) continue; - const result = repairPoint(t, t.radius || 22); - if (result === "delete") { - t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); - t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); - t.vx = 0; - t.vy = 0; - t.entryTimer = 0; - t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"); - } else if (result === "moved") { - t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); - t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); - t.vx = 0; - t.vy = 0; - t.entryTimer = 0; - } - } - let changedAnts = false; - for (const ant of this.ants || []) { - if (!ant || ant.dead) continue; - const result = repairPoint(ant, ant.kind === "queen" ? 18 : 10); - if (result === "delete") { - ant.dead = true; - changedAnts = true; - continue; - } - if (result === "moved") { - ant.vx = 0; - ant.vy = 0; - if (ant.state === "drag") { - ant.state = "return"; - ant.targetId = ""; - ant.targetToken = 0; - ant.targetRef = null; - } - changedAnts = true; - } - } - if (changedAnts) this.compactAnts?.(); - if (changedItems) { - this.compactItems(); - this.updateItemCounts(); - this.rebuildSpatial(); - } - } - - updateWeather(dt) { - this.nextWeatherChange -= dt; - if (this.nextWeatherChange > 0) return; - const current = this.weather || "sunny"; - const choices = current === "sunny" - ? ["sunny", "cloudy", "light_rain"] - : current === "cloudy" - ? ["sunny", "cloudy", "light_rain"] - : ["sunny", "cloudy"]; - const next = pick(choices); - this.weather = next; - this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); - if (next !== current) this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); - } - - fencePlacementBlocked(item) { - if (!item || !this.isFenceType(item.type)) return false; - const rect = this.fenceRect(item); - if (!rect) return false; - const margin = Math.max(6, (item.r || 42) * 0.24); - if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; - const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72; - for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { - if (!it || it.dead) continue; - for (const r of this.solidObstacleRects(it)) { - const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; - if (!separated) return true; - } - } - return false; - } - - importantPlacementOverlapBlocked(item) { - if (!item || item.dead) return true; - const important = new Set(["grass", "fence_v", "fence_h", "nest_box", "water_bowl"]); - if (!important.has(item.type)) return false; - const placingGrass = item.type === "grass"; - const itemRects = this.solidObstacleRects(item); - const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback); - const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12); - const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => { - if (!rect) return false; - const px = clamp(cx, rect.left - margin, rect.right + margin); - const py = clamp(cy, rect.top - margin, rect.bottom + margin); - return distXY(cx, cy, px, py) < radius + margin; - }; - const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom)); - for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) { - if (!other || other === item || other.dead || !important.has(other.type)) continue; - // Grass is a ground layer: objects may be placed on top of existing grass. - // Grass placement itself still avoids objects and other grass via grassSpotOpen(). - if (!placingGrass && other.type === "grass") continue; - const otherRects = this.solidObstacleRects(other); - const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12); - const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); - if (itemRects.length && otherRects.length) { - if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; - continue; - } - if (itemRects.length) { - if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true; - continue; - } - if (otherRects.length) { - if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true; - continue; - } - if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true; - } - return false; - } - - placementBlocked(item) { - if (!item || item.dead) return true; - if (item.type === "genkotsu") return false; - const pad = CONFIG.worldPadding || 30; - const rects = this.solidObstacleRects(item); - if (rects.length) { - for (const rect of rects) { - if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; - } - } else { - const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); - if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; - } - if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; - if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; - if (this.importantPlacementOverlapBlocked(item)) return true; - if (item.type === "nest_box") { - const margin = Math.max(8, (item.r || 42) * 0.18); - for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { - if (!it || it === item || it.dead) continue; - for (const rect of this.solidObstacleRects(it)) { - for (const own of rects) { - const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom; - if (!separated) return true; - } - } - } - } - return false; - } - - placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { - const pad = CONFIG.worldPadding || 30; - const rects = this.solidObstacleRects(item); - if (rects.length) { - let left = 0, right = 0, top = 0, bottom = 0; - for (const rect of rects) { - left = Math.max(left, (item.x || 0) - rect.left); - right = Math.max(right, rect.right - (item.x || 0)); - top = Math.max(top, (item.y || 0) - rect.top); - bottom = Math.max(bottom, rect.bottom - (item.y || 0)); - } - return { - x: clamp(x, pad + left, this.w - pad - right), - y: clamp(y, pad + top, this.h - pad - bottom), - }; - } - const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); - return { - x: clamp(x, pad + radius, this.w - pad - radius), - y: clamp(y, pad + radius, this.h - pad - radius), - }; - } - - placementProbeItem(item, x, y) { - return { ...item, x, y, dead: false }; - } - - findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) { - if (!item || item.dead) return null; - if (item.type === "grass") { - const spot = this.findGrassPlantingSpot - ? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true }) - : null; - if (!spot) return null; - const probe = this.placementProbeItem(item, spot.x, spot.y); - return this.placementBlocked(probe) ? null : spot; - } - - const start = this.placementClampPointFor(item, item.x, item.y); - const original = this.placementProbeItem(item, start.x, start.y); - if (allowOriginal && !this.placementBlocked(original)) return start; - - const solid = this.isFenceType(item.type) || item.type === "nest_box"; - const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); - const count = attempts || (solid ? 72 : 36); - const golden = Math.PI * (3 - Math.sqrt(5)); - let best = null; - let bestD = Infinity; - for (let i = 0; i < count; i++) { - const t = count <= 1 ? 1 : i / (count - 1); - const radius = Math.max(8, searchRadius * Math.sqrt(t)); - const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2; - const rawX = item.x + Math.cos(angle) * radius; - const rawY = item.y + Math.sin(angle) * radius * 0.74; - const p = this.placementClampPointFor(item, rawX, rawY); - const probe = this.placementProbeItem(item, p.x, p.y); - if (this.placementBlocked(probe)) continue; - const d = distXY(item.x, item.y, p.x, p.y); - if (d < bestD) { best = p; bestD = d; } - if (d < Math.max(18, (item.r || 12) * 0.75)) break; - } - return best; - } - - findGrabTargetAt(x, y) { - let foundT = null; - for (let i = this.tarinai.length - 1; i >= 0; i--) { - const t = this.tarinai[i]; - if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; - if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; } - } - let foundItem = null, foundItemD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (!it || it.dead || it.type === "trace" || it.type === "splat") continue; - const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2)); - const d = distXY(x, y, it.x, it.y); - if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; } - } - if (foundT && foundItem) { - const td = distXY(x, y, foundT.x, foundT.y); - return td <= foundItemD + 8 ? foundT : foundItem; - } - return foundT || foundItem; - } - - placeItem(item, dropped = true) { - if (!item) return null; - if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) { - showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002"); - return null; - } - if (this.isFenceType(item.type)) { - this.compactItems(); - this.updateItemCounts(); - if (this.liveFenceCount() >= this.fenceLimit()) { - showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); - return null; - } - } - if (this.placementBlocked(item)) { - showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002"); - return null; - } - if (dropped) { - item.dropMax = item.type === "genkotsu" ? 0.78 : (item.type === "stone" ? 0.72 : 0.58); - item.dropTimer = item.dropMax; - item.dropImpactDone = false; - } - this.items.push(item); - this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1; - this.rebuildSpatial(); - return item; - } - - handleClick(x, y) { - const tool = this.tool; - if (tool === "observe") { - let found = null; - for (let i = this.tarinai.length - 1; i >= 0; i--) { - if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } - } - this.selected = found; - return; - } - - if (tool === "poke") { - let ballTarget = null; - let ballTargetD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (!it || it.dead || it.type !== "ball") continue; - const hit = Math.max(72, (it.r || 18) * 4.0); - const d = distXY(x, y, it.x, it.y); - if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; } - } - if (ballTarget && this.pokeBall(ballTarget, x, y)) return; - - const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); - if (target) { - target.poke(); - if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); - } else { - this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002", "observe", { hiddenFromObservation: true, countInStats: false }); - } - return; - } - - if (tool === "new") { - const spot = this.edgeSpawnPoint(x, y, 42, true); - const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 }); - t.wanderAngle = spot.angle; - audio.birth(); - this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); - return; - } - - if (tool === "delete") { - let best = null, bestD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (it.dead) continue; - const visualHit = Math.max(22, (it.r || 12) * 2.0); - const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit; - const d = distXY(x, y, it.x, it.y); - if (d < hit && d < bestD) { best = it; bestD = d; } - } - if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } - best.amount = 0; - this.compactItems(); - this.updateItemCounts(); - this.rebuildSpatial(); - const label = toolLabel(best.type); - this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); - return; - } - - const itemType = toolItemType(tool); - if (itemType) { - let px = x, py = y; - const rawItem = this.applyToolSize(new Item(itemType, px, py)); - // Placement uses the same footprint that the preview validates. - const placed = this.placeItem(rawItem, true); - if (!placed) return; - const label = toolLabel(itemType); - this.log(itemType === "genkotsu" ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); - } - } - - inferLogKind(text) { - const s = String(text || "").toLowerCase(); - if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; - if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) return "fight"; - if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; - if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death"; - if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; - if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; - if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; - if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe"; - if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation"; - return "note"; - } - - shouldHideFromObservationRecords(entry = {}) { - const type = typeof entry === "string" ? "" : (entry.eventType || ""); - return type === "ball_poke" || type === "avoid_relationship"; - } - - log(text, kind = null, options = {}) { - const entry = { - time: this.time, - text, - kind: kind || this.inferLogKind(text), - eventType: options.eventType || "", - hiddenFromObservation: Boolean(options.hiddenFromObservation), - participants: (options.participants || []).filter(Boolean).map(t => ({ - id: t.id || t.releasedLiveId || "", - liveId: t.id || t.releasedLiveId || "", - liveToken: t.liveToken || t.releasedLiveToken || 0, - familyKey: this.tarinaiFamilyKey(t), - name: t.name || "", - type: t.type || "", - generation: t.generation || 1, - })), - participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id), - participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({ - liveId: t.id || t.releasedLiveId || "", - liveToken: t.liveToken || t.releasedLiveToken || 0, - familyKey: this.tarinaiFamilyKey(t), - name: t.name || "", - type: t.type || "", - generation: t.generation || 1, - })), - }; - if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; - const countInStats = options.countInStats ?? !entry.hiddenFromObservation; - entry.countInStats = Boolean(countInStats); - if (!this.eventCounters) this.eventCounters = {}; - if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; - for (const t of options.participants || []) { - if (!t) continue; - if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); - } - this.logs.unshift(entry); - this.logs = this.logs.slice(0, 100); - renderLog(this.logs); - } - } -let world; -try { - world = new World(); -} catch (error) { - if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error); - throw error; -} +if (typeof window !== "undefined") window.World = World; +if (typeof globalThis !== "undefined") globalThis.World = World; diff --git a/js/world_ants_system.js b/js/world_ants_system.js new file mode 100644 index 0000000..2b48aee --- /dev/null +++ b/js/world_ants_system.js @@ -0,0 +1,123 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_ants_system.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + updateAnts(dt) { + if (!this.ants) this.ants = []; + const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest"); + for (const nest of nests) { + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); + if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6; + if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); + nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt); + const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; + const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); + const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0); + if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) { + const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32); + const a = new AntActor(this, { + kind: "worker", + homeId: nest.id, + x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18), + y: nest.y - nest.r * 1.00 + rand(-3, 3), + hp, + maxHp: ANT_WORKER_HP || 32, + }); + this.ants.push(a); + this.emit?.("ant:spawned", { ant: a, nest }); + if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); + nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45; + this.drawListDirty = true; + } + } + const aliveAntCount = (this.ants || []).reduce((n, a) => n + (a && !a.dead ? 1 : 0), 0); + const q = this.performanceQuality ? this.performanceQuality() : null; + const stride = Math.max(1, Math.min(3, q?.antStride || (aliveAntCount >= 70 ? 3 : aliveAntCount >= 28 ? 2 : 1))); + this.antUpdatePhase = ((this.antUpdatePhase || 0) + 1) % stride; + for (let i = 0; i < this.ants.length; i++) { + const a = this.ants[i]; + if (!a || a.dead) continue; + a.updateAccum = (a.updateAccum || 0) + dt; + // Keep active hauling responsive, but stagger search/return/wander updates. + const force = a.state === "drag" || a.kind === "queen"; + if (stride <= 1 || force || ((i + this.antUpdatePhase) % stride === 0)) { + const antDt = Math.min(a.updateAccum, 0.18); + a.updateAccum = 0; + a.update(antDt); + } + } + }, + + completeAntHaul(home, target) { + if (!home || home.dead || home.type !== "ant_nest" || !target || target.dead) return; + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(home); + const before = typeof syncAntNestCount === "function" + ? syncAntNestCount(this, home) + : clamp(Math.round(home.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); + if (before >= (ANT_NEST_MAX_COUNT || 10)) { + this.spawnQueenAnt(home); + } else { + if (Array.isArray(home.antWorkers)) home.antWorkers.push(ANT_WORKER_HP || 32); + home.antCount = clamp(before + 1, 0, ANT_NEST_MAX_COUNT || 10); + if (typeof syncAntNestCount === "function") syncAntNestCount(this, home); + } + target.x = home.x; + target.y = home.y; + audio.antNest?.(); + target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f"); + this.emit?.("ant:carried", { nest: home, target, complete: true }); + this.drawListDirty = true; + }, + + spawnQueenAnt(home) { + if (!home || home.dead || home.type !== "ant_nest") return null; + if ((home.queenSpawnAt || -999) + (CONFIG.dayLength || 120) > (this.time || 0)) return null; + if ((this.ants || []).some(a => a && !a.dead && a.kind === "queen" && a.homeId === home.id)) return null; + const spot = this.findAntNestFoundingSpot(home) || { + x: clamp((home.x || this.w * 0.5) + rand(-220, 220), 56, this.w - 56), + y: clamp((home.y || this.h * 0.5) + rand(-160, 160), 56, this.h - 56), + }; + home.queenSpawnAt = this.time || 0; + const queen = new AntActor(this, { + kind: "queen", + homeId: home.id, + x: home.x, + y: home.y - (home.r || 34) * 0.9, + foundingX: spot.x, + foundingY: spot.y, + foundingDelayUntil: (this.time || 0) + 0.35, + }); + this.ants.push(queen); + this.emit?.("ant:spawned", { ant: queen, nest: home }); + audio.queenAnt?.(); + this.drawListDirty = true; + return queen; + }, + + findAntNestFoundingSpot(source) { + const baseX = Number.isFinite(source?.x) ? source.x : this.w * 0.5; + const baseY = Number.isFinite(source?.y) ? source.y : this.h * 0.5; + let best = null; + let bestD = -Infinity; + for (let i = 0; i < 70; i++) { + const a = rand(0, Math.PI * 2); + const r = rand(150, Math.min(430, Math.max(this.w, this.h) * 0.52)); + const x = clamp(baseX + Math.cos(a) * r, 56, this.w - 56); + const y = clamp(baseY + Math.sin(a) * r * 0.74, 56, this.h - 56); + const nest = new Item("ant_nest", x, y); + const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y }; + if (!spot) continue; + let nearest = Infinity; + for (const it of this.items || []) { + if (!it || it.dead || it.type !== "ant_nest") continue; + nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y)); + } + if (nearest > bestD) { best = spot; bestD = nearest; } + if (nearest > 280) break; + } + return best; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_bootstrap.js b/js/world_bootstrap.js new file mode 100644 index 0000000..b447f7b --- /dev/null +++ b/js/world_bootstrap.js @@ -0,0 +1,11 @@ +"use strict"; + +let world; +try { + world = new World(); + if (typeof window !== "undefined") window.world = world; + if (typeof globalThis !== "undefined") globalThis.world = world; +} catch (error) { + if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error); + throw error; +} diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js new file mode 100644 index 0000000..3fd8e60 --- /dev/null +++ b/js/world_combat_effects.js @@ -0,0 +1,978 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_combat_effects.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) { + let hit = 0; + for (const ant of this.ants || []) { + if (!ant || ant.dead) continue; + const d = Math.max(1, distXY(ant.x, ant.y, x, y)); + if (d > radius + (ant.r || 4)) continue; + const p = clamp(1 - d / Math.max(1, radius), 0, 1); + if (p <= 0 && d > radius) continue; + const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0); + if (damage > 0) { + ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage); + ant.hpBarTimer = Math.max(ant.hpBarTimer || 0, 1.25); + } + if (opts.push) { + let nx = (ant.x - x) / d; + let ny = (ant.y - y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const push = typeof opts.push === "function" ? opts.push(p, ant, d) : Number(opts.push || 0); + ant.vx = (ant.vx || 0) + nx * push; + ant.vy = (ant.vy || 0) + ny * push; + ant.x = clamp(ant.x + nx * Math.min(18, push * 0.018), CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ant.y = clamp(ant.y + ny * Math.min(18, push * 0.018), CONFIG.worldPadding, this.h - CONFIG.worldPadding); + } + hit += 1; + if (ant.hp <= 0 && ant.dieAsCorpse) ant.dieAsCorpse(ant.homeNest?.()); + } + if (hit > 0) this.drawListDirty = true; + return hit; + }, + + damageStructuresInRadius(x, y, radius, damageFn, breaker = null) { + let hit = 0; + for (const structure of this.items || []) { + if (!structure || structure.dead || !structure.isStructure) continue; + const d = Math.max(1, distXY(structure.x, structure.y, x, y)); + if (d > radius + (structure.r || 18)) continue; + const p = clamp(1 - d / Math.max(1, radius), 0, 1); + if (p <= 0 && d > radius) continue; + const damage = typeof damageFn === "function" ? damageFn(p, structure, d) : Number(damageFn || 0); + if (damage <= 0) continue; + if (typeof structure.damage === "function") structure.damage(damage, breaker, this); + else if (window.TarinaiNeedSystem?.damageStructure) window.TarinaiNeedSystem.damageStructure.call(structure, damage, breaker, this); + else { + structure.hp = Math.max(0, Number(structure.hp || 0) - damage); + if (structure.hp <= 0) structure.dead = true; + } + hit += 1; + } + if (hit > 0) { + this.drawListDirty = true; + this.markTerrainDirty?.("structure-damage"); + } + return hit; + }, + + explodeFirecracker(it) { + if (!it || it.dead || it.type !== "firecracker") return; + it.amount = 0; + const x = it.x, y = it.y; + 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.effects.push(new Effect("explosion", x, y, { size: 128, life: 0.82, color: "rgba(255,92,42,0.70)" })); + 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.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1); + for (let i = 0; i < 34; i++) { + const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); + const speed = rand(90, 260); + this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { + vx: Math.cos(a) * speed, + vy: Math.sin(a) * speed, + size: rand(8, i % 3 === 0 ? 24 : 20), + life: rand(0.26, 0.72), + color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", + })); + } + for (const t of this.tarinai) { + if (t.dead) continue; + const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed"; + t.sleeping = false; + if (wasSleeping) { + if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); + else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } + } + t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); + t.fearTimer = Math.max(t.fearTimer, 0.58); + const dx = t.x - x; + const dy = t.y - y; + const d = Math.hypot(dx, dy) || 1; + if (d > blastRadius) continue; + const p = clamp(1 - d / blastRadius, 0, 1); + t.damage(5 + p * 18, "\u7206\u7af9"); + t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); + t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); + t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); + if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); + else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); } + let nx = dx / d; + let ny = dy / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const distanceBoost = p * p * 0.55 + p * 0.45; + const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); + this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { + panic: true, + target: { x, y }, + fearTimer: 1.7 + p, + thought: "爆竹の爆風でパニックになっている", + }); + t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); + t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); + t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); + this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); + const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; + if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) t.infectExplosionDisease?.(it); + if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + } + this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { + push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), + }); + this.damageStructuresInRadius(x, y, blastRadius, p => 35 + p * 90, it); + let blastedBalls = 0; + for (const ball of this.items) { + if (!ball || ball.dead || ball.type !== "ball") continue; + let dx = ball.x - x; + let dy = ball.y - y; + let d = Math.hypot(dx, dy) || 1; + if (d > blastRadius * 1.18) continue; + const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); + if (d < 0.001) { + const a = rand(0, Math.PI * 2); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const blast = 430 + p * 960; + this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); + const speed = Math.hypot(ball.vx || 0, ball.vy || 0); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); + ball.lastPokedAt = this.time || 0; + 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)" })); + blastedBalls += 1; + } + audio.explode(); + this.log(blastedBalls ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u30dc\u30fc\u30eb\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8fba\u308a\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); + }, + + explodeDiseaseTarinai(t) { + if (!t || t.dead) return; + const x = t.x, y = t.y; + const blastScale = 0.68; + const blastRadius = 240 * blastScale; + t.explosionDisease = false; + 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.effects.push(new Effect("explosion", x, y, { size: 128 * blastScale, life: 0.82, color: "rgba(255,92,42,0.70)" })); + 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.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale); + for (let i = 0; i < 34; i++) { + const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); + const speed = rand(90, 260) * blastScale; + this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22) * blastScale, y + Math.sin(a) * rand(2, 22) * blastScale, { + vx: Math.cos(a) * speed, + vy: Math.sin(a) * speed, + size: rand(8, i % 3 === 0 ? 24 : 20) * blastScale, + life: rand(0.26, 0.72), + color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", + })); + } + for (const o of this.tarinai) { + if (!o || o.dead || o === t) continue; + const dx = o.x - x; + const dy = o.y - y; + const d = Math.hypot(dx, dy) || 1; + if (d > blastRadius) continue; + const q = clamp(1 - d / blastRadius, 0, 1); + o.damage(5 + q * 18, "\u7206\u767a\u75c5"); + o.addStress ? o.addStress(22 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 22 * q, 0, 130)); + let nx = dx / d; + let ny = dy / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + const distanceBoost = q * q * 0.55 + q * 0.45; + const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * blastScale; + this.applyImpulse(o, nx * power + rand(-95, 95) * (0.6 + q), ny * power + rand(-95, 95) * (0.6 + q), { + panic: true, + target: { x, y }, + fearTimer: 1.7 + q, + thought: "爆発病の爆発に巻き込まれている", + }); + o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8); + o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85); + if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" }); + else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); } + o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); + o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); + o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); + o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70); + o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer); + this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q); + const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; + if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance)) o.infectExplosionDisease?.(t); + if (!o.dead && Math.random() < 0.45) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + } + this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { + push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, + }); + this.damageStructuresInRadius(x, y, blastRadius, q => 28 + q * 76, t); + for (const ball of this.items) { + if (!ball || ball.dead || ball.type !== "ball") continue; + let dx = ball.x - x; + let dy = ball.y - y; + let d = Math.hypot(dx, dy) || 1; + if (d > blastRadius * 1.18) continue; + const q = clamp(1 - d / (blastRadius * 1.18), 0, 1); + if (d < 0.001) { + const a = rand(0, Math.PI * 2); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const blast = 430 + q * 960; + this.applyImpulse(ball, dx / d * blast * blastScale + rand(-80, 80), dy / d * blast * blastScale + rand(-80, 80)); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); + ball.lastPokedAt = this.time || 0; + 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)" })); + } + t.die("\u7206\u767a\u75c5"); + audio.explode(); + this.log(`${t.name}\u306f\u7206\u767a\u75c5\u3067\u7206\u767a\u3057\u305f\u3002`, "accident", { participants: [t] }); + }, + + applyWaterHose(x, y, dx = 0, dy = 0, dt = 0.08) { + const radius = 96; + const speed = Math.hypot(dx, dy) || 1; + const nx = speed > 1 ? dx / speed : Math.cos(this.time * 9.1); + const ny = speed > 1 ? dy / speed : Math.sin(this.time * 7.7); + let cleaned = 0; + for (const it of this.nearbyItems(x, y, radius + 80)) { + if (!it || it.dead) continue; + const d = distXY(it.x, it.y, x, y); + if (d > radius + (it.r || 12)) continue; + const p = clamp(1 - d / (radius + (it.r || 12)), 0, 1); + if (it.type === "zunchi" || it.type === "splat" || it.type === "trace") { + it.amount -= dt * (it.type === "zunchi" ? 230 : 180) * (0.35 + p); + cleaned += p; + } + } + for (const ef of this.effects || []) { + if (!ef || ef.dead) continue; + if (ef.type !== "bleed" && ef.type !== "splat") continue; + const d = distXY(ef.x || 0, ef.y || 0, x, y); + if (d > radius + 22) continue; + const p = clamp(1 - d / (radius + 22), 0, 1); + ef.life = Math.min(ef.life || 0.1, Math.max(0.01, (ef.life || 0.1) - dt * (1.6 + p * 5.0))); + cleaned += p * 0.55; + } + for (const t of this.nearbyTarinai(x, y, radius + 70)) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + const d = distXY(t.x, t.y, x, y); + if (d > radius + t.radius) continue; + const p = clamp(1 - d / (radius + t.radius), 0, 1); + t.vx += nx * (95 + 165 * p) + rand(-10, 10); + t.vy += ny * (95 + 165 * p) + rand(-10, 10); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); + t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose"); + } + if (Math.random() < 0.72) { + this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" })); + } + if (cleaned > 0.25) { + this.drawListDirty = true; + this.updateItemCounts(); + } + }, + + spawnBurstZunchi(source, count = 1, pin = null) { + const n = Math.max(1, Math.min(28, Math.floor(count || 1))); + const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5); + const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5); + for (let i = 0; i < n; i++) { + const a = -Math.PI * 0.15 + rand(-0.92, 0.92) + (i / Math.max(1, n - 1) - 0.5) * 1.2; + const it = new Item("zunchi", clamp(sx + rand(-8, 8), 40, this.w - 40), clamp(sy + rand(-8, 8), 40, this.h - 40)); + it.amount = 240; + it.vx = Math.cos(a) * rand(260, 560) + rand(-40, 40); + it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160); + it.stage = "fresh"; + it.burstFromOshibyo = true; + this.items.push(it); + this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + } + this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); + this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] }); + audio.place?.("zunchi"); + this.rebuildSpatial?.(true); + }, + + spawnZunchi(x, y) { + const zunchiLimit = CONFIG.zunchiLimit ?? 56; + if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { + let oldest = null; + for (const it of this.items) { + if (it.type !== "zunchi" || it.dead) continue; + if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it; + } + if (oldest) oldest.amount = 0; + } + const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); + it.amount = 260; + this.items.push(it); + this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + audio.place?.("zunchi"); + }, + + spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") { + if ((this.effectCounts.bubble || 0) >= CONFIG.bubbleLimit) return; + if (text === "z") text = "Zzz..."; + audio.bubble(text); + this.effects.push(new Effect("bubble", x, y, { + vx: rand(-2, 2), + vy: rand(-5, -2), + life: rand(2.2, 3.2), + size: 8, + text, + color, + })); + this.effectCounts.bubble = (this.effectCounts.bubble || 0) + 1; + }, + + spawnHeadbuttEffect(x, y) { + this.effects.push(new Effect("fight", x, y, { + size: rand(10, 16), + life: rand(0.20, 0.34), + color: "rgba(105, 62, 34, 0.86)", + })); + if (Math.random() < 0.5) audio.fight(); + }, + + spawnFallEffect(x, y, scale = 1) { + if ((this.effectCounts.fall || 0) > 18) return; + this.effects.push(new Effect("fall", x, y, { + vx: rand(-12, 12), + vy: rand(-4, 3), + size: rand(20, 32) * scale, + life: rand(0.48, 0.72), + color: "rgba(154, 124, 80, 0.58)", + })); + }, + + spawnEatEffect(x, y, color = "#f1dfb7") { + if (Math.random() < 0.70) { + this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { + vx: rand(-10, 10), + vy: rand(-18, -3), + size: rand(2.2, 4.2), + life: rand(0.18, 0.32), + color, + })); + } + if (Math.random() < 0.08) { + this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); + } + }, + + spawnBleedEffect(x, y) { + this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { + vx: rand(-8, 8), + vy: rand(3, 14), + size: rand(2.0, 3.8), + life: rand(0.34, 0.62), + color: "rgba(80, 172, 55, 0.78)", + })); + }, + + isSleepFurniture(it) { + return Boolean(it && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box"))); + }, + + isTarinaiHiddenInNestBox(t) { + return Boolean(t && !t.dead && t.insideNestBoxId); + }, + + bedOccupancy(bed) { + if (!this.isSleepFurniture(bed)) return 0; + if (bed.type === "nest_box") { + return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 0; + } + let n = 0; + const radius = Math.max(86, bed.r * 2.45); + for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { + if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; + } + return n; + }, + + bedComfort(bed) { + if (!this.isSleepFurniture(bed)) return 0.7; + const occ = this.bedOccupancy(bed); + return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); + }, + + bestBedFor(t, maxDist = 360) { + const choose = (kind) => { + let best = null; + let bestScore = Infinity; + for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { + if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue; + if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; + if (occupants.length >= this.nestBoxCapacity(bed)) continue; + } + if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; + const d = dist(t, bed); + const occ = this.bedOccupancy(bed); + const comfort = this.bedComfort(bed); + const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34; + const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty; + if (score < bestScore) { best = bed; bestScore = score; } + } + return best; + }; + // Prefer an available nest box, then fall back to a hay bed. + return choose("nest_box") || choose("bed"); + }, + + relationNotice(aId, bId, kind, cooldown = 30) { + const key = [aId, bId].sort().join(":") + `:${kind}`; + const last = this.relationNotices?.[key] || -Infinity; + if (this.time - last < cooldown) return false; + if (!this.relationNotices) this.relationNotices = {}; + this.relationNotices[key] = this.time; + return true; + }, + + applyImpulse(entity, vx = 0, vy = 0, options = {}) { + if (!entity || entity.dead) return false; + if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; + if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { + entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; + entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; + } else { + entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx; + entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; + } + if (options.panic && entity instanceof Tarinai) { + if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" }); + else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); } + } + return true; + }, + + applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { + if (!entity || !Number.isFinite(strength)) return false; + let dx = (entity.x || 0) - sourceX; + let dy = (entity.y || 0) - sourceY; + let d = Math.hypot(dx, dy); + if (!Number.isFinite(d) || d < 0.001) { + const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); + dx = Math.cos(a); + dy = Math.sin(a); + d = 1; + } + const random = options.random || 0; + return this.applyImpulse( + entity, + dx / d * strength + rand(-random, random), + dy / d * strength + rand(-random, random), + options, + ); + }, + + applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { + if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; + const normalized = options.cause || cause || "\u885d\u7a81"; + this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null }); + if (target.damage) target.damage(amount, normalized); + else if (Number.isFinite(target.hp)) target.hp -= amount; + return true; + }, + + maybeDefeatFromFightDamage(victim, attacker, damage = 0) { + if (!victim || !attacker || victim.dead || attacker.dead || victim.id === attacker.id) return false; + if ((victim.fightTimer || 0) <= 0.04 || (attacker.fightTimer || 0) <= 0.04) return false; + if (victim.defeatedById || (victim.defeatedTimer || 0) > 0.04) return false; + const profile = victim.personalityProfile ? victim.personalityProfile() : { fight: 1 }; + const aggressionHold = clamp((profile.fight || 1) - 1, -0.8, 1.3); + const healthRisk = clamp((42 - (victim.energy || 0)) / 42, 0, 1.25); + const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4); + const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8); + const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78); + if (Math.random() >= chance) return false; + victim.defeatedById = attacker.id; + victim.fightWinnerId = attacker.id; + attacker.fightWinnerId = attacker.id; + attacker.defeatedById = null; + victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); + attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42)); + this.emit?.("fight:lost", { winner: attacker, loser: victim, damage }); + if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" }); + else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } + const dx = victim.x - attacker.x; + const dy = victim.y - attacker.y; + const d = Math.hypot(dx, dy) || 1; + victim.vx += dx / d * rand(38, 76); + victim.vy += dy / d * rand(22, 56); + this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + return true; + }, + + resolveFightOutcome(winner, loser) { + if (!winner || !loser || winner.dead || loser.dead) return; + const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; + if (!this.resolvedFightIds) this.resolvedFightIds = {}; + if (this.resolvedFightIds[key]) return; + this.resolvedFightIds[key] = true; + winner.defeatedById = null; + winner.defeatedTimer = 0; + winner.fearTimer = Math.max(0, winner.fearTimer - 0.75); + winner.fightTargetIds = []; + winner.fightTargetId = null; + winner.adjustRelation(loser, -0.6, 0.02, "\u55a7\u5629\u306b\u52dd\u3063\u305f"); + loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f"); + winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1; + loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1; + winner.totalFightWins = (winner.totalFightWins || 0) + 1; + loser.totalFightLosses = (loser.totalFightLosses || 0) + 1; + this.emit?.("fight:ended", { winner, loser }); + winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); + loser.adjustPersonality?.("aggression", -0.018, "after losing fights."); + this.maybeApplyZunchiSlaveByFightRecord?.(winner, loser); + this.maybeApplyZunchiSlaveByFightRecord?.(loser, winner); + loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); + this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + winner.affection = clamp(winner.affection + 0.8, 0, 80); + winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); + if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { + this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); + } + }, + + maybeApplyZunchiSlaveByFightRecord(tarinai, other = null) { + if (!tarinai || tarinai.dead || tarinai.isZunchiSlave) return false; + const wins = Number(tarinai.totalFightWins || 0); + const losses = Number(tarinai.totalFightLosses || 0); + const total = wins + losses; + if (total < 5) return false; + const winRate = total > 0 ? wins / total : 0; + if (winRate > 0.30) return false; + const oldName = tarinai.formerName || tarinai.name; + if (!tarinai.becomeZunchiSlave?.({ fightRecord: true, locked: true })) return false; + this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); + this.log?.(`${oldName}\u306f\u55a7\u5629\u30925\u56de\u7d4c\u9a13\u3057\u3001\u52dd\u738750%\u4ee5\u4e0b\u3067\u305a\u3093\u3061\u3069\u308c\u3044\u306b\u306a\u3063\u305f\u3002`, "fight", { participants: other ? [tarinai, other] : [tarinai] }); + return true; + }, + + itemDropImpact(it) { + if (!it || it.dead || it.type === "water") return; + const isStone = it.type === "stone"; + const isGenkotsu = it.type === "genkotsu"; + const radius = isGenkotsu ? Math.max(164, (it.r || 88) * 2.05) : (isStone ? 64 : Math.max(34, (it.r || 14) * 1.75)); + const damage = isGenkotsu ? rand(38, 58) : (isStone ? rand(20, 34) : rand(3, 7)); + const push = isGenkotsu ? rand(760, 1040) : (isStone ? rand(145, 210) : rand(54, 92)); + const reason = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); + if (isGenkotsu) { it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); } + let hit = 0; + for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); + const p = clamp(1 - d / (radius + t.radius), 0, 1); + if (p <= 0) continue; + let nx = (t.x - it.x) / d; + let ny = (t.y - it.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const a = rand(0, Math.PI * 2); + nx = Math.cos(a); + ny = Math.sin(a); + } + t.vx += nx * push * (0.35 + p); + t.vy += ny * push * (0.35 + p) - (isGenkotsu ? 190 : (isStone ? 42 : 18)) * p; + const dealtDamage = damage * (0.35 + p * 0.65); + t.damage(dealtDamage, reason); + if (isGenkotsu) { + t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); + t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); + t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); + t.thought = "げんこつがこわい"; + } else { + t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している"; + } + t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); + t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); + t.fallMax = Math.max(t.fallMax || t.fallTimer, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + hit += 1; + } + const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { + push: p => push * (0.62 + p * 1.25), + }); + const structureHit = this.damageStructuresInRadius(it.x, it.y, radius + 54, p => damage * (0.45 + p * 0.85), it); + if (isGenkotsu) { + this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75); + for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); + for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); + } else { + this.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)" })); + } + if (hit > 0 || antHit > 0 || structureHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45)); + if (isGenkotsu) audio.genkotsuImpact?.(); + else if (isStone) audio.stoneImpact?.(); + else if (hit > 0 || antHit > 0) audio.poke?.(); + else audio.drop?.(); + if (hit > 0 || antHit > 0) { + const label = isGenkotsu ? "\u3052\u3093\u3053\u3064" : (isStone ? "\u77f3" : "\u9053\u5177"); + const victims = []; + if (hit > 0) victims.push(`${hit}\u5339\u306e\u305f\u308a\u306a\u3044`); + if (antHit > 0) victims.push(`${antHit}\u5339\u306e\u30a2\u30ea`); + this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident"); + } + }, + + countBallChasers(ball, except = null) { + if (!ball) return 0; + let count = 0; + for (const t of this.tarinai || []) { + if (!t || t === except || t.dead) continue; + if (t.state === "play_ball" && t.target === ball) count += 1; + } + return count; + }, + + limitBallChasers() { + if (!this.itemCounts?.ball) return; + const groups = new Map(); + for (const t of this.tarinai || []) { + if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue; + const arr = groups.get(t.target) || []; + arr.push(t); + groups.set(t.target, arr); + } + for (const [ball, arr] of groups) { + if (arr.length <= 5) continue; + arr.sort((a, b) => { + const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; + const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; + return dist(a, ball) + ap - (dist(b, ball) + bp); + }); + for (const t of arr.slice(5)) { + t.goIdle("ボールが混み合っているので眺めている"); + t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); + } + } + }, + + pokeBall(ball, x, y) { + if (!ball || ball.dead || ball.type !== "ball") return false; + const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0); + let dx = ball.x - x; + let dy = ball.y - y; + let d = Math.hypot(dx, dy); + if (d < 0.001) { + const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55); + dx = Math.cos(a); + dy = Math.sin(a); + d = 1; + } + const nx = dx / d; + const ny = dy / d; + const recent = now - (ball.lastPokedAt || -999) < 0.82; + ball.pokeCombo = recent ? Math.min(10, (ball.pokeCombo || 0) + 1) : 1; + ball.lastPokedAt = now; + ball.lastPokeAngle = Math.atan2(ny, nx); + const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24); + ball.vx = (ball.vx || 0) + nx * impulse; + ball.vy = (ball.vy || 0) + ny * impulse; + const speed = Math.hypot(ball.vx || 0, ball.vy || 0); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38); + ball.amount = Math.max(ball.amount || 999, 999); + 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: "rgba(255,255,255,0.58)" })); + this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true }); + return true; + }, + + resolveBallBallCollisions(dt) { + if ((this.itemCounts?.ball || 0) < 2) return; + const now = this.time || 0; + if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); + const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball"); + for (const a of balls) { + const ar = a.r || 18; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; + for (const b of this.nearbyItems(a.x, a.y, range) || []) { + if (!b || b === a || b.dead || b.type !== "ball") continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue; + const br = b.r || 18; + const hitRadius = ar + br; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d = Math.hypot(dx, dy); + let hitX = a.x; + let hitY = a.y; + if (d > hitRadius) { + const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x; + const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y; + const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x; + const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y; + const rvx = (a.x - ax0) - (b.x - bx0); + const rvy = (a.y - ay0) - (b.y - by0); + const rx = ax0 - bx0; + const ry = ay0 - by0; + const aa = rvx * rvx + rvy * rvy; + if (aa <= 0.0001) continue; + const bb = 2 * (rx * rvx + ry * rvy); + const cc = rx * rx + ry * ry - hitRadius * hitRadius; + const disc = bb * bb - 4 * aa * cc; + if (disc < 0) continue; + const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1); + const axHit = ax0 + (a.x - ax0) * u; + const ayHit = ay0 + (a.y - ay0) * u; + const bxHit = bx0 + (b.x - bx0) * u; + const byHit = by0 + (b.y - by0) * u; + dx = bxHit - axHit; + dy = byHit - ayHit; + d = Math.hypot(dx, dy); + hitX = (axHit + bxHit) * 0.5; + hitY = (ayHit + byHit) * 0.5; + if (d > hitRadius + 0.5) continue; + } else { + hitX = (a.x + b.x) * 0.5; + hitY = (a.y + b.y) * 0.5; + } + if (d < 0.001) { + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const rs = Math.hypot(rvx, rvy); + if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } + else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } + } + const nx = dx / d; + const ny = dy / d; + const rvx = (b.vx || 0) - (a.vx || 0); + const rvy = (b.vy || 0) - (a.vy || 0); + const relNormal = rvx * nx + rvy * ny; + if (relNormal > 0 && d >= hitRadius - 0.5) continue; + this.ballCollisionMemo.set(key, now); + const restitution = 0.86; + const impulse = -(1 + restitution) * relNormal / 2; + if (Number.isFinite(impulse)) { + a.vx = (a.vx || 0) - impulse * nx; + a.vy = (a.vy || 0) - impulse * ny; + b.vx = (b.vx || 0) + impulse * nx; + b.vy = (b.vy || 0) + impulse * ny; + } + const overlap = Math.max(0, hitRadius - d + 0.6); + if (overlap > 0) { + a.x -= nx * overlap * 0.5; + a.y -= ny * overlap * 0.5; + b.x += nx * overlap * 0.5; + b.y += ny * overlap * 0.5; + } + const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0)); + a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38); + b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38); + this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" })); + } + } + }, + + resolveBallInteractions(dt) { + if (!this.itemCounts?.ball) return; + for (const ball of this.items) { + if (!ball || ball.dead || ball.type !== "ball") continue; + const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; + const range = (ball.r || 18) + 72 + sweep; + const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x; + const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y; + const sx = ball.x - px; + const sy = ball.y - py; + const segLenSq = sx * sx + sy * sy; + for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { + if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; + let hitX = ball.x; + let hitY = ball.y; + if (segLenSq > 1) { + const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1); + hitX = px + sx * u; + hitY = py + sy * u; + } + let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y)); + const hitDistance = (ball.r || 18) + t.radius * 0.74; + if (d > hitDistance) continue; + const now = this.time || 0; + const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; + if (repeat && ballSpeed < 300) continue; + let nx = (hitX - t.x) / d; + let ny = (hitY - t.y) / d; + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + if (ballSpeed > 0.01) { + nx = (ball.vx || 0) / ballSpeed; + ny = (ball.vy || 0) / ballSpeed; + } else { + nx = t.facingDir ? t.facingDir() : 1; + ny = rand(-0.22, 0.22); + } + } + + const isDangerous = ballSpeed >= 320; + if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { + const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; + const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; + const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); + t.lastBallDamageAt = now; + this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { + panic: true, + target: { x: ball.x, y: ball.y }, + fearTimer: 0.95, + thought: "高速のボールにぶつかってパニックになっている", + }); + this.applyImpactDamage(t, damage, "\u885d\u7a81"); + 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); + t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); + if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); + else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true }); + t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); + t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); + t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); + t.fallDir = bvx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer, 0.95); + t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44); + t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); + this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8)); + this.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)" })); + if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [t] }); + ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); + ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); + continue; + } + + const relVx = (t.vx || 0) - (ball.vx || 0); + const relVy = (t.vy || 0) - (ball.vy || 0); + const relativeSpeed = Math.hypot(relVx, relVy); + const playIntent = t.state === "play_ball" || t.target === ball; + const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); + const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); + ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; + ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; + const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); + ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding); + ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding); + ball.lastKickedAt = now; + ball.lastKickerId = t.id; + t.vx -= nx * Math.min(22, impulse * 0.10); + t.vy -= ny * Math.min(22, impulse * 0.10); + t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); + const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; + const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; + t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); + if (playful && playIntent) { + t.loneliness = clamp(t.loneliness - 0.9, 0, 100); + t.affection = clamp(t.affection + 0.18, 0, 100); + } + t.goodMode = playIntent || playful ? "smile" : t.goodMode; + if (playIntent && now > (t.nextPlayBubbleAt || 0)) { + t.nextPlayBubbleAt = now + rand(2.0, 3.8); + this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); + } + if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); + this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); + } + } + }, + + resolveTarinaiHighSpeedCollisions(dt) { + const threshold = 150; + const now = this.time || 0; + if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); + for (const a of this.tarinai || []) { + if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; + const avx = a.vx || 0; + const avy = a.vy || 0; + const aSpeed = Math.hypot(avx, avy); + if (aSpeed < threshold) continue; + const px = Number.isFinite(a.prevX) ? a.prevX : a.x; + const py = Number.isFinite(a.prevY) ? a.prevY : a.y; + const sx = a.x - px; + const sy = a.y - py; + const segLenSq = sx * sx + sy * sy; + const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; + for (const b of this.nearbyTarinai(a.x, a.y, range)) { + if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; + const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; + if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; + const bvx = b.vx || 0; + const bvy = b.vy || 0; + const relVx = avx - bvx; + const relVy = avy - bvy; + const relSpeed = Math.hypot(relVx, relVy); + if (relSpeed < threshold) continue; + let hitX = a.x; + let hitY = a.y; + if (segLenSq > 1) { + const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); + hitX = px + sx * u; + hitY = py + sy * u; + } + const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); + const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; + if (d > hitDistance) continue; + this.tarinaiCollisionMemo.set(key, now); + const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; + const target = impactor === a ? b : a; + const ivx = impactor.vx || 0; + const ivy = impactor.vy || 0; + const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1; + let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); + let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y)); + if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { + const aa = rand(0, Math.PI * 2); + nx = Math.cos(aa); + ny = Math.sin(aa); + } + const damage = clamp((relSpeed - 118) / 26, 1.2, 22); + this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { + panic: true, + target: { x: impactor.x, y: impactor.y }, + fearTimer: 0.55, + thought: "高速でぶつかって混乱している", + }); + impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); + impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); + this.applyImpactDamage(target, damage, "\u885d\u7a81"); + if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); + for (const t of [target, impactor]) { + if (!t || t.dead) continue; + if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); + else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true }); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); + t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); + t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); + t.fallDir = nx >= 0 ? 1 : -1; + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + } + this.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)" })); + if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.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] }); + } + } + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_environment.js b/js/world_environment.js new file mode 100644 index 0000000..21c63c2 --- /dev/null +++ b/js/world_environment.js @@ -0,0 +1,317 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_environment.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + nearest(entity, types, maxDist = Infinity) { + let best = null, bestD = maxDist; + const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; + for (const it of candidates) { + if (it.dead || !types.includes(it.type)) continue; + if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue; + const d = dist(entity, it); + if (d < bestD) { best = it; bestD = d; } + } + return best; + }, + + nearestOther(entity, maxDist = Infinity, predicate = null) { + let best = null, bestD = maxDist; + const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; + for (const o of candidates) { + if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue; + if (predicate && !predicate(o)) continue; + if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; + const d = dist(entity, o); + if (d < bestD) { best = o; bestD = d; } + } + return best; + }, + + temperatureAt(x, y) { + let temp = 0.54 + Math.sin(this.time / 55) * 0.05; + for (const it of this.nearbyItems(x, y, 190)) { + if (it.type === "stone") { + const d = distXY(x, y, it.x, it.y); + temp += clamp(1 - d / 190, 0, 1) * 0.35; + } + } + return clamp(temp, 0, 1); + }, + + fenceRect(it) { + const vertical = it?.type === "fence_v"; + const horizontal = it?.type === "fence_h"; + if (!vertical && !horizontal) return null; + const len = Math.max(112, (it.r || 42) * 3.55); + const thick = Math.max(10, (it.r || 42) * 0.31); + const halfW = vertical ? thick / 2 : len / 2; + const halfH = vertical ? len / 2 : thick / 2; + return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal }; + }, + + nestBoxCapacity() { + return 5; + }, + + nestBoxBaseRect(it) { + if (!it || it.dead || it.type !== "nest_box") return null; + const r = it.r || 42; + return { + left: it.x - r * 1.28, + right: it.x + r * 1.28, + top: it.y - r * 0.80, + bottom: it.y + r * 0.72, + type: "nest_box", + item: it, + }; + }, + + nestBoxSolidRects(it) { + const base = this.nestBoxBaseRect(it); + if (!base) return []; + const w = base.right - base.left; + const h = base.bottom - base.top; + const colW = w / 3; + const rowH = h / 3; + const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it }); + return [ + mk(base.left, base.right, base.top, base.top + rowH, "top"), + mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"), + mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"), + ]; + }, + + nestBoxTopRect(it) { + return this.nestBoxSolidRects(it)[0] || null; + }, + + nestBoxEntryPoint(box) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const r = box.r || 42; + // Move the approach point to the center of the open middle cell. The + // collision resolver also ignores this nest box for active sleepers near + // the door, so tarinai can cross the threshold instead of sliding off posts. + return { + x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18), + }; + }, + + nestBoxExitPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return this.nestBoxEntryPoint(box); + const r = box.r || 42; + const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; + return { + x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding), + }; + }, + + nestBoxInnerPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : []; + let index = occupants.indexOf(occupant); + if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); + const r = box.r || 42; + const slots = [ + [0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16], + ]; + const slot = slots[index % slots.length]; + return { + x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), + y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), + }; + }, + + solidObstacleRects(it) { + if (!it || it.dead) return []; + if (this.isFenceType(it.type)) { + const rect = this.fenceRect(it); + return rect ? [{ ...rect, type: it.type, item: it }] : []; + } + if (it.type === "nest_box") return this.nestBoxSolidRects(it); + return []; + }, + + shouldIgnoreNestBoxCollisionFor(t, box) { + if (!t || !box || box.dead || box.type !== "nest_box") return false; + if (t.insideNestBoxId) return true; + if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false; + const entry = this.nestBoxEntryPoint(box); + const base = this.nestBoxBaseRect(box); + const r = box.r || 42; + const entryD = distXY(t.x, t.y, entry.x, entry.y); + const centerD = distXY(t.x, t.y, box.x, box.y); + const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0); + const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58; + return Boolean(aroundDoor || inDoorColumn); + }, + + nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { + const rects = []; + let checked = 0; + for (const it of this.nearbyItems(x, y, radius)) { + if (!it || it === exclude || it.dead) continue; + if (include && !include(it)) continue; + const partRects = this.solidObstacleRects(it); + if (!partRects.length) continue; + checked += 1; + for (const rect of partRects) rects.push(rect); + if (checked >= maxChecks) break; + } + return rects; + }, + + pointInRect(x, y, r, padding = 0) { + return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding); + }, + + pushTarinaiOutOfRect(t, r, rr) { + if (!t || !r) return false; + const cx = clamp(t.x, r.left, r.right); + const cy = clamp(t.y, r.top, r.bottom); + let dx = t.x - cx; + let dy = t.y - cy; + let d = Math.hypot(dx, dy); + if (d >= rr) return false; + if (d < 0.001) { + const left = Math.abs(t.x - r.left); + const right = Math.abs(r.right - t.x); + const top = Math.abs(t.y - r.top); + const bottom = Math.abs(r.bottom - t.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; d = 1; } + else if (m === right) { dx = 1; dy = 0; d = 1; } + else if (m === top) { dx = 0; dy = -1; d = 1; } + else { dx = 0; dy = 1; d = 1; } + } + const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); + t.x += dx / d * push; + t.y += dy / d * push; + if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; + else t.vy *= -0.18; + return true; + }, + + resolveSolidObstacleCollision(t) { + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; + const rr = Math.max(8, t.radius * 0.74); + let pushed = false; + for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { + if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; + if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true; + } + if (pushed) { + const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); + t.x = clamp(t.x, pad, this.w - pad); + t.y = clamp(t.y, pad, this.h - pad); + t.vx = clamp(t.vx || 0, -130, 130); + t.vy = clamp(t.vy || 0, -130, 130); + } + }, + + resolveFenceCollision(t) { + this.resolveSolidObstacleCollision(t); + }, + + segmentIntersectsRect(x1, y1, x2, y2, r) { + if (!r) return false; + if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true; + const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => { + const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px); + return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); + }; + return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top) + || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom) + || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom) + || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); + }, + + pathBlockedByFence(x1, y1, x2, y2, padding = 16) { + const cx = (x1 + x2) / 2; + const cy = (y1 + y2) / 2; + const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; + for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) { + const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; + if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; + } + return false; + }, + + grassBlockedAt(x, y, self = null) { + for (const it of this.nearbyItems(x, y, 96)) { + if (it === self || it.dead) continue; + for (const r of this.solidObstacleRects(it)) { + if (this.pointInRect(x, y, r, 10)) return true; + } + if (it.type !== "zunchi") continue; + const dx = (x - it.x) / Math.max(22, it.r * 1.35); + const dy = (y - it.y) / Math.max(12, it.r * 0.82); + if (dx * dx + dy * dy < 1) return true; + } + return false; + }, + + grassCrowdedAt(x, y, minGap = 30, self = null) { + for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) { + if (it === self || it.dead || it.type !== "grass") continue; + if (distXY(x, y, it.x, it.y) < minGap) return true; + } + return false; + }, + + grassOnTarinaiAt(x, y, minGap = 24) { + for (const t of this.nearbyTarinai(x, y, minGap + 36)) { + if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; + } + return false; + }, + + grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) { + if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false; + if (this.grassBlockedAt(x, y, self)) return false; + if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false; + if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false; + return true; + }, + + findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) { + const cx = clamp(x, 44, this.w - 44); + const cy = clamp(y, 44, this.h - 44); + if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy }; + const blockedAtCenter = this.grassBlockedAt(cx, cy); + const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius; + const outer = Math.max(maxRadius, inner + 42); + const golden = Math.PI * (3 - Math.sqrt(5)); + for (let i = 0; i < attempts; i++) { + const t = attempts <= 1 ? 1 : i / (attempts - 1); + const radius = lerp(inner, outer, Math.sqrt(t)); + const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2; + const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44); + const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44); + if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py }; + } + return null; + }, + + findGrassSproutSpot(source, fertile = false) { + const attempts = fertile ? 24 : 10; + const minR = fertile ? Math.max(30, source.r * 1.9) : 18; + const maxR = fertile ? 125 : 72; + for (let i = 0; i < attempts; i++) { + const angle = rand(0, Math.PI * 2); + const radius = rand(minR, maxR); + const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44); + const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44); + if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y }; + } + return null; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_family_social.js b/js/world_family_social.js new file mode 100644 index 0000000..c210a5a --- /dev/null +++ b/js/world_family_social.js @@ -0,0 +1,758 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_family_social.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + reset(seedPopulation = null, fieldType = this.fieldType || "garden") { + const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden; + this.fieldType = field.id; + this.fieldZoom = 1; + this.setViewportSize(this.viewportW, this.viewportH); + this.time = 0; + this.day = 1; + this.paused = false; + this.speed = 1; + this.toolSize = this.toolSize || "medium"; + this.toolSizes = this.toolSizes || {}; + this.tarinai = []; + this.items = []; + this.ants = []; + this.effects = []; + this.logs = []; + this.events = window.TarinaiEvents || null; + this.eventCounters = {}; + this.countsTimer = 0; + this.compactTimer = 0; + this.drawSortTimer = 0; + this.drawListDirty = true; + this.antUpdatePhase = 0; + this.selected = null; + this.deadCount = 0; + this.liveIdNext = 1; + this.liveIdFree = []; + this.liveIdSerial = 0; + this.liveTarinai = new Map(); + this.lastBirthAt = -999; + this.maxGeneration = 1; + this.family = {}; + this.familyVersion = 0; + this.relationNotices = {}; + this.resolvedFightIds = {}; + this.foodSpoilagePenalty = 0; + this.foodSpoilageEvents = 0; + this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 }; + this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2); + this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2); + this.shakeTimer = 0; + this.shakeDuration = 0; + this.shakeStrength = 0; + this.clampCamera(); + this.lastPhase = this.phaseName(); + this.weather = "sunny"; + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation; + const grassCount = field.grass ?? 18; + const types = spawnableSprites().map(s => s.id); + for (let i = 0; i < seedPopulation; i++) { + this.addTarinai({ + type: types[i % types.length], + x: rand(80, this.w - 80), + y: rand(80, this.h - 80), + generation: 1, + }); + } + for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); + this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); + this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22)); + this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78)); + this.updateItemCounts(); + this.updateEffectCounts(); + this.markTerrainDirty?.("reset"); + this.rebuildSpatial(true); + showToast("\u65b0\u3057\u3044\u89b3\u5bdf\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002"); + syncTopButtons(); + }, + + tarinaiFamilyKey(t) { + return t?.familyKey || t?.archiveKey || t?.id || ""; + }, + + assignTarinaiLiveId(t) { + if (!t) return ""; + if (!this.liveTarinai) this.liveTarinai = new Map(); + // Live IDs are never recycled. Relationships and transient targets are keyed by + // live ID, so reuse can make dead friends/enemies leak onto new individuals. + this.liveIdFree = []; + if (!Number.isFinite(this.liveIdNext)) this.liveIdNext = 1; + if (!Number.isFinite(this.liveIdSerial)) this.liveIdSerial = 0; + const id = `t${this.liveIdNext++}`; + t.id = id; + t.liveToken = ++this.liveIdSerial; + this.liveTarinai.set(id, { token: t.liveToken, target: t }); + return id; + }, + + releaseTarinaiLiveId(t) { + if (!t?.id || !this.liveTarinai) return false; + const current = this.liveTarinai.get(t.id); + if (!current || current.target !== t || current.token !== t.liveToken) return false; + const id = t.id; + this.liveTarinai.delete(id); + // Do not recycle live IDs. Relationships are keyed by live ID; recycling can + // make a dead friend's/enemy's relationship appear on a newly spawned individual. + t.releasedLiveId = id; + t.id = ""; + t.liveToken = 0; + return true; + }, + + liveTarinaiById(id, token = null) { + if (!id || !this.liveTarinai) return null; + const entry = this.liveTarinai.get(id); + if (!entry || (token !== null && entry.token !== token)) return null; + const t = entry.target; + return t && !t.dead ? t : null; + }, + + recordFamily(t) { + if (!this.family) this.family = {}; + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey) return; + const uniqueIds = (list) => Array.from(new Set((list || []).filter(Boolean))); + const sameList = (a, b) => { + const aa = uniqueIds(a); + const bb = uniqueIds(b); + return aa.length === bb.length && aa.every((id, i) => id === bb[i]); + }; + let changed = false; + const nextParents = uniqueIds(t.parents || []); + const nextChildren = uniqueIds(t.children || []); + const nextPersonalityTags = getPersonalityTraitTags(t); + const nextScale = Number.isFinite(t.scale) ? t.scale : (Number.isFinite(t.adultScale) ? t.adultScale : 0.28); + const nextAdultScale = Number.isFinite(t.adultScale) ? t.adultScale : nextScale; + const nextGrowth = Number.isFinite(t.growth) ? t.growth : 1; + if (!this.family[familyKey]) { + this.family[familyKey] = { + id: familyKey, + name: t.name, + type: t.type, + personalityTags: nextPersonalityTags, + generation: t.generation, + hasPaired: !!t.hasPaired, + parents: nextParents, + parentNames: [...(t.parentNames || [])], + children: nextChildren, + birthTime: t.birthTime, + scale: nextScale, + adultScale: nextAdultScale, + growth: nextGrowth, + alive: !t.dead, + deathReason: t.deathReason || "", + }; + changed = true; + } else { + const entry = this.family[familyKey]; + const nextAlive = !t.dead; + if (entry.name !== t.name) changed = true; + if (entry.type !== t.type) changed = true; + if (!sameList(entry.personalityTags, nextPersonalityTags)) changed = true; + if (entry.hasPaired !== !!t.hasPaired) changed = true; + if (entry.generation !== t.generation) changed = true; + if (Math.abs((Number(entry.scale) || 0) - nextScale) > 0.002) changed = true; + if (Math.abs((Number(entry.adultScale) || 0) - nextAdultScale) > 0.002) changed = true; + if (Math.abs((Number(entry.growth) || 0) - nextGrowth) > 0.02) changed = true; + if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; + if (entry.alive !== nextAlive) changed = true; + if (!sameList(entry.parents, nextParents)) changed = true; + if (!sameList(entry.children, nextChildren)) changed = true; + entry.name = t.name; + entry.type = t.type; + delete entry.personality; + entry.personalityTags = nextPersonalityTags; + entry.alive = nextAlive; + entry.generation = t.generation; + entry.scale = nextScale; + entry.adultScale = nextAdultScale; + entry.growth = nextGrowth; + entry.hasPaired = !!t.hasPaired; + entry.deathReason = t.deathReason || entry.deathReason || ""; + entry.parents = nextParents; + entry.parentNames = [...(t.parentNames || entry.parentNames || [])]; + entry.children = nextChildren; + } + if (changed) { + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-record"); + } + }, + + markFamilyTreeDirty(reason = "family") { + this.familyTreeDirty = true; + this.familyTreeDirtyReason = reason; + this.familyTreeDirtyAt = this.time || 0; + }, + + normalizeFamily() { + if (!this.family) this.family = {}; + if (this.familyCleanVersion === (this.familyVersion || 0)) return false; + const family = this.family; + const liveById = new Map((this.tarinai || []).map(t => [this.tarinaiFamilyKey(t), t]).filter(([id]) => id)); + let changed = false; + const uniqueIds = (list, allow) => Array.from(new Set((list || []).filter(id => id && (!allow || allow.has(id))))); + const sameList = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]); + for (const t of this.tarinai || []) { + const familyKey = this.tarinaiFamilyKey(t); + if (!familyKey || (!this.family[familyKey] && !(t.hasPaired || (t.parents || []).length || (t.children || []).length))) continue; + const before = this.familyVersion || 0; + this.recordFamily(t); + changed = changed || before !== (this.familyVersion || 0); + } + const known = new Set(Object.keys(family)); + for (const id of Object.keys(family)) { + const n = family[id] || {}; + const live = liveById.get(id); + const parents = uniqueIds(n.parents, known); + const children = uniqueIds(n.children, known); + if (live) { + const liveParents = uniqueIds(live.parents, known); + const liveChildren = uniqueIds(live.children, known); + for (const p of liveParents) if (!parents.includes(p)) parents.push(p); + for (const c of liveChildren) if (!children.includes(c)) children.push(c); + } + const next = { + id, + name: live?.name || n.name || id, + type: live?.type || n.type || "smile", + personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])], + generation: Math.max(1, live?.generation || n.generation || 1), + hasPaired: !!(live?.hasPaired || n.hasPaired), + parents, + parentNames: live?.parentNames ? [...live.parentNames] : [...(n.parentNames || [])], + children, + birthTime: live?.birthTime ?? n.birthTime ?? 0, + scale: Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : (Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : 0.28))), + adultScale: Number.isFinite(live?.adultScale) ? live.adultScale : (Number.isFinite(n.adultScale) ? n.adultScale : (Number.isFinite(live?.scale) ? live.scale : (Number.isFinite(n.scale) ? n.scale : 0.28))), + growth: Number.isFinite(live?.growth) ? live.growth : (Number.isFinite(n.growth) ? n.growth : 1), + alive: live ? !live.dead : n.alive !== false, + deathReason: live?.deathReason || n.deathReason || "", + }; + for (const key of Object.keys(next)) { + const a = family[id]?.[key]; + const b = next[key]; + const equal = Array.isArray(a) && Array.isArray(b) ? sameList(a, b) : a === b; + if (!equal) changed = true; + } + family[id] = next; + } + for (const child of Object.values(family)) { + for (const parentId of child.parents || []) { + const parent = family[parentId]; + if (!parent) continue; + if (!parent.children.includes(child.id)) { + parent.children.push(child.id); + changed = true; + } + } + } + for (const parent of Object.values(family)) { + const nextChildren = uniqueIds(parent.children, known).filter(childId => family[childId]?.parents?.includes(parent.id)); + if (!sameList(parent.children || [], nextChildren)) { + parent.children = nextChildren; + changed = true; + } + } + for (const id of Object.keys(family)) { + const n = family[id]; + if (!n?.hasPaired && !(n?.parents || []).length && !(n?.children || []).length) { + delete family[id]; + changed = true; + } + } + for (const [id, live] of liveById.entries()) { + const n = family[id]; + if (!n) continue; + if (!sameList(live.parents || [], n.parents || [])) { + live.parents = [...(n.parents || [])]; + changed = true; + } + if (!sameList(live.children || [], n.children || [])) { + live.children = [...(n.children || [])]; + changed = true; + } + if (live.hasPaired !== !!n.hasPaired) { + live.hasPaired = !!n.hasPaired; + changed = true; + } + if (live.generation !== n.generation) { + live.generation = n.generation; + changed = true; + } + } + if (changed) { + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-normalize"); + } + this.familyCleanVersion = this.familyVersion || 0; + return changed; + }, + + resetFamilyTree() { + this.family = {}; + this.familyCleanVersion = 0; + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-reset"); + this.maxGeneration = 1; + for (const t of this.tarinai || []) { + t.parents = []; + t.parentNames = []; + t.children = []; + t.generation = 1; + t.hasPaired = false; + } + }, + + validateFamily() { + this.normalizeFamily(); + const family = this.family || {}; + const issues = []; + const seenRenderIds = new Set(); + for (const n of Object.values(family)) { + if (!n?.id) continue; + const childSeen = new Set(); + for (const p of n.parents || []) { + if (!family[p]) issues.push({ type: "missing-parent", id: n.id, parent: p }); + else if (!family[p].children?.includes(n.id)) issues.push({ type: "missing-reciprocal-child", id: n.id, parent: p }); + } + for (const c of n.children || []) { + if (childSeen.has(c)) issues.push({ type: "duplicate-child", id: n.id, child: c }); + childSeen.add(c); + if (!family[c]) issues.push({ type: "missing-child", id: n.id, child: c }); + else if (!family[c].parents?.includes(n.id)) issues.push({ type: "missing-reciprocal-parent", id: n.id, child: c }); + } + if (seenRenderIds.has(n.id)) issues.push({ type: "duplicate-render-node", id: n.id }); + seenRenderIds.add(n.id); + } + return issues; + }, + + clearLiveReferencesTo(dead) { + const deadId = dead?.id || dead?.releasedLiveId || ""; + if (!deadId) return false; + let changed = false; + for (const other of this.tarinai || []) { + if (!other || other === dead || other.dead) continue; + if (other.target === dead || other.target?.id === deadId) { + other.target = null; + if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった"); + changed = true; + } + if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } + if (other.fightTargetId === deadId) { other.fightTargetId = null; changed = true; } + if (Array.isArray(other.fightTargetIds) && other.fightTargetIds.includes(deadId)) { + other.fightTargetIds = other.fightTargetIds.filter(id => id !== deadId); + changed = true; + } + for (const key of ["defeatedById", "fightWinnerId", "intimidateTargetId", "birthPartnerId"]) { + if (other[key] === deadId) { other[key] = null; changed = true; } + } + } + return changed; + }, + + markDead(t, reason) { + this.clearLiveReferencesTo(t); + if (!this.family) this.family = {}; + const finalReason = t?.normalizeDeathReason ? t.normalizeDeathReason(reason) : reason; + const familyKey = this.tarinaiFamilyKey(t); + if (familyKey && this.family[familyKey]) { + if (this.family[familyKey].alive !== false || this.family[familyKey].deathReason !== finalReason) { + this.family[familyKey].alive = false; + this.family[familyKey].deathReason = finalReason; + delete this.family[familyKey].personality; + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("death"); + } + } + this.notifyDeathToRelations?.(t, finalReason); + this.familyPrunePending = true; + }, + + pruneExtinctFamilies(opts = {}) { + if (opts.normalize !== false) this.normalizeFamily(); + const family = this.family || {}; + const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); + if (!nodes.length) return; + const components = globalThis.TarinaiFamilyGraph?.connectedComponents + ? globalThis.TarinaiFamilyGraph.connectedComponents(nodes) + : []; + const remove = components.length + ? components.filter(comp => !comp.some(n => family[n.id]?.alive)).flat().map(n => n.id) + : []; + if (!remove.length) return; + for (const id of remove) delete family[id]; + this.familyVersion = (this.familyVersion || 0) + 1; + this.markFamilyTreeDirty("family-prune"); + this.familyCleanVersion = this.familyVersion || 0; + }, + + addTarinai(opts = {}) { + const t = new Tarinai(this, opts); + this.assignTarinaiLiveId(t); + this.tarinai.push(t); + this.drawListDirty = true; + if (t.hasPaired || (t.parents || []).length || (t.children || []).length) this.recordFamily(t); + this.maxGeneration = Math.max(this.maxGeneration, t.generation); + this.emit?.("tarinai:born", { tarinai: t, source: opts.parents?.length ? "family" : "spawn" }); + return t; + }, + + startBirthRitual(a, b) { + if (!a || !b || a.dead || b.dead) return false; + if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false; + if (this.areParentChild(a, b)) return false; + if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false; + if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; + const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); + const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length); + if (aJoined && !bJoined) b.generation = a.generation; + else if (bJoined && !aJoined) a.generation = b.generation; + else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; } + // Do not mark either parent as part of a family during the ritual. + // The archive should only gain nodes after a child has actually been created; + // otherwise childless ritual participants appear as isolated family trees. + const duration = 10.0; + a.birthRitualTimer = b.birthRitualTimer = duration; + a.birthRitualMax = b.birthRitualMax = duration; + a.birthPartnerId = b.id; + b.birthPartnerId = a.id; + a.birthRitualRole = -1; + b.birthRitualRole = 1; + a.birthRitualLeader = true; + b.birthRitualLeader = false; + a.setActionState("birth_ritual", { target: b, reason: "近くで体をゆすっている" }); + b.setActionState("birth_ritual", { target: a, reason: "近くで体をゆすっている" }); + a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); + b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); + a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); + b.surpriseTimer = Math.max(b.surpriseTimer, 0.35); + return true; + }, + + finishBirthRitual(a, b) { + if (!a || !b || a.dead || b.dead) return null; + const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; + a.birthRitualTimer = b.birthRitualTimer = 0; + a.birthPartnerId = b.birthPartnerId = null; + a.birthRitualLeader = b.birthRitualLeader = false; + a.goIdle("誕生の前ぶれが終わった"); + b.goIdle("誕生の前ぶれが終わった"); + if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) { + a.birthRitualRole = b.birthRitualRole = 0; + return null; + } + a.fightTimer = b.fightTimer = 0; + a.fightTargetIds = []; b.fightTargetIds = []; + a.fightTargetId = b.fightTargetId = null; + a.defeatedById = b.defeatedById = null; + a.postBirthPeaceTimer = b.postBirthPeaceTimer = 18; + a.fightCooldown = b.fightCooldown = Math.max(CONFIG.fightCooldown + 8, a.fightCooldown || 0, b.fightCooldown || 0); + const children = []; + const countRoll = Math.random(); + const childCount = countRoll < 0.08 ? 3 : (countRoll < 0.36 ? 2 : 1); + for (let i = 0; i < childCount; i++) { + const child = this.spawnChild(a, b, i, childCount); + if (child) { child.postBirthPeaceTimer = 18; children.push(child); } + } + if (children.length > 0) this.lastBirthAt = this.time; + if (children.length > 1) this.log(`${a.name}\u3068${b.name}\u306e\u5b50\u304c${children.length}\u5339\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [a, b, ...children] }); + return children[0] || null; + }, + + areParentChild(a, b) { + if (!a || !b) return false; + const aId = this.tarinaiFamilyKey(a) || a; + const bId = this.tarinaiFamilyKey(b) || b; + if (!aId || !bId) return false; + return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId)); + }, + + areCoParents(a, b) { + if (!a || !b) return false; + const aLiveId = a.id || a; + const bLiveId = b.id || b; + if (!aLiveId || !bLiveId || aLiveId === bLiveId) return false; + if (a.birthPartnerId && a.birthPartnerId === bLiveId) return true; + if (b.birthPartnerId && b.birthPartnerId === aLiveId) return true; + const aId = this.tarinaiFamilyKey(a) || aLiveId; + const bId = this.tarinaiFamilyKey(b) || bLiveId; + if (!aId || !bId || aId === bId) return false; + const aChildren = new Set((a.children || []).filter(Boolean)); + if (!aChildren.size) return false; + for (const id of (b.children || [])) { + if (aChildren.has(id)) return true; + } + return false; + }, + + canStartFight(t) { + if (!t || t.dead) return false; + if (t.sleepDisease) return false; + // Disease fight rule: explosion, zunchi, and fight disease can lash out; + // sleep disease cannot fight or be challenged, and other diseases cannot start fights. + if (t.explosionDisease || t.zunchiDisease || t.fightDisease) return true; + const flag = (value) => typeof value === "function" ? Boolean(value.call(t)) : Boolean(value); + const hasOtherDisease = flag(t.disease) || flag(t.sick) || flag(t.infection) || flag(t.hasDisease); + if (hasOtherDisease) return false; + return true; + }, + + canBeFightTarget(t) { + return Boolean(t && !t.dead && !t.sleepDisease); + }, + + canFightPair(actor, target) { + return this.canStartFight(actor) && this.canBeFightTarget(target); + }, + + spawnChild(a, b, index = 0, total = 1) { + const zunchiSlaveChild = !!(a.isZunchiSlave && b.isZunchiSlave); + const inherited = Math.random() < 0.56 ? a.type : b.type; + const mutation = zunchiSlaveChild ? "zunchi_slave" : (Math.random() < 0.18 ? baseSpriteId() : inherited); + const childSeed = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}-${index}`; + const childGenetics = inheritedGenetics(a, b, childSeed); + const childAdultScale = adultScaleFromGenetics(childSeed, childGenetics); + const childBirthPersonality = zunchiSlaveChild ? zunchiSlavePersonalityValue() : personalityFromParents(a, b); + const child = this.addTarinai({ + familyKey: childSeed, + type: mutation, + birthPersonality: childBirthPersonality, + currentPersonality: childBirthPersonality, + genetics: childGenetics, + adultScale: childAdultScale, + name: zunchiSlaveChild ? "\u305a\u3093\u3061\u3069\u308c\u3044" : makeName(), + isZunchiSlave: zunchiSlaveChild, + age: 0, + x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, + y: (a.y + b.y) / 2 + rand(-14, 14) + (total > 1 ? rand(-10, 10) : 0), + scale: scaleForGrowth(childAdultScale, 0), + hunger: rand(20, 40), + loneliness: rand(30, 56), + energy: rand(55, 95), + stress: rand(0, 14), + need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), + generation: Math.max(a.generation, b.generation) + 1, + lifeSpan: rand(1500, 2200) * (childGenetics.lifeSpanMul || 1), + parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)], + parentNames: [a.name, b.name], + birthTime: this.time, + powerItemMode: "", + sizeItemMode: "", + lifeItemMode: "", + }); + if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true }); + this.maxGeneration = Math.max(this.maxGeneration, child.generation); + a.hasPaired = true; + b.hasPaired = true; + a.affection = 0; b.affection = 0; + a.loneliness *= 0.65; b.loneliness *= 0.65; + a.adjustRelation?.(b, 0.65, -0.20, "co_parent"); + b.adjustRelation?.(a, 0.65, -0.20, "co_parent"); + a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28); + b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28); + const childKey = this.tarinaiFamilyKey(child); + const aKey = this.tarinaiFamilyKey(a); + const bKey = this.tarinaiFamilyKey(b); + a.children.push(childKey); + b.children.push(childKey); + this.recordFamily(a); + this.recordFamily(b); + this.recordFamily(child); + if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey); + if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey); + this.familyCleanVersion = null; + audio.birth(); + this.lastBirthAt = this.time; + if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] }); + return child; + }, + + startForcedFight(a, b) { + if (!a || !b || a === b || a.dead || b.dead) return false; + if (!this.canFightPair(a, b)) return false; + const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id); + if (alreadyFighting) return true; + for (const t of [a, b]) { + t.birthRitualTimer = 0; + t.birthRitualMax = 0; + t.birthPartnerId = null; + t.birthRitualLeader = false; + t.birthRitualRole = 0; + t.postBirthPeaceTimer = 0; + t.fightCooldown = 0; + t.intimidateTimer = 0; + t.intimidateTargetId = null; + t.intimidatedTimer = 0; + t.defeatedTimer = 0; + t.defeatedById = null; + t.fightWinnerId = null; + t.goIdle(""); + } + this.startFightMochiIntimidation(a, b); + this.startFight(a, b); + return true; + }, + + startFightMochiIntimidation(a, b) { + if (!a || !b || a.dead || b.dead) return false; + const aDrug = (a.fightMochiTimer || 0) > 0.04; + const bDrug = (b.fightMochiTimer || 0) > 0.04; + const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); + const target = actor === a ? b : a; + if (!this.canFightPair(actor, target)) return false; + actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); + actor.intimidateTargetId = target.id; + actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25)); + target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear); + actor.adjustRelation(target, -0.05, 0.06, "intimidate"); + target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate"); + this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { + life: 0.34, + size: actor.radius * 0.62, + color: "rgba(154, 88, 42, 0.74)", + })); + this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "!", "rgba(120,62,38,0.82)"); + return true; + }, + + startConflict(a, b) { + if (!a || !b || a.dead || b.dead) return; + if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; } + if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; + if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; + const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const actor = scoreA >= scoreB ? a : b; + const target = actor === a ? b : a; + if (!this.canFightPair(actor, target)) return; + const actorScore = actor === a ? scoreA : scoreB; + const targetScore = actor === a ? scoreB : scoreA; + const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); + const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04; + const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90); + if (canIntimidate && Math.random() < intimidationChance) { + this.startIntimidation(actor, target, actorScore, targetScore); + return; + } + this.startFight(a, b); + }, + + startIntimidation(actor, target, actorScore = 0, targetScore = 0) { + if (!actor || !target || actor.dead || target.dead) return false; + if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; + const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; + const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0); + const targetAggression = Math.max(0, target.currentPersonality?.aggression || 0); + const actorSize = actor.effectiveScale ? actor.effectiveScale() : (actor.scale || 0.28); + const targetSize = target.effectiveScale ? target.effectiveScale() : (target.scale || 0.28); + const sizeEdge = clamp((actorSize - targetSize) / 0.16, -1, 1); + const aggressionEdge = clamp(actorAggression - targetAggression * 0.55, -1, 1); + const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + rand(-5, 5); + const successChance = clamp(0.56 + (actorScore - targetScore + fearLoad - resistance) / 82 + aggressionEdge * 0.13 + sizeEdge * 0.16, 0.30, 0.96); + actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); + target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); + actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); + target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); + actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); + actor.intimidateTargetId = target.id; + actor.setActionState("intimidate", { target, reason: "相手を威嚇している" }); + this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { + life: 0.42, + size: actor.radius * 0.55, + 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)"); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45)); + target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); + if (Math.random() >= successChance) { + actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation"); + target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); + actor.intimidateTimer = 0; + actor.intimidateTargetId = null; + target.intimidatedTimer = 0; + if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した"); + if (target.state === "panic") target.goIdle("威嚇から戻った"); + actor.spriteLockUntil = 0; + target.spriteLockUntil = 0; + this.startFight(actor, target); + return true; + } + actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation"); + target.defeatedById = actor.id; + target.fightWinnerId = actor.id; + target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); + if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" }); + else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); } + this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); + target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); + target.vy += rand(-18, 18); + actor.adjustRelation(target, -0.08, 0.06, "intimidate"); + target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); + if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { + this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] }); + actor.lastLog = target.lastLog = this.time; + } + return true; + }, + + startFight(a, b) { + if (!a || !b || a.dead || b.dead) return; + if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; + a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` }); + b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` }); + a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); + b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); + a.nextHeadbutt = this.time + rand(0.08, 0.18); + b.nextHeadbutt = a.nextHeadbutt; + a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); + b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); + if (!a.fightTargetIds) a.fightTargetIds = []; + if (!b.fightTargetIds) b.fightTargetIds = []; + if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); + if (!b.fightTargetIds.includes(a.id)) b.fightTargetIds.push(a.id); + a.fightTargetIds = a.fightTargetIds.slice(-4); + b.fightTargetIds = b.fightTargetIds.slice(-4); + a.fightTargetId = a.fightTargetIds[0]; + b.fightTargetId = b.fightTargetIds[0]; + a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); + b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); + const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); + const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); + a.damage(damageToA, "\u55a7\u5629"); + b.damage(damageToB, "\u55a7\u5629"); + this.maybeDefeatFromFightDamage?.(a, b, damageToA); + this.maybeDefeatFromFightDamage?.(b, a, damageToB); + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); + b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); + 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, { + size: rand(12, 20), + life: rand(0.32, 0.52), + color: "rgba(116, 73, 38, 0.82)", + })); + audio.fight(); + 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] }); + a.lastLog = b.lastLog = this.time; + } + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_placement_log.js b/js/world_placement_log.js new file mode 100644 index 0000000..301ddf3 --- /dev/null +++ b/js/world_placement_log.js @@ -0,0 +1,475 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_placement_log.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + fencePlacementBlocked(item) { + if (!item || !this.isFenceType(item.type)) return false; + const rect = this.fenceRect(item); + if (!rect) return false; + const margin = Math.max(6, (item.r || 42) * 0.24); + if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; + const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72; + for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { + if (!it || it.dead) continue; + for (const r of this.solidObstacleRects(it)) { + const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; + if (!separated) return true; + } + } + return false; + }, + + importantPlacementOverlapBlocked(item) { + if (!item || item.dead) return true; + const important = new Set(["grass", "fence_v", "fence_h", "nest_box", "water_bowl"]); + if (!important.has(item.type)) return false; + const placingGrass = item.type === "grass"; + const itemRects = this.solidObstacleRects(item); + const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback); + const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12); + const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => { + if (!rect) return false; + const px = clamp(cx, rect.left - margin, rect.right + margin); + const py = clamp(cy, rect.top - margin, rect.bottom + margin); + return distXY(cx, cy, px, py) < radius + margin; + }; + const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom)); + for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) { + if (!other || other === item || other.dead || !important.has(other.type)) continue; + // Grass is a ground layer: objects may be placed on top of existing grass. + // Grass placement itself still avoids objects and other grass via grassSpotOpen(). + if (!placingGrass && other.type === "grass") continue; + const otherRects = this.solidObstacleRects(other); + const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12); + const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); + if (itemRects.length && otherRects.length) { + if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; + continue; + } + if (itemRects.length) { + if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true; + continue; + } + if (otherRects.length) { + if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true; + continue; + } + if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true; + } + return false; + }, + + placementBlocked(item) { + if (!item || item.dead) return true; + if (item.type === "genkotsu") return false; + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + for (const rect of rects) { + if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; + } + } else { + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; + } + if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; + if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; + if (this.importantPlacementOverlapBlocked(item)) return true; + if (item.type === "nest_box") { + const margin = Math.max(8, (item.r || 42) * 0.18); + for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { + if (!it || it === item || it.dead) continue; + for (const rect of this.solidObstacleRects(it)) { + for (const own of rects) { + const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom; + if (!separated) return true; + } + } + } + } + return false; + }, + + placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + let left = 0, right = 0, top = 0, bottom = 0; + for (const rect of rects) { + left = Math.max(left, (item.x || 0) - rect.left); + right = Math.max(right, rect.right - (item.x || 0)); + top = Math.max(top, (item.y || 0) - rect.top); + bottom = Math.max(bottom, rect.bottom - (item.y || 0)); + } + return { + x: clamp(x, pad + left, this.w - pad - right), + y: clamp(y, pad + top, this.h - pad - bottom), + }; + } + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + return { + x: clamp(x, pad + radius, this.w - pad - radius), + y: clamp(y, pad + radius, this.h - pad - radius), + }; + }, + + placementProbeItem(item, x, y) { + return { ...item, x, y, dead: false }; + }, + + findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) { + if (!item || item.dead) return null; + if (item.type === "grass") { + const spot = this.findGrassPlantingSpot + ? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true }) + : null; + if (!spot) return null; + const probe = this.placementProbeItem(item, spot.x, spot.y); + return this.placementBlocked(probe) ? null : spot; + } + + const start = this.placementClampPointFor(item, item.x, item.y); + const original = this.placementProbeItem(item, start.x, start.y); + if (allowOriginal && !this.placementBlocked(original)) return start; + + const solid = this.isFenceType(item.type) || item.type === "nest_box"; + const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); + const count = attempts || (solid ? 72 : 36); + const golden = Math.PI * (3 - Math.sqrt(5)); + let best = null; + let bestD = Infinity; + for (let i = 0; i < count; i++) { + const t = count <= 1 ? 1 : i / (count - 1); + const radius = Math.max(8, searchRadius * Math.sqrt(t)); + const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2; + const rawX = item.x + Math.cos(angle) * radius; + const rawY = item.y + Math.sin(angle) * radius * 0.74; + const p = this.placementClampPointFor(item, rawX, rawY); + const probe = this.placementProbeItem(item, p.x, p.y); + if (this.placementBlocked(probe)) continue; + const d = distXY(item.x, item.y, p.x, p.y); + if (d < bestD) { best = p; bestD = d; } + if (d < Math.max(18, (item.r || 12) * 0.75)) break; + } + return best; + }, + + findGrabTargetAt(x, y) { + let foundT = null; + for (let i = this.tarinai.length - 1; i >= 0; i--) { + const t = this.tarinai[i]; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; + if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; } + } + let foundItem = null, foundItemD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type === "trace" || it.type === "splat") continue; + const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2)); + const d = distXY(x, y, it.x, it.y); + if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; } + } + if (foundT && foundItem) { + const td = distXY(x, y, foundT.x, foundT.y); + return td <= foundItemD + 8 ? foundT : foundItem; + } + return foundT || foundItem; + }, + + placeItem(item, dropped = true) { + if (!item) return null; + if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) { + showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002"); + return null; + } + if (this.isFenceType(item.type)) { + this.compactItems(); + this.updateItemCounts(); + if (this.liveFenceCount() >= this.fenceLimit()) { + showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); + return null; + } + } + if (this.placementBlocked(item)) { + showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002"); + return null; + } + if (dropped) { + item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : (isPinType(item.type) ? 0.48 : 0.58)); + item.dropTimer = item.dropMax; + item.dropImpactDone = false; + } + this.items.push(item); + this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1; + this.markTerrainDirty?.(`place:${item.type}`); + this.emit("tool:place", { item, type: item.type, dropped }); + this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); + if (dropped) audio.place?.(item.type); + this.rebuildSpatial(true); + return item; + }, + + signboardAt(x, y) { + let sign = null; + let bestD = Infinity; + for (let i = (this.items || []).length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type !== "signboard") continue; + const d = distXY(x, y, it.x, it.y); + if (d <= Math.max(72, (it.r || 42) * 2.2) && d < bestD) { sign = it; bestD = d; } + } + return sign; + }, + + machineAt(x, y) { + let best = null; + let bestD = Infinity; + for (let i = (this.items || []).length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || !(typeof isMachineType === "function" ? isMachineType(it.type) : (it.type === "duplicator" || it.type === "cannon"))) continue; + const hit = Math.max(46, (it.r || 42) * (it.type === "cannon" ? 1.35 : 1.55)); + const d = distXY(x, y, it.x, it.y); + if (d <= hit && d < bestD) { best = it; bestD = d; } + } + return best; + }, + + setMachineSlot(machine, itemType) { + if (!machine || machine.dead || !itemType || !(typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) return false; + if (machine.machineSlotType && machine.machineSlotType !== itemType) { + for (const it of this.items || []) { + if (it && !it.dead && it.machineProducerId === machine.id) it.amount = 0; + } + } + machine.machineSlotType = itemType; + machine.machineSlotSize = this.toolSizeFor ? this.toolSizeFor(itemType) : "medium"; + machine.machineSpawnTimer = 0; + machine.cannonFireTimer = machine.type === "cannon" ? 0.35 : machine.cannonFireTimer; + const label = toolLabel(itemType); + this.log?.(`${toolLabel(machine.type)}に${label}をセットした。`, "observe"); + showToast(`${toolLabel(machine.type)}: ${label}をセット`); + this.rebuildSpatial?.(true); + return true; + }, + + rotateCannon(cannon, x, y) { + if (!cannon || cannon.dead || cannon.type !== "cannon") return false; + const dx = x - cannon.x; + const dy = y - cannon.y; + if (Math.hypot(dx, dy) > 3) cannon.cannonAngle = Math.atan2(dy, dx); + else cannon.cannonAngle = ((Number.isFinite(cannon.cannonAngle) ? cannon.cannonAngle : 0) + Math.PI / 4) % (Math.PI * 2); + const deg = Math.round((((cannon.cannonAngle * 180 / Math.PI) % 360) + 360) % 360); + showToast(`大砲の向き: ${deg}°`); + this.log?.(`大砲の向きを${deg}度に変えた。`, "observe"); + return true; + }, + + editSignboard(sign) { + if (!sign || sign.dead || sign.type !== "signboard") return false; + const current = String(sign.text || ""); + const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current; + if (next === null) return true; + sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 36).trim(); + sign.textEditedAt = this.time || 0; + this.markTerrainDirty?.("signboard:text"); + this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe"); + return true; + }, + + handleClick(x, y) { + const tool = this.tool; + if (tool === "observe") { + let found = null; + for (let i = this.tarinai.length - 1; i >= 0; i--) { + if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } + } + if (!found) { + const machine = this.machineAt?.(x, y) || null; + if (machine) { + if (machine.type === "cannon") this.rotateCannon?.(machine, x, y); + else showToast(machine.machineSlotType ? `複製機: ${toolLabel(machine.machineSlotType)}を複製中` : "複製機: 消費アイテムを選んでクリックするとセット"); + this.selected = null; + return; + } + const sign = this.signboardAt?.(x, y) || null; + if (sign) { + this.editSignboard?.(sign); + this.selected = null; + return; + } + } + this.selected = found; + return; + } + + if (tool === "poke") { + let ballTarget = null; + let ballTargetD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type !== "ball") continue; + const hit = Math.max(72, (it.r || 18) * 4.0); + const d = distXY(x, y, it.x, it.y); + if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; } + } + if (ballTarget && this.pokeBall(ballTarget, x, y)) return; + + const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); + if (target) { + target.poke(); + if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); + } + return; + } + + if (tool === "new") { + const spot = this.edgeSpawnPoint(x, y, 42, true); + const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 }); + t.wanderAngle = spot.angle; + audio.birth(); + this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + return; + } + + if (tool === "delete") { + let best = null, bestD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (it.dead) continue; + const visualHit = Math.max(22, (it.r || 12) * 2.0); + const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit; + const d = distXY(x, y, it.x, it.y); + if (d < hit && d < bestD) { best = it; bestD = d; } + } + if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } + if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; } + if (best.isStructure && typeof best.damage === "function") best.damage(9999, null, this); + else best.amount = 0; + audio.delete?.(); + this.compactItems(); + this.updateItemCounts(); + this.rebuildSpatial(); + const label = toolLabel(best.type); + this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); + return; + } + + const itemType = toolItemType(tool); + if (itemType === "signboard") { + const sign = this.signboardAt?.(x, y) || null; + if (sign) { this.editSignboard?.(sign); return; } + } + if (itemType) { + const machine = this.machineAt?.(x, y) || null; + if (machine && (typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) { + this.setMachineSlot?.(machine, itemType); + return; + } + if (machine && machine.type === "cannon" && itemType !== "cannon" && itemType !== "duplicator") { + this.rotateCannon?.(machine, x, y); + return; + } + let px = x, py = y; + const rawItem = this.applyToolSize(new Item(itemType, px, py)); + // Placement uses the same footprint that the preview validates. + const placed = this.placeItem(rawItem, true); + if (!placed) return; + const label = toolLabel(itemType); + this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + if (itemType === "signboard") this.editSignboard?.(placed); + } + }, + + inferLogKind(text) { + const s = String(text || "").toLowerCase(); + if (s.includes("\u914d\u7f6e") || s.includes("\u8a2d\u7f6e") || s.includes("placed") || s.includes("selected")) return "observe"; + if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; + if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) return "fight"; + if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; + if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death"; + if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; + if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; + if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; + if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe"; + if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation"; + return "note"; + }, + + shouldHideFromObservationRecords(entry = {}) { + const type = typeof entry === "string" ? "" : (entry.eventType || ""); + return type === "ball_poke" || type === "avoid_relationship"; + }, + + playLogSoundForEntry(entry = {}) { + if (!entry || entry.hiddenFromObservation || typeof audio === "undefined") return; + const kind = entry.kind || "note"; + const text = String(entry.text || ""); + if (kind === "death") return audio.death?.(); + if (kind === "birth") return audio.birth?.(); + if (kind === "fight") return /\u6c7a\u7740|\u52dd|\u8ca0|\u899a\u3048/.test(text) ? audio.fightFinish?.() : audio.fight?.(); + if (kind === "food") return audio.eat?.(); + if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 }); + if (kind === "weather") return audio.phase?.(); + if (kind === "relation") return audio.notify?.(); + if (kind === "accident") { + if (/\u914d\u7f6e|\u7f6e\u3044\u305f|\u8a2d\u7f6e/.test(text)) return audio.place?.(/\u7206\u7af9/.test(text) ? "firecracker" : ""); + if (/\u3052\u3093\u3053\u3064/.test(text)) return audio.genkotsuImpact?.(); + if (/\u7206\u7af9|\u7206\u767a/.test(text)) return audio.explode?.(); + if (/\u77f3/.test(text)) return audio.stoneImpact?.(); + if (/\u30dc\u30fc\u30eb/.test(text)) return audio.ballHit?.(); + return audio.damage?.(18); + } + if (kind === "event") { + if (/\u5973\u738b\u30a2\u30ea/.test(text)) return audio.queenAnt?.(); + if (/\u30a2\u30ea|\u5de3/.test(text)) return audio.antNest?.(); + if (/\u75c5|\u767a\u75c5/.test(text)) return audio.disease?.(); + if (/\u56de\u5fa9|\u6cbb/.test(text)) return audio.heal?.(); + } + }, + + log(text, kind = null, options = {}) { + const entry = { + time: this.time, + text, + kind: kind || this.inferLogKind(text), + eventType: options.eventType || "", + hiddenFromObservation: Boolean(options.hiddenFromObservation), + participants: (options.participants || []).filter(Boolean).map(t => ({ + id: t.id || t.releasedLiveId || "", + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), + participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id), + participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({ + liveId: t.id || t.releasedLiveId || "", + liveToken: t.liveToken || t.releasedLiveToken || 0, + familyKey: this.tarinaiFamilyKey(t), + name: t.name || "", + type: t.type || "", + generation: t.generation || 1, + })), + }; + if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; + const countInStats = options.countInStats ?? !entry.hiddenFromObservation; + entry.countInStats = Boolean(countInStats); + if (!this.eventCounters) this.eventCounters = {}; + if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; + for (const t of options.participants || []) { + if (!t) continue; + if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); + } + this.logs.unshift(entry); + this.emit("log:entry", { entry }); + this.playLogSoundForEntry?.(entry); + this.logs = this.logs.slice(0, 100); + renderLog(this.logs); + if (typeof pushLogNotification === "function") pushLogNotification(entry); + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js new file mode 100644 index 0000000..92145b9 --- /dev/null +++ b/js/world_spatial_budget.js @@ -0,0 +1,191 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js"); + + function liveCount(list) { + let count = 0; + for (const item of list || []) if (item && !item.dead) count += 1; + return count; + } + + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + performanceLevel() { + if (window.TarinaiPerformance) return window.TarinaiPerformance.level || 0; + const fps = typeof window !== "undefined" ? Number(window.__tarinaiFps || 0) : 60; + if (!fps || !Number.isFinite(fps)) return 0; + if (fps < 22) return 3; + if (fps < 34) return 2; + if (fps < 48) return 1; + return 0; + }, + + performanceQuality() { + return window.TarinaiPerformance?.quality?.(this) || { level: this.performanceLevel(), antStride: 1, effectLimit: CONFIG.effectLimit ?? 96 }; + }, + + grassLoadLevel() { + const grass = this.itemCounts?.grass || 0; + const perf = this.performanceLevel(); + if (perf >= 3 || grass >= 180) return 3; + if (perf >= 2 || grass >= 120) return 2; + if (perf >= 1 || grass >= 72) return 1; + return 0; + }, + + grassUpdateFactor() { + return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0; + }, + + sortedDrawList() { + if (!this.drawList) this.drawList = []; + const antCount = liveCount(this.ants); + const tarinaiCount = liveCount(this.tarinai); + if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) { + this.drawList.length = 0; + for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t); + for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a); + this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0)); + this.drawListDirty = false; + } + return this.drawList; + }, + + nearbyItems(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch, precise); + }, + + nearbyTarinai(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise); + }, + + nearbyAnts(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise); + }, + + nearbyObstacles(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise); + }, + + nearbyFood(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise); + }, + + nearbyHazards(x, y, radius, precise = false) { + return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise); + }, + + rebuildSpatial(force = false) { + this.spatial.rebuild(this.items, this.tarinai, this.ants || []); + this.spatialDirty = false; + this.spatialVersion = (this.spatialVersion || 0) + 1; + }, + + updateItemCounts() { + const counts = this.itemCounts; + for (const key of Object.keys(counts)) counts[key] = 0; + for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1; + }, + + updateEffectCounts() { + const counts = this.effectCounts; + for (const key of Object.keys(counts)) counts[key] = 0; + for (const ef of this.effects) counts[ef.type] = (counts[ef.type] || 0) + 1; + }, + + compactItems() { + this.enforceItemBudget(); + const before = this.items.length; + let write = 0; + for (let read = 0; read < this.items.length; read++) { + const it = this.items[read]; + if (it.type === "mirror") continue; + if (!it.dead) this.items[write++] = it; + } + this.items.length = write; + const changed = before !== this.items.length; + if (changed) { + this.markSpatialDirty?.("compact-items"); + this.markTerrainDirty?.("compact-items"); + } + return changed; + }, + + enforceItemBudget() { + const pruneOldest = (type, limit) => { + if (!Number.isFinite(limit) || limit < 0) return; + let count = 0; + for (const it of this.items) if (it.type === type && !it.dead) count += 1; + if (count <= limit) return; + const victims = this.items + .filter(it => it.type === type && !it.dead) + .sort((a, b) => (b.age || 0) - (a.age || 0)) + .slice(0, count - limit); + for (const it of victims) it.amount = 0; + }; + pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56); + pruneOldest("trace", CONFIG.traceLimit ?? 64); + pruneOldest("splat", CONFIG.splatLimit ?? 48); + pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)); + const fenceLimit = this.fenceLimit ? this.fenceLimit() : (CONFIG.fenceLimit ?? 72); + const liveFences = this.liveFenceCount ? this.liveFenceCount() : 0; + if (liveFences > fenceLimit) { + const victims = this.items + .filter(it => this.isFenceType?.(it.type) && !it.dead) + .sort((a, b) => (b.age || 0) - (a.age || 0)) + .slice(0, liveFences - fenceLimit); + for (const it of victims) it.amount = 0; + } + + const limit = CONFIG.itemLimit ?? Infinity; + if (!Number.isFinite(limit) || this.items.length <= limit) return; + const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7, fence_v: 8, fence_h: 8 }; + const removable = this.items + .filter(it => !it.dead && (priority[it.type] || 99) < 99) + .sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0)); + let extra = this.items.length - limit; + for (const it of removable) { + if (extra <= 0) break; + it.amount = 0; + extra -= 1; + } + }, + + compactEffects() { + const limit = this.performanceQuality?.().effectLimit ?? CONFIG.effectLimit ?? 96; + if (Number.isFinite(limit) && this.effects.length > limit) { + this.effects.sort((a, b) => (a.life / Math.max(a.maxLife || 1, 0.001)) - (b.life / Math.max(b.maxLife || 1, 0.001))); + const remove = this.effects.length - limit; + for (let i = 0; i < remove; i++) this.effects[i].life = 0; + } + let write = 0; + for (let read = 0; read < this.effects.length; read++) { + const ef = this.effects[read]; + if (!ef.dead) this.effects[write++] = ef; + } + this.effects.length = write; + }, + + compactTarinai() { + let write = 0; + for (let read = 0; read < this.tarinai.length; read++) { + const t = this.tarinai[read]; + if (!t.dead) this.tarinai[write++] = t; + } + if (this.tarinai.length !== write) this.drawListDirty = true; + this.tarinai.length = write; + }, + + compactAnts() { + if (!this.ants) this.ants = []; + let write = 0; + for (let read = 0; read < this.ants.length; read++) { + const a = this.ants[read]; + if (a && !a.dead) this.ants[write++] = a; + } + if (this.ants.length !== write) this.drawListDirty = true; + this.ants.length = write; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_update.js b/js/world_update.js new file mode 100644 index 0000000..d7fb326 --- /dev/null +++ b/js/world_update.js @@ -0,0 +1,368 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_update.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + environmentScores() { + const alive = this.tarinai || []; + const avgStress = alive.length ? alive.reduce((sum, t) => sum + (t.stress || 0), 0) / alive.length : 0; + let fightPressure = 0; + let diseaseCount = 0; + for (const t of alive) { + if (!t || t.dead) continue; + if (t.state === "fight") fightPressure += 16; + if (t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04 || (t.intimidatedTimer || 0) > 0.04) fightPressure += 7; + if ((t.hurtTimer || 0) > 0.04 || (t.lastBleedAt || -999) + 30 > (this.time || 0)) fightPressure += 5; + if (t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease) diseaseCount += 1; + } + let blood = 0; + for (const ef of this.effects || []) if (ef && !ef.dead && (ef.type === "bleed" || ef.type === "splat")) blood += 1; + const zunchi = this.itemCounts?.zunchi || 0; + const splat = this.itemCounts?.splat || 0; + const trace = this.itemCounts?.trace || 0; + const antCorpse = this.itemCounts?.ant_corpse || 0; + const security = clamp(100 - fightPressure - blood * 1.6, 0, 100); + const foodSpoilage = this.foodSpoilagePenalty || 0; + const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - antCorpse * 0.65 - blood * 1.8 - diseaseCount * 8.0 - foodSpoilage, 0, 100); + const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100); + return { security, hygiene, happiness }; + }, + + + registerFoodSpoilage(amount = 0, item = null) { + const value = Math.max(0, Number(amount) || 0); + if (!value) return; + this.foodSpoilagePenalty = clamp((this.foodSpoilagePenalty || 0) + value, 0, 35); + this.foodSpoilageEvents = (this.foodSpoilageEvents || 0) + 1; + }, + + hourOfDay(timeValue = this.time || 0) { + return ((((timeValue || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24 + 24) % 24; + }, + + colonyMoodDefinition(id = "relaxed") { + const defs = { + calm: { id: "calm", label: "おだやか", description: "社交性プラス", effects: { personality: { sociability: 0.42 } } }, + prickly: { id: "prickly", label: "ピリピリ", description: "攻撃性プラス", effects: { personality: { aggression: 0.46 } } }, + stinky: { id: "stinky", label: "くさい", description: "開放性マイナス", effects: { personality: { openness: -0.44 } } }, + weaklings: { id: "weaklings", label: "ざこだらけ", description: "ずんちどれい以外のストレス減少", effects: { personality: {} } }, + zunda_party: { id: "zunda_party", label: "ずんだぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + ball_party: { id: "ball_party", label: "ぼーるぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + sun_party: { id: "sun_party", label: "おひさまぱーてぃー", description: "日光浴頻度増加", effects: { personality: {} } }, + ant_overrun: { id: "ant_overrun", label: "ありだらけ", description: "アリを見るとパニック", effects: { personality: {} } }, + relaxed: { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } }, + }; + return defs[id] || defs.relaxed; + }, + + evaluateColonyMood() { + const alive = (this.tarinai || []).filter(t => t && !t.dead); + const avgStress = alive.length ? alive.reduce((s, t) => s + (t.stress || 0), 0) / alive.length : 0; + const avgHunger = alive.length ? alive.reduce((s, t) => s + (t.hunger || 0), 0) / alive.length : 0; + const environment = this.environmentScores ? this.environmentScores() : { hygiene: 100 }; + const fights = alive.filter(t => t.state === "fight" || (t.fightTimer || 0) > 0.04 || t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04).length; + const corpseCount = (this.itemCounts?.ant_corpse || 0) + Math.max(0, Math.round((this.deadCount || 0) * 0.5)); + const slaveRatio = alive.length ? alive.filter(t => t.isZunchiSlave).length / alive.length : 0; + const candidates = []; + if (alive.length && avgStress <= 28 && avgHunger <= 34) candidates.push("calm"); + if (fights >= 2 || corpseCount >= 4) candidates.push("prickly"); + if ((environment.hygiene || 0) <= 42) candidates.push("stinky"); + if (alive.length && slaveRatio >= 0.45) candidates.push("weaklings"); + if ((this.itemCounts?.sweet || 0) >= 30) candidates.push("zunda_party"); + if ((this.itemCounts?.ball || 0) >= 10) candidates.push("ball_party"); + if (alive.some(t => t.state === "sunbath" || (t.sunbathTimer || 0) > 0.1)) candidates.push("sun_party"); + if ((this.itemCounts?.ant_nest || 0) >= 1) candidates.push("ant_overrun"); + const nextId = candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : "relaxed"; + this.colonyMood = this.colonyMoodDefinition(nextId); + this.lastColonyMoodDay = this.day || 1; + this.log?.(`今日のコロニーの雰囲気は「${this.colonyMood.label}」。`, "event"); + return this.colonyMood; + }, + + observeHighlightKind(t) { + const s = this.selected; + if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; + const tFamilyKey = this.tarinaiFamilyKey(t); + if ((s.parents || []).includes(tFamilyKey)) return "parent"; + if ((s.children || []).includes(tFamilyKey)) return "child"; + const rel = s.relationTo ? s.relationTo(t.id) : null; + if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend"; + if (rel && (rel.fear || 0) >= 8) return "enemy"; + return ""; + }, + + nestBoxOccupants(box, limit = 5) { + if (!box || box.dead || box.type !== "nest_box") return []; + const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id); + return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; + }, + + pointerNestBoxInfo() { + const p = this.pointer; + if (!p?.inside) return null; + let best = null, bestD = Infinity; + for (const it of this.nearbyItems(p.x, p.y, 140)) { + if (!it || it.dead || it.type !== "nest_box") continue; + 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 (!best) return null; + const occupants = this.nestBoxOccupants(best, Infinity); + const capacity = this.nestBoxCapacity(best); + return { box: best, occupants, capacity }; + }, + + nestBoxToolTipCount() { + const pointed = this.pointerNestBoxInfo?.(); + if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) }; + const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box"); + if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() }; + let current = 0, max = 0; + for (const box of boxes) { + current += this.nestBoxOccupants(box, Infinity).length; + max += this.nestBoxCapacity(box); + } + return { current, max }; + }, + + blastZunchiFrom(x, y, radius, strength = 1) { + let moved = 0; + for (const it of this.nearbyItems(x, y, radius + 60)) { + if (!it || it.dead || it.type !== "zunchi") continue; + let dx = it.x - x; + let dy = it.y - y; + let d = Math.hypot(dx, dy) || 1; + if (d > radius) continue; + if (d < 0.001) { + const a = rand(0, Math.PI * 2); + dx = Math.cos(a); dy = Math.sin(a); d = 1; + } + const p = clamp(1 - d / radius, 0, 1); + it.vx = (it.vx || 0) + dx / d * (130 + p * 430) * strength + rand(-45, 45); + it.vy = (it.vy || 0) + dy / d * (130 + p * 430) * strength + rand(-45, 45); + it.amount = Math.max(10, (it.amount || 80) - p * 8); + it.stage = "fresh"; + moved += 1; + if (moved < 12) 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) this.drawListDirty = true; + return moved; + }, + + update(dt) { + if (this.paused) return; + dt *= this.speed; + dt = Math.min(dt, 0.12 * this.speed); + + const prevTime = this.time; + this.time += dt; + const previousDay = this.day || 1; + this.day = Math.floor(this.time / CONFIG.dayLength) + 1; + const prevHour = this.hourOfDay ? this.hourOfDay(prevTime) : 0; + const nowHour = this.hourOfDay ? this.hourOfDay(this.time) : 0; + const crossedColonyEvalHour = (this.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); + const pendingColonyMoodEvaluation = crossedColonyEvalHour && this.lastColonyMoodDay !== this.day; + if (this.day !== previousDay) { + for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} }; + } + if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4); + this.shakeTimer = Math.max(0, (this.shakeTimer || 0) - dt); + if ((this.shakeTimer || 0) <= 0) { this.shakeDuration = 0; this.shakeStrength = 0; } + this.updateWeather(dt); + this.foodSpoilagePenalty = Math.max(0, (this.foodSpoilagePenalty || 0) - dt * 0.010); + this.updateTerrainDirtyState?.(dt); + this.rebuildSpatial(); + + const perf = this.performanceLevel(); + const itemCountBefore = this.items.length; + for (const it of this.items) it.update(dt, this); + if (this.itemCounts?.ball) { + this.rebuildSpatial(); + this.resolveBallBallCollisions(dt); + this.rebuildSpatial(); + } + this.updateAnts?.(dt); + + for (const ef of this.effects) ef.update(dt); + + for (const t of this.tarinai) { + t.update(dt); + } + this.rebuildSpatial(); + this.limitBallChasers(); + this.resolveBallInteractions(dt); + this.resolveTarinaiHighSpeedCollisions(dt); + + this.compactTimer += dt; + const compactInterval = perf >= 2 ? 0.95 : 0.58; + let itemsCompacted = false; + if (this.compactTimer >= compactInterval) { + itemsCompacted = this.compactItems(); + this.compactEffects(); + this.compactTarinai(); + this.compactAnts?.(); + this.compactTimer = 0; + } + + this.countsTimer += dt; + const countsInterval = perf >= 2 ? 0.85 : 0.42; + if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) { + this.updateItemCounts(); + this.updateEffectCounts(); + this.countsTimer = 0; + } + + if ((this.outOfBoundsCheckAt || -999) + 1.0 <= this.time) { + this.outOfBoundsCheckAt = this.time; + this.sanitizeOutOfBounds(); + } + + this.drawSortTimer += dt; + const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07; + if (this.drawSortTimer >= sortInterval) { + this.drawListDirty = true; + this.drawSortTimer = 0; + } + + const phase = this.phaseName(); + if (phase !== this.lastPhase) { + this.lastPhase = phase; + this.emit("world:phase", { phase }); + audio.phase(); + } + + if (this.tarinai.length > 0 && this.tarinai.length <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { + const spot = this.edgeSpawnPoint(null, null, 42, true); + const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 }); + t.wanderAngle = spot.angle; + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); + audio.birth?.(); + this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + } + + + const grassCount = this.itemCounts.grass || 0; + if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) { + const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { + allowOriginal: true, + minRadius: 28, + maxRadius: 140, + attempts: 24, + }); + if (spot) { + const sprout = new Item("grass", spot.x, spot.y); + sprout.amount = rand(22, 48); + sprout.growth = rand(0.12, 0.30); + this.items.push(sprout); + this.itemCounts.grass = grassCount + 1; + this.markTerrainDirty?.("grass-spawn"); + } + } + + // Rain visuals are diagonal line streaks in render.js. This branch creates + // horizontal oval water items spawned by rain; it intentionally does not + // share the rain overlay renderer. + if (window.TarinaiWeatherSystem?.shouldDropRainWater?.(this, dt)) { + const drop = window.TarinaiWeatherSystem.createRainWaterItem(this); + this.items.push(drop); + this.itemCounts.water = (this.itemCounts.water || 0) + 1; + this.markTerrainDirty?.("rain-water"); + this.emit?.("rain:itemDropped", { item: drop, type: "water" }); + } + + }, + + sanitizeOutOfBounds() { + const pad = Math.max(28, CONFIG.worldPadding || 30); + const far = Math.max(360, Math.max(this.w || 1000, this.h || 720) * 0.75); + let changedItems = false; + const repairPoint = (obj, radius = 12) => { + if (!obj) return "delete"; + if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return "delete"; + const minX = pad - radius; + const maxX = (this.w || 1000) - pad + radius; + const minY = pad - radius; + const maxY = (this.h || 720) - pad + radius; + if (obj.x >= minX && obj.x <= maxX && obj.y >= minY && obj.y <= maxY) return "ok"; + if (obj.x < -far || obj.x > (this.w || 1000) + far || obj.y < -far || obj.y > (this.h || 720) + far) return "delete"; + obj.x = clamp(obj.x, pad, (this.w || 1000) - pad); + obj.y = clamp(obj.y, pad, (this.h || 720) - pad); + if (Number.isFinite(obj.vx)) obj.vx *= -0.18; + if (Number.isFinite(obj.vy)) obj.vy *= -0.18; + return "moved"; + }; + for (const it of this.items || []) { + const result = repairPoint(it, it.r || 12); + if (result === "delete") { it.amount = 0; changedItems = true; } + else if (result === "moved") changedItems = true; + } + for (const ef of this.effects || []) { + const result = repairPoint(ef, ef.size || 12); + if (result === "delete") ef.life = 0; + } + for (const t of this.tarinai || []) { + if (!t || t.dead) continue; + const result = repairPoint(t, t.radius || 22); + if (result === "delete") { + t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); + t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); + t.vx = 0; + t.vy = 0; + t.entryTimer = 0; + t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"); + } else if (result === "moved") { + t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); + t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); + t.vx = 0; + t.vy = 0; + t.entryTimer = 0; + } + } + let changedAnts = false; + for (const ant of this.ants || []) { + if (!ant || ant.dead) continue; + const result = repairPoint(ant, ant.kind === "queen" ? 18 : 10); + if (result === "delete") { + ant.dead = true; + changedAnts = true; + continue; + } + if (result === "moved") { + ant.vx = 0; + ant.vy = 0; + if (ant.state === "drag") { + ant.state = "return"; + ant.targetId = ""; + ant.targetToken = 0; + ant.targetRef = null; + } + changedAnts = true; + } + } + if (changedAnts) this.compactAnts?.(); + if (changedItems) { + this.compactItems(); + this.updateItemCounts(); + this.rebuildSpatial(); + } + }, + + updateWeather(dt) { + this.nextWeatherChange -= dt; + if (this.nextWeatherChange > 0) return; + if (window.TarinaiWeatherSystem?.applyNextWeather) { + window.TarinaiWeatherSystem.applyNextWeather(this); + return; + } + const current = this.weather || "sunny"; + const next = pick(current === "light_rain" ? ["sunny", "cloudy"] : ["sunny", "cloudy", "light_rain"]); + this.weather = next; + this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); + if (next !== current) { + this.emit?.("weather:changed", { previous: current, next }); + this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`); + } + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_view.js b/js/world_view.js new file mode 100644 index 0000000..b97db53 --- /dev/null +++ b/js/world_view.js @@ -0,0 +1,316 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_view.js"); + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + dayProgress() { + return (this.time % CONFIG.dayLength) / CONFIG.dayLength; + }, + + lightLevel() { + return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2; + }, + + triggerGroundShake(duration = 0.4, strength = 6) { + this.shakeTimer = Math.max(this.shakeTimer || 0, duration); + this.shakeDuration = Math.max(this.shakeDuration || 0, duration); + this.shakeStrength = Math.max(this.shakeStrength || 0, strength); + }, + + currentShakeOffset() { + const timeLeft = Math.max(0, this.shakeTimer || 0); + if (timeLeft <= 0) return { x: 0, y: 0 }; + const duration = Math.max(0.001, this.shakeDuration || timeLeft); + const strength = Math.max(0, this.shakeStrength || 0); + const fade = clamp(timeLeft / duration, 0.18, 1); + const t = this.time || 0; + return { + x: Math.sin(t * 48) * strength * 0.34 * fade + Math.sin(t * 91) * strength * 0.12 * fade, + y: Math.cos(t * 60) * strength * 0.18 * fade, + }; + }, + + emit(type, detail = {}) { + return this.events?.emit?.(type, { ...detail, world: this }) || null; + }, + + on(type, handler) { + return this.events?.on?.(type, handler) || (() => {}); + }, + + markSpatialDirty(reason = "manual") { + this.spatialDirty = true; + this.spatialDirtyReason = reason; + }, + + markTerrainDirty(reason = "manual") { + this.terrainDirty = true; + this.terrainDirtyReason = reason; + this.terrainVersion = (this.terrainVersion || 0) + 1; + this.terrainLastDirtyAt = this.time || 0; + this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion }); + }, + + terrainSignature() { + const counts = this.itemCounts || {}; + return `${this.fieldType || "garden"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`; + }, + + updateTerrainDirtyState(dt = 0) { + const signature = this.terrainSignature(); + if (signature !== this.lastTerrainSignature) { + this.lastTerrainSignature = signature; + this.markTerrainDirty("terrain-signature"); + return; + } + const q = window.TarinaiPerformance?.quality?.(this) || {}; + const interval = q.terrainRedrawInterval || (this.performanceLevel() >= 2 ? 1.05 : 0.62); + if ((this.time || 0) - (this.terrainLastDirtyAt || 0) >= interval) this.markTerrainDirty("terrain-periodic"); + }, + + phaseName() { + const hour = this.dayProgress() * 24; + if (hour >= 5 && hour < 10) return "\u671d"; + if (hour >= 10 && hour < 17) return "\u663c"; + if (hour >= 17 && hour < 20) return "\u5915\u65b9"; + return "\u591c"; + }, + + clockString() { + const totalMinutes = Math.floor(this.dayProgress() * 24 * 60); + const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0"); + const mm = String(totalMinutes % 60).padStart(2, "0"); + return `${hh}:${mm}`; + }, + + fieldDefinition() { + return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden; + }, + + fieldWorldScale() { + const field = this.fieldDefinition(); + // Field type defines the logical simulation area. Wheel zoom must not + // rewrite this size; it is a camera/display transform only. + return Math.max(0.22, field.worldScale || 1); + }, + + grassLimit() { + const field = this.fieldDefinition(); + if (Number.isFinite(field?.grassLimit)) return Math.max(0, Math.round(field.grassLimit)); + const base = CONFIG.grassLimit ?? 96; + const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); + const ratio = this.fieldWorldScale() / mediumScale; + // \u4e2d\u30b5\u30a4\u30ba\u3092 CONFIG.grassLimit \u306e\u57fa\u6e96\u306b\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8ad6\u7406\u30b5\u30a4\u30ba\u306b\u5fdc\u3058\u3066\u5897\u6e1b\u3059\u308b\u3002 + return Math.max(12, Math.round(base * ratio)); + }, + + fenceLimit() { + const base = CONFIG.fenceLimit ?? 72; + const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1); + const ratio = Math.max(0.24, this.fieldWorldScale() / mediumScale); + return clamp(Math.round(base * Math.sqrt(ratio)), 28, 140); + }, + + isFenceType(type = "") { + return type === "fence_v" || type === "fence_h"; + }, + + liveFenceCount() { + let count = 0; + for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1; + return count; + }, + + baseZoomMin() { + return CONFIG.fieldZoomMin || 0.42; + }, + + baseZoomMax() { + return CONFIG.fieldZoomMax || 2.45; + }, + + fitFieldZoom() { + const vw = Math.max(1, this.viewportW || this.w || 1000); + const vh = Math.max(1, this.viewportH || this.h || 720); + const ww = Math.max(1, this.w || vw); + const wh = Math.max(1, this.h || vh); + // The minimum visible zoom is field-size dependent. Small fields must not + // be allowed to shrink below the viewport, otherwise empty margins appear. + return Math.max(vw / ww, vh / wh); + }, + + zoomLimits() { + const min = Math.max(this.baseZoomMin(), this.fitFieldZoom()); + // Keep the old upper bound for normal/large fields, but give small fields + // proportional zoom-in headroom because their no-margin minimum is higher. + const max = Math.max(this.baseZoomMax(), min * this.baseZoomMax()); + return { min, max }; + }, + + normalizedFieldZoom(value = this.fieldZoom) { + const limits = this.zoomLimits(); + return clamp(Number(value) || limits.min, limits.min, limits.max); + }, + + viewScale() { + return this.normalizedFieldZoom(this.fieldZoom); + }, + + visibleWorldW() { + return (this.viewportW || this.w) / this.viewScale(); + }, + + visibleWorldH() { + return (this.viewportH || this.h) / this.viewScale(); + }, + + screenSizeToWorld(size) { + // Kept as a compatibility shim for older hit-test call sites. The values + // currently passed here are already logical world units, not CSS pixels. + return size; + }, + + fieldScreenOffset() { + const scale = this.viewScale(); + return { + x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2), + y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2), + }; + }, + + maxCameraX() { + return Math.max(0, this.w - this.visibleWorldW()); + }, + + maxCameraY() { + return Math.max(0, this.h - this.visibleWorldH()); + }, + + clampCamera() { + this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX()); + this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY()); + }, + + panCamera(dx, dy) { + const scale = this.viewScale(); + this.cameraX = (this.cameraX || 0) + dx / scale; + this.cameraY = (this.cameraY || 0) + dy / scale; + this.clampCamera(); + }, + + screenToWorld(x, y) { + const scale = this.viewScale(); + const off = this.fieldScreenOffset(); + const rawX = (x - off.x) / scale + (this.cameraX || 0); + const rawY = (y - off.y) / scale + (this.cameraY || 0); + return { + x: clamp(rawX, 0, this.w), + y: clamp(rawY, 0, this.h), + inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h, + }; + }, + + worldToScreen(x, y) { + const scale = this.viewScale(); + const off = this.fieldScreenOffset(); + return { + x: (x - (this.cameraX || 0)) * scale + off.x, + y: (y - (this.cameraY || 0)) * scale + off.y, + }; + }, + + setViewportSize(width, height, opts = {}) { + const oldW = this.w || width; + const oldH = this.h || height; + this.viewportW = Math.max(1, width || this.viewportW || 1000); + this.viewportH = Math.max(1, height || this.viewportH || 720); + const requestedScale = this.fieldWorldScale(); + const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH); + this.w = this.viewportW * scale; + this.h = this.viewportH * scale; + this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom); + this.clampCamera(); + if (opts.scaleContents && oldW > 0 && oldH > 0) { + const sx = this.w / oldW; + const sy = this.h / oldH; + const scalePoint = (p) => { + if (!p) return; + if (Number.isFinite(p.x)) p.x *= sx; + if (Number.isFinite(p.y)) p.y *= sy; + }; + for (const t of this.tarinai || []) scalePoint(t); + for (const it of this.items || []) scalePoint(it); + for (const ef of this.effects || []) scalePoint(ef); + scalePoint(this.pointer); + this.cameraX *= sx; + this.cameraY *= sy; + this.clampCamera(); + this.markTerrainDirty?.("viewport-scale"); + this.rebuildSpatial(true); + this.drawListDirty = true; + } + }, + + setFieldZoom(nextZoom) { + const current = this.normalizedFieldZoom(this.fieldZoom); + const next = this.normalizedFieldZoom(nextZoom); + if (Math.abs(next - current) < 0.001) { + this.fieldZoom = current; + this.clampCamera(); + return false; + } + this.fieldZoom = next; + this.clampCamera(); + this.markTerrainDirty?.("field-zoom"); + this.drawListDirty = true; + return true; + }, + + edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) { + const w = Math.max(this.w || 1000, margin * 2 + 1); + const h = Math.max(this.h || 720, margin * 2 + 1); + let side; + if (Number.isFinite(targetX) && Number.isFinite(targetY)) { + const distances = [targetY, w - targetX, h - targetY, targetX]; + side = distances.indexOf(Math.min(...distances)); + } else { + side = Math.floor(Math.random() * 4); + } + const p = outside ? -(margin + 24) : margin; + if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: p, vx: rand(-10, 10), vy: rand(28, 48), angle: Math.PI / 2 }; + if (side === 1) return { x: outside ? w - p : w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-48, -28), vy: rand(-10, 10), angle: Math.PI }; + if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: outside ? h - p : h - margin, vx: rand(-10, 10), vy: rand(-48, -28), angle: -Math.PI / 2 }; + return { x: p, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(28, 48), vy: rand(-10, 10), angle: 0 }; + }, + + toolSizeFor(type = "") { + return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(this, type) : ((this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"); + }, + + toolSizeScale(type = "") { + return typeof toolSizeScaleFor === "function" ? toolSizeScaleFor(this, type) : ({ small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1); + }, + + applyToolSize(item) { + if (!item) return item; + const scale = this.toolSizeScale(item.type); + const size = this.toolSizeFor(item.type); + item.toolSize = size; + this.toolSize = size; + item.r *= scale; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { + const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5; + item.foodServingScale = scale; + item.foodServingsMax = servings; + item.foodServingsRemaining = servings; + item.amount = servings; + } else if (["grass", "zunchi"].includes(item.type)) { + item.amount *= scale * scale; + item.foodServingScale = scale; + } + if (item.type === "firecracker") item.blastScale = scale; + return item; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/scripts/generate_app_files.py b/scripts/generate_app_files.py new file mode 100644 index 0000000..dc3c15c --- /dev/null +++ b/scripts/generate_app_files.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Regenerate index.html CSS/script tags and service-worker.js from app_manifest.json. +This project intentionally stays buildless; this script is a lightweight manifest sync tool. +""" +from __future__ import annotations +from pathlib import Path +import json, re + +ROOT = Path(__file__).resolve().parents[1] +manifest = json.loads((ROOT / "app_manifest.json").read_text(encoding="utf-8")) +version = manifest["version"] +css_files = manifest["css"] +js_files = manifest["js"] +index = ROOT / "index.html" +s = index.read_text(encoding="utf-8") +css_block = "".join([f'\n ' for f in css_files]) +s = re.sub(r'\s*(?:\s*)*', css_block, s, count=1) +script_block = "\n".join([f' ' for f in js_files]) +s = re.sub(r'\n\s*[\s\S]*?\n\s*', "\n" + script_block, s, count=1) +index.write_text(s, encoding="utf-8") +core_names = [Path(f).stem for f in js_files] +css_entries = ",\n ".join([f'`./{f}?${{v}}`' for f in css_files]) +js_names = ", ".join([json.dumps(n, ensure_ascii=False) for n in core_names]) +(ROOT / "service-worker.js").write_text(f'''"use strict";\n\nconst APP_VERSION = "{version}";\nconst CACHE_NAME = `tarinai-colony-${{APP_VERSION}}`;\nconst v = `v=${{APP_VERSION}}`;\n// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.\nconst coreScriptNames = [\n {js_names}\n];\nconst CORE_ASSETS = [\n "./",\n "./index.html",\n {css_entries},\n ...coreScriptNames.map(name => `./js/${{name}}.js?${{v}}`),\n];\n\nself.addEventListener("install", (event) => {{\n event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined));\n self.skipWaiting();\n}});\n\nself.addEventListener("activate", (event) => {{\n event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));\n self.clients.claim();\n}});\n\nfunction isStaticAsset(request) {{\n const url = new URL(request.url);\n return /\\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname);\n}}\n\nself.addEventListener("fetch", (event) => {{\n const request = event.request;\n if (request.method !== "GET") return;\n const url = new URL(request.url);\n if (url.origin !== self.location.origin) return;\n if (/service-worker\\.js$/i.test(url.pathname)) return;\n if (request.mode === "navigate" || /index\\.html$/i.test(url.pathname)) {{\n event.respondWith(fetch(request).then(response => {{\n const copy = response.clone();\n caches.open(CACHE_NAME).then(cache => cache.put(request, copy));\n return response;\n }}).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));\n return;\n }}\n if (isStaticAsset(request)) {{\n event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {{\n const copy = response.clone();\n caches.open(CACHE_NAME).then(cache => cache.put(request, copy));\n return response;\n }})));\n }}\n}});\n''', encoding="utf-8") diff --git a/scripts/generate_item_icons.py b/scripts/generate_item_icons.py new file mode 100644 index 0000000..a124765 --- /dev/null +++ b/scripts/generate_item_icons.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Generate UI icons for item designs that must match js/items.js. + +The game is buildless, but these icons are generated snapshots of the field +appearance. Do not invent separate tool-UI-only designs. +""" +from __future__ import annotations +from pathlib import Path +import json, math, re +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "assets" / "ui" +REGISTRY = ROOT / "js" / "item_visual_registry.js" + + +def visual_registry() -> dict: + text = REGISTRY.read_text(encoding="utf-8") + match = re.search(r"String\.raw`([\s\S]*?)`", text) + if not match: + raise RuntimeError("Could not find JSON visual registry in js/item_visual_registry.js") + return json.loads(match.group(1)) + + +def save_icon(name: str, draw_fn) -> None: + im = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) + d = ImageDraw.Draw(im, "RGBA") + draw_fn(d) + im.save(OUT / name, "WEBP", lossless=True, quality=95) + + +def draw_drop(fill, stroke, highlight): + def fn(d: ImageDraw.ImageDraw) -> None: + d.ellipse([18, 60, 78, 74], fill=(60, 45, 35, 32)) + # Same horizontal oval as fallen water/zunda/mercury item sprites; rain overlay is separate diagonal lines. + d.ellipse([12, 33, 84, 63], fill=fill, outline=stroke, width=4) + d.ellipse([24, 38, 45, 48], fill=highlight) + return fn + +def draw_water_bowl(palette): + bowl_fill = rgba(palette.get("bowlFill"), (171, 120, 76, 184)) + bowl_stroke = rgba(palette.get("bowlStroke"), (94, 69, 48, 140)) + water_fill = rgba(palette.get("waterFill"), (86, 157, 224, 148)) + water_stroke = rgba(palette.get("waterStroke"), (62, 113, 178, 92)) + highlight = rgba(palette.get("highlight"), (255, 255, 255, 138)) + def fn(d: ImageDraw.ImageDraw) -> None: + d.ellipse([9, 29, 87, 72], fill=bowl_fill, outline=bowl_stroke, width=4) + d.ellipse([19, 35, 77, 62], fill=water_fill, outline=water_stroke, width=3) + d.ellipse([34, 38, 44, 47], fill=highlight) + return fn + + +def rgba(value, fallback): + if not value: + return fallback + if value.startswith("#"): + value = value.lstrip("#") + if len(value) == 6: + return tuple(int(value[i:i+2], 16) for i in (0, 2, 4)) + (255,) + match = re.match(r"rgba?\(([^)]+)\)", value.replace(" ", "")) + if match: + parts = match.group(1).split(",") + r, g, b = (int(float(parts[i])) for i in range(3)) + a = int(float(parts[3]) * 255) if len(parts) > 3 else 255 + return (r, g, b, a) + return fallback + + +def draw_powder(fill, stroke, highlight): + def fn(d: ImageDraw.ImageDraw) -> None: + d.ellipse([18, 62, 78, 76], fill=(60, 45, 35, 36)) + for i in range(20): + x = 48 + math.cos(i * 1.9) * (8 + (i % 5) * 4) + y = 56 - abs(math.sin(i * 1.3)) * 24 + (i % 3) * 3 + r = 5 + (i % 4) + d.ellipse([x - r, y - r * 0.65, x + r, y + r * 0.65], fill=highlight if i % 4 == 0 else fill, outline=stroke, width=1) + d.ellipse([31, 31, 52, 39], fill=rgba("rgba(255,255,255,0.62)", (255, 255, 255, 158))) + return fn + + +def draw_pill(fill, stroke, highlight, scale=1.0): + def fn(d: ImageDraw.ImageDraw) -> None: + cx, cy = 48, 48 + w, h = 66 * scale, 28 * scale + box = [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2] + d.rounded_rectangle(box, radius=int(h / 2), fill=fill, outline=stroke, width=4) + d.line([(cx, cy - h / 2 + 2), (cx, cy + h / 2 - 2)], fill=highlight, width=4) + d.ellipse([cx - w * 0.32, cy - h * 0.24, cx - w * 0.08, cy - h * 0.04], fill=(255, 255, 255, 110)) + return fn + + +def draw_laxative(d: ImageDraw.ImageDraw) -> None: + d.rounded_rectangle([14, 28, 82, 68], radius=15, fill=(244, 212, 154, 255), outline=(98, 61, 29, 235), width=4) + # Zunchi mound motif inside the tablet. + for box in ([26, 47, 44, 60], [38, 36, 60, 53], [54, 47, 74, 61], [46, 29, 61, 41]): + d.ellipse(box, fill=(96, 70, 38, 230), outline=(56, 39, 22, 130), width=2) + d.arc([22, 27, 72, 50], 205, 330, fill=(255, 255, 255, 135), width=3) + +def star_points(cx: float, cy: float, r1: float, r2: float, n: int = 5, rot: float = -math.pi / 2): + return [(cx + math.cos(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2), cy + math.sin(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2)) for i in range(n * 2)] + + +def draw_mystery(d: ImageDraw.ImageDraw) -> None: + d.ellipse([18, 23, 78, 73], fill=(243, 236, 255, 255), outline=(91, 55, 150, 240), width=5) + try: + from PIL import ImageFont + font = ImageFont.truetype("DejaVuSans-Bold.ttf", 44) + except Exception: + font = None + d.text((48, 48), "?", anchor="mm", fill=(91, 55, 150, 245), font=font) + d.arc([25, 24, 74, 54], 205, 330, fill=(255, 255, 255, 135), width=3) + +def draw_bullet(d: ImageDraw.ImageDraw) -> None: + d.ellipse([20, 62, 76, 77], fill=(60, 45, 35, 38)) + pts = [(76, 48), (60, 28), (27, 28), (16, 35), (16, 61), (27, 68), (60, 68)] + d.polygon(pts, fill=(244, 196, 106, 255), outline=(87, 51, 22, 240)) + d.line(pts + [pts[0]], fill=(87, 51, 22, 240), width=4) + d.rectangle([16, 35, 29, 61], fill=(176, 111, 35, 255), outline=(87, 51, 22, 220), width=3) + d.line([(34, 36), (64, 42)], fill=(255, 237, 176, 170), width=4) + d.line([(34, 60), (58, 56)], fill=(142, 82, 30, 110), width=3) + + +def draw_sleep(d: ImageDraw.ImageDraw) -> None: + d.ellipse([17, 28, 79, 68], fill=(235, 228, 255, 255), outline=(74, 60, 144, 230), width=4) + d.ellipse([41, 31, 65, 56], fill=(112, 91, 188, 220)) + d.ellipse([52, 27, 74, 52], fill=(235, 228, 255, 255)) + d.arc([24, 27, 74, 57], 200, 330, fill=(255, 255, 255, 190), width=3) + + +def main() -> None: + registry = visual_registry() + renderer_map = { + "laxative_tablet": draw_laxative, + "mystery_tablet": draw_mystery, + "bullet": draw_bullet, + "sleep_tablet": draw_sleep, + } + for item_id, visual in registry.items(): + if item_id == "ball": + continue + palette = visual.get("palette") or {} + renderer = visual.get("renderer") + fill = rgba(palette.get("fill"), (255, 250, 241, 255)) + stroke = rgba(palette.get("stroke"), (141, 111, 72, 220)) + highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150)) + if renderer == "oval_droplet": + draw_fn = draw_drop(fill, stroke, highlight) + elif renderer == "water_bowl": + draw_fn = draw_water_bowl(palette) + elif renderer == "powder_pile": + draw_fn = draw_powder(fill, stroke, highlight) + elif renderer == "split_pill": + draw_fn = draw_pill(fill, stroke, highlight, visual.get("uiPreviewScale") or visual.get("scale") or 1.0) + else: + draw_fn = renderer_map.get(renderer) + if draw_fn: + save_icon(f"tool_{item_id}.webp", draw_fn) + + +if __name__ == "__main__": + main() diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..e095abe --- /dev/null +++ b/service-worker.js @@ -0,0 +1,57 @@ +"use strict"; + +const APP_VERSION = "15.35.0"; +const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; +const v = `v=${APP_VERSION}`; +// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. +const coreScriptNames = [ + "version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "need_behavior_system", "physics_helpers", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" +]; +const CORE_ASSETS = [ + "./", + "./index.html", + `./css/base.css?${v}`, + `./css/layout.css?${v}`, + `./css/panel.css?${v}`, + `./css/components.css?${v}`, + `./css/mobile.css?${v}`, + ...coreScriptNames.map(name => `./js/${name}.js?${v}`), +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined)); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))))); + self.clients.claim(); +}); + +function isStaticAsset(request) { + const url = new URL(request.url); + return /\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname); +} + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + if (/service-worker\.js$/i.test(url.pathname)) return; + if (request.mode === "navigate" || /index\.html$/i.test(url.pathname)) { + event.respondWith(fetch(request).then(response => { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); + return response; + }).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html")))); + return; + } + if (isStaticAsset(request)) { + event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => { + const copy = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); + return response; + }))); + } +});