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..b705442 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,8 +1,12 @@ -## 15.9.2 修正 -- 巣箱アイコンとゲーム内描画から、枠内の明色・透過っぽく見える装飾を除去。 -- アリのHPバーを、たりないと同様にダメージ直後だけ表示する方式へ変更。 -- 道具UIに「げんこつ」を追加。添付画像から正義げんこつ部分を切り抜き、上から落下してたりない・アリにダメージと吹っ飛びを与える効果を追加。 -- 初期ロード画面と読み込み進捗バーを追加。JS/CSSをdefer化し、CSS/JSのサーバーキャッシュを有効化して再訪問時の通信量を削減。 +## 15.15.0 + +- 巨大ファイル分割を実施。 +- `world.js` をコアクラス、視点/家系/戦闘/環境/空間管理/アリ/更新/配置ログ/ブートストラップに分割。 +- `tarinai.js` をコアクラス、人格・関係、病気・巣箱、欲求・アイテム、社会/移動/寿命、描画に分割。 +- `ui.js` を状態、ログ、選択詳細、レイアウト/ダイアログ、道具、イベント束縛に分割。 +- `ui_family.js` をデータ、非同期構築、レイアウト、パス生成、描画に分割。 +- 既存グローバルAPI互換を維持し、挙動変更を避ける形で分割。 +- Service Workerキャッシュと静的アセットバージョンを `15.15.0` に更新。 ## 15.9.1 修正 @@ -67,3 +71,78 @@ - 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.11.0 +- イベントUI下部にログ種別別のプッシュ通知設定を追加。ON時は左下最前面に通知表示 +- 巣がなくなったアリは空きのある別巣へ所属変更。空き巣がない場合は死亡せず彷徨う +- 女王アリを大きめに表示 +- 画像アセットをWebP化し、参照をWebPへ切り替え +- Service Workerによる静的アセットキャッシュを追加し、indexのno-cache指定を解除 + +## 15.12.0 +- 優先度S/A/B相当の効果音イベントを追加。未指定音源はWeb Audioの仮SEで再生。 +- 効果音カテゴリを「たりないの声」「通知」「操作」に分類し、イベントUI下部から個別ON/OFF可能にした。 +- 音素材差し替え用の安定IDを `docs/SOUND_IDS.md` に整理。 +- げんこつ、爆竹、喧嘩、ダメージ、死亡、誕生、アリ、病気、回復、水、草、zunchi、UI操作などに音フックを追加。 + + +## 15.12.3 +- プッシュ通知の保存キーを更新し、初期状態はOFFに戻した。 +- 効果音設定は右上の音設定で、操作音・通知音のみデフォルトON、たりないの声はデフォルトOFFに変更。 +- 操作音と通知音の内蔵仮SE音量を大幅に増加。 +- つまむ操作でたりないデータカードが開く挙動を修正。 +- 道具から配置する全オブジェクトで配置音が鳴るよう、placeItem側に配置音を統合。 +- イベントUI折り畳み時の高さを圧縮。 + + +## 15.12.4 +- 効果音の仮SE音量を再度引き上げ。 +- イベントUI内の効果音設定を廃止し、右上の音設定メニューへ統一。 +- イベントログ欄が上下端に達した時、右メニュー全体へスクロールを受け渡すよう修正。 + +## 15.15.0 +- バージョン/キャッシュ管理を `js/version.js` と `service-worker.js` に統一。 +- Service Worker登録を `updateViaCache: "none"` に変更し、`.htaccess` でService Worker本体をno-cache化。 +- `js/event_bus.js` を追加し、ログ/フェーズ/配置/地形dirty/FPS変化などをイベント化。 +- `js/performance_manager.js` を追加し、FPSレベル、アリ更新間引き、エフェクト上限、統計更新間隔、地形再描画間隔を中央管理。 +- 地面キャッシュを全走査ハッシュ方式から `terrainVersion` dirty方式へ変更。 +- SpatialGridをアリ/障害物/食料/危険物セルに拡張。 +- `?debug=1` の内部デバッグオーバーレイを追加。 +- 音システムに音ID別ゲイン、音ID別最小間隔、同時発音上限、サンプル読み込み失敗イベントを追加。 + +## v15.15.0 +- 個体パラメータ変化アイテムを追加: 下剤、プロテイン、ンイテロプ、弾薬、怪しい薬。 +- 下剤: 効果時間中3秒ごとにずんちを放出し、食事で空腹度が下がらない。 +- プロテイン/ンイテロプ: 永続の与ダメージ・病気発症確率補正を追加。両者は排他で、最後に食べたものだけ有効。 +- 弾薬: 効果時間中の歩行速度10倍と、移動中のたりない/アリ衝突ノックバックを追加。 +- 怪しい薬: 行動変容アイテム効果と病気の中からランダム2種を付与。 +- たりないデータに「現在のアイテム効果」を追加。 +- バージョンとService Workerキャッシュを15.15.0に更新。 + +## v15.18.0 +- Implemented static `app_manifest.json` and `scripts/generate_app_files.py` for HTML/Service Worker static-list synchronization. +- Split `css/styles.css` into ordered CSS files while keeping a compatibility shim. +- Added `js/effect_registry.js` and routed item-effect labels/colors/random pools/selected display text through the registry. +- Added `js/sound_pack.js`; `audio.js` now consumes sound pack gains, cooldowns, and sample paths. +- Added mobile canvas input modes: 自動 / カメラ / 道具 / 家系図. +- Expanded debug overlay with Service Worker/cache/audio/effect diagnostics. +- Added dedicated tool icons for 下剤, プロテイン, ンイテロプ, 弾薬, 怪しい薬, 水銀, 巨大薬, 矮小薬, ずんだ汁. + +## v15.19.1 +- 効果音のマスター出力、カテゴリ別ゲイン、ID別ゲインを再調整。連続再生で小さくなりにくいよう同時発音上限とWeb Audio出力経路を変更。 +- 食べ物の自然消耗を連続減少から半日に2回程度の離散消耗へ変更。衛生統計ペナルティも同じタイミングに抑制。 +- 薬系アイテムの地面表示とUIアイコンを錠剤風に変更。 +- プロテイン/ンイテロプの外観を山盛りの粉に変更。永続効果中は継続的なオーラ/粒子エフェクトを表示。 +- ずんだ汁を緑の水滴外観へ変更。雨と水滴表示は灰色寄りへ変更。 +- バージョンとService Workerキャッシュを 15.19.1 に更新。 + + +## v15.19.1 +- Removed embedded letters/sub-labels from new item sprites and tool icons. +- Refreshed parameter item UI icons; cache-busted updated icon URLs. +- Fixed firecracker placement triggering explosion sound through accident log inference. +- Made giant drug placed sprite larger and dwarf drug placed sprite smaller. +- Redrew mercury, zunda juice, and water as rain-drop style sprites. +- Changed protein/niteropu persistent visuals to red rising particles and purple falling particles. +- Fixed remaining colony chart tab label from 生活 to 精神. diff --git a/README.md b/README.md index 69725d1..2431355 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,30 @@ js/render.js Background, weather, lighting, HUD, global canvas render. js/sim_core.js Names, personalities, relationships, effects, spatial grid. js/health.js Damage memory, death-cause normalization, damage application. js/items.js Item state, lifecycle, drawing, grass/zunchi/ball/firecracker logic. -js/tarinai.js Agent state, AI, disease state, eating, conflict, reproduction, draw. -js/world.js World update, placement, weather, logs, global interactions. -js/ui.js Input, tool selection, dialogs, observation panel. -js/ui_charts.js Population/statistics charts. -js/ui_family.js Family/archive UI and cause-of-death display. +js/tarinai.js Tarinai constructor/core state only. +js/tarinai_identity_social.js Agent identity, records, personality, relations, stress/damage wrappers. +js/tarinai_disease_nest.js Diseases, water effects, nest-box entry/exit. +js/tarinai_needs_items.js Needs AI, eating/drinking, item interaction, facing, zunchi behavior. +js/tarinai_social_move_life.js Social interaction, movement, life/death. +js/tarinai_render.js Agent drawing and hit testing. +js/world.js World constructor/core state only. +js/world_view.js Field scale, camera, viewport, tool sizing, time/phase helpers. +js/world_family_social.js Family tree, birth, fights, social conflict entry points. +js/world_combat_effects.js Damage, explosions, water hose, effects, balls, collision outcomes. +js/world_environment.js Obstacles, fences, grass placement, temperature, nearest queries. +js/world_spatial_budget.js Spatial grid, draw list, budgets, compaction, performance helpers. +js/world_ants_system.js Ant updates, nest haul/founding, queen spawning. +js/world_update.js Main world update, weather, out-of-bounds sanitation, pointer info. +js/world_placement_log.js Placement, click handling, log classification/dispatch. +js/world_bootstrap.js Creates global `world` after World mixins load. +js/ui.js UI state/cache references only. +js/ui_log.js Log rendering, push notification controls, sound setting sync. +js/ui_selected.js Selected Tarinai data panel and text formatting. +js/ui_layout_dialogs.js Canvas resize, dialogs, field switching, top buttons. +js/ui_tools.js Tool size badges and tool size cycling. +js/ui_bind.js Event listeners and input binding. +js/ui_charts.js Population/statistics charts. +js/ui_family*.js Family/archive data, async build, layout, paths, render. docs/AI_CONTEXT.md Minimal implementation context for AI edits. docs/code_map.md Symbol-to-file lookup table. ``` @@ -77,3 +96,61 @@ docs/code_map.md Symbol-to-file lookup table. - \u5de3\u7bb1\u5165\u9000\u5ba4\u3092\u5c02\u7528\u72b6\u614b\u5316\u3057\u3001\u900f\u660e\u5316\u30fb\u5f53\u305f\u308a\u5224\u5b9a\u6d88\u6ec5\u30fb\u500b\u4f53\u6570\u53cd\u6620\u30fb\u9000\u51fa\u5fa9\u5e30\u3092\u5b89\u5b9a\u5316\u3002 - \u7761\u7720\u6642\u306e\u512a\u5148\u9806\u4f4d\u3092\u300c\u5de3\u7bb1 \u2192 \u5e72\u3057\u8349\u30d9\u30c3\u30c9 \u2192 \u305d\u306e\u5834\u300d\u306b\u5909\u66f4\u3002 - \u305f\u308a\u306a\u3044\u30c7\u30fc\u30bfUI\u306e\u53cb\u9054/\u6575\u5bfe\u3092\u5168\u4ef6\u30c1\u30c3\u30d7\u8868\u793a\u306b\u3057\u3001\u30af\u30ea\u30c3\u30af\u3067\u500b\u4f53\u3078\u79fb\u52d5\u3067\u304d\u308b\u3088\u3046\u306b\u5909\u66f4\u3002 + + +## 15.18.0 +- イベントUI下部にログ種別別のプッシュ通知設定を追加。ON時は左下最前面に通知表示 +- 巣がなくなったアリは空きのある別巣へ所属変更。空き巣がない場合は死亡せず彷徨う +- 女王アリを大きめに表示 +- 画像アセットをWebP化し、参照をWebPへ切り替え +- Service Workerによる静的アセットキャッシュを追加し、indexのno-cache指定を解除 + +### 効果音差し替え +v15.18.0以降、`js/audio.js` の `samplePaths` に `sfx_genkotsu_impact` などの音IDとファイルパスを追加すると、内蔵の仮SEよりその音源が優先されます。ID一覧は `docs/SOUND_IDS.md` を参照してください。 + + +## 15.18.0 +- プッシュ通知の保存キーを更新し、初期状態はOFFに戻した。 +- 効果音設定は右上の音設定で、操作音・通知音のみデフォルトON、たりないの声はデフォルトOFFに変更。 +- 操作音と通知音の内蔵仮SE音量を大幅に増加。 +- つまむ操作でたりないデータカードが開く挙動を修正。 +- 道具から配置する全オブジェクトで配置音が鳴るよう、placeItem側に配置音を統合。 +- イベントUI折り畳み時の高さを圧縮。 + + +## 15.18.0 +- 効果音の仮SE音量を再度引き上げ。 +- イベントUI内の効果音設定を廃止し、右上の音設定メニューへ統一。 +- イベントログ欄が上下端に達した時、右メニュー全体へスクロールを受け渡すよう修正。 + +## 15.18.0 内部システム更新 +- バージョン/キャッシュ管理、イベントバス、PerformanceManager、地形dirtyキャッシュ、拡張SpatialGrid、デバッグモードを追加しました。 +- `?debug=1` をURLに付けるとFPS/負荷レベル/エンティティ数/イベント履歴を確認できます。 +- 詳細は `docs/INTERNAL_SYSTEMS.md` を参照してください。 + +### v15.18.0 個体効果アイテム +- 下剤、プロテイン、ンイテロプ、弾薬、怪しい薬を追加。 +- たりないデータの「現在のアイテム効果」で、永続効果と残り時間つき効果を確認できます。 + +### v15.18.0 internal maintenance +- Static loading order is centralized in `app_manifest.json`. +- Run `python3 scripts/generate_app_files.py` after adding/removing CSS or JS files. +- Sound replacement is centralized in `js/sound_pack.js`. +- Item/disease effect metadata is centralized in `js/effect_registry.js`. + +## v15.19.1 +- 効果音のマスター出力、カテゴリ別ゲイン、ID別ゲインを再調整。連続再生で小さくなりにくいよう同時発音上限とWeb Audio出力経路を変更。 +- 食べ物の自然消耗を連続減少から半日に2回程度の離散消耗へ変更。衛生統計ペナルティも同じタイミングに抑制。 +- 薬系アイテムの地面表示とUIアイコンを錠剤風に変更。 +- プロテイン/ンイテロプの外観を山盛りの粉に変更。永続効果中は継続的なオーラ/粒子エフェクトを表示。 +- ずんだ汁を緑の水滴外観へ変更。雨と水滴表示は灰色寄りへ変更。 +- バージョンとService Workerキャッシュを 15.19.1 に更新。 + + +### v15.19.1 +- 新アイテム系スプライト/道具アイコンから文字表示を削除。 +- 爆竹配置時に爆発音が鳴る問題を修正。 +- 巨大薬/矮小薬の設置サイズを視覚的に差別化。 +- 水銀/ずんだ汁/水滴を降雨の水滴表現に合わせて再描画。 +- プロテイン/ンイテロプの永続演出を粒子表現へ変更。 +- 統計タブの「生活」表記残りを「精神」に修正。 diff --git a/app_manifest.json b/app_manifest.json new file mode 100644 index 0000000..90e5ae8 --- /dev/null +++ b/app_manifest.json @@ -0,0 +1,67 @@ +{ + "version": "15.19.1", + "css": [ + "css/base.css", + "css/layout.css", + "css/panel.css", + "css/components.css", + "css/mobile.css" + ], + "js": [ + "js/version.js", + "js/event_bus.js", + "js/performance_manager.js", + "js/effect_registry.js", + "js/sound_pack.js", + "js/data.js", + "js/math.js", + "js/assets.js", + "js/audio.js", + "js/render.js", + "js/sim_core.js", + "js/items.js", + "js/ants.js", + "js/health.js", + "js/tarinai.js", + "js/tarinai_identity_social.js", + "js/tarinai_disease_nest.js", + "js/tarinai_item_effects.js", + "js/tarinai_needs_items.js", + "js/tarinai_social_move_life.js", + "js/tarinai_render.js", + "js/world.js", + "js/world_view.js", + "js/world_family_social.js", + "js/world_combat_effects.js", + "js/world_environment.js", + "js/world_spatial_budget.js", + "js/world_ants_system.js", + "js/world_update.js", + "js/world_placement_log.js", + "js/world_bootstrap.js", + "js/text_catalog.js", + "js/ui.js", + "js/ui_log.js", + "js/ui_selected.js", + "js/ui_layout_dialogs.js", + "js/ui_tools.js", + "js/ui_bind.js", + "js/ui_charts.js", + "js/ui_family.js", + "js/ui_family_data.js", + "js/ui_family_async.js", + "js/ui_family_layout.js", + "js/ui_family_paths.js", + "js/ui_family_render.js", + "js/main.js", + "js/debug_tools.js" + ], + "assetGlobs": [ + "assets/ui/*.webp", + "assets/objects/*.webp", + "assets/sprites/*.webp", + "assets/sounds/*.wav", + "assets/sounds/*.mp3" + ], + "notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list." +} diff --git a/assets/objects/ant_corpse.png b/assets/objects/ant_corpse.png deleted file mode 100644 index 2f19212..0000000 Binary files a/assets/objects/ant_corpse.png and /dev/null differ diff --git a/assets/objects/ant_corpse.webp b/assets/objects/ant_corpse.webp new file mode 100644 index 0000000..ec1fdc8 Binary files /dev/null and b/assets/objects/ant_corpse.webp differ diff --git a/assets/objects/ant_queen.png b/assets/objects/ant_queen.png deleted file mode 100644 index e26b998..0000000 Binary files a/assets/objects/ant_queen.png and /dev/null differ diff --git a/assets/objects/ant_queen.webp b/assets/objects/ant_queen.webp new file mode 100644 index 0000000..4ffcce9 Binary files /dev/null and b/assets/objects/ant_queen.webp differ diff --git a/assets/objects/ant_worker.png b/assets/objects/ant_worker.png deleted file mode 100644 index 59c0142..0000000 Binary files a/assets/objects/ant_worker.png and /dev/null differ diff --git a/assets/objects/ant_worker.webp b/assets/objects/ant_worker.webp new file mode 100644 index 0000000..3845cd8 Binary files /dev/null and b/assets/objects/ant_worker.webp differ diff --git a/assets/objects/ant_worker_mid.png b/assets/objects/ant_worker_mid.png deleted file mode 100644 index 226d40f..0000000 Binary files a/assets/objects/ant_worker_mid.png and /dev/null differ diff --git a/assets/objects/ant_worker_mid.webp b/assets/objects/ant_worker_mid.webp new file mode 100644 index 0000000..9423f5b Binary files /dev/null and b/assets/objects/ant_worker_mid.webp differ diff --git a/assets/objects/ant_worker_small.png b/assets/objects/ant_worker_small.png deleted file mode 100644 index 7f9b4c3..0000000 Binary files a/assets/objects/ant_worker_small.png and /dev/null differ diff --git a/assets/objects/ant_worker_small.webp b/assets/objects/ant_worker_small.webp new file mode 100644 index 0000000..2dde4c6 Binary files /dev/null and b/assets/objects/ant_worker_small.webp differ diff --git a/assets/objects/ant_worker_small_b.png b/assets/objects/ant_worker_small_b.png deleted file mode 100644 index 4653f4d..0000000 Binary files a/assets/objects/ant_worker_small_b.png and /dev/null differ diff --git a/assets/objects/ant_worker_small_b.webp b/assets/objects/ant_worker_small_b.webp new file mode 100644 index 0000000..734d40c Binary files /dev/null and b/assets/objects/ant_worker_small_b.webp differ diff --git a/assets/objects/genkotsu.png b/assets/objects/genkotsu.png deleted file mode 100644 index 8160368..0000000 Binary files a/assets/objects/genkotsu.png and /dev/null differ diff --git a/assets/objects/genkotsu.webp b/assets/objects/genkotsu.webp new file mode 100644 index 0000000..af7ff3b Binary files /dev/null and b/assets/objects/genkotsu.webp differ diff --git a/assets/objects/zunchi.png b/assets/objects/zunchi.png deleted file mode 100644 index 70a90d7..0000000 Binary files a/assets/objects/zunchi.png and /dev/null differ diff --git a/assets/objects/zunchi.webp b/assets/objects/zunchi.webp new file mode 100644 index 0000000..5e97dc6 Binary files /dev/null and b/assets/objects/zunchi.webp differ diff --git a/assets/objects/zunchi_02.png b/assets/objects/zunchi_02.png deleted file mode 100644 index a2c270d..0000000 Binary files a/assets/objects/zunchi_02.png and /dev/null differ diff --git a/assets/objects/zunchi_02.webp b/assets/objects/zunchi_02.webp new file mode 100644 index 0000000..73a377a Binary files /dev/null and b/assets/objects/zunchi_02.webp differ diff --git a/assets/sprites/tarinai_01_smile.png b/assets/sprites/tarinai_01_smile.png deleted file mode 100644 index 2c64d7c..0000000 Binary files a/assets/sprites/tarinai_01_smile.png and /dev/null differ diff --git a/assets/sprites/tarinai_01_smile.webp b/assets/sprites/tarinai_01_smile.webp new file mode 100644 index 0000000..bcaab40 Binary files /dev/null and b/assets/sprites/tarinai_01_smile.webp differ diff --git a/assets/sprites/tarinai_02_angry.png b/assets/sprites/tarinai_02_angry.png deleted file mode 100644 index 557b003..0000000 Binary files a/assets/sprites/tarinai_02_angry.png and /dev/null differ diff --git a/assets/sprites/tarinai_02_angry.webp b/assets/sprites/tarinai_02_angry.webp new file mode 100644 index 0000000..643c5ad Binary files /dev/null and b/assets/sprites/tarinai_02_angry.webp differ diff --git a/assets/sprites/tarinai_03_teary.png b/assets/sprites/tarinai_03_teary.png deleted file mode 100644 index bdd7bc5..0000000 Binary files a/assets/sprites/tarinai_03_teary.png and /dev/null differ diff --git a/assets/sprites/tarinai_03_teary.webp b/assets/sprites/tarinai_03_teary.webp new file mode 100644 index 0000000..85ecf3b Binary files /dev/null and b/assets/sprites/tarinai_03_teary.webp differ diff --git a/assets/sprites/tarinai_04_jito.png b/assets/sprites/tarinai_04_jito.png deleted file mode 100644 index 3cd4c57..0000000 Binary files a/assets/sprites/tarinai_04_jito.png and /dev/null differ diff --git a/assets/sprites/tarinai_04_jito.webp b/assets/sprites/tarinai_04_jito.webp new file mode 100644 index 0000000..068dd35 Binary files /dev/null and b/assets/sprites/tarinai_04_jito.webp differ diff --git a/assets/sprites/tarinai_05_drool.png b/assets/sprites/tarinai_05_drool.png deleted file mode 100644 index bbd9f12..0000000 Binary files a/assets/sprites/tarinai_05_drool.png and /dev/null differ diff --git a/assets/sprites/tarinai_05_drool.webp b/assets/sprites/tarinai_05_drool.webp new file mode 100644 index 0000000..b0cca44 Binary files /dev/null and b/assets/sprites/tarinai_05_drool.webp differ diff --git a/assets/sprites/tarinai_06_cry.png b/assets/sprites/tarinai_06_cry.png deleted file mode 100644 index 3f25d00..0000000 Binary files a/assets/sprites/tarinai_06_cry.png and /dev/null differ diff --git a/assets/sprites/tarinai_06_cry.webp b/assets/sprites/tarinai_06_cry.webp new file mode 100644 index 0000000..a823445 Binary files /dev/null and b/assets/sprites/tarinai_06_cry.webp differ diff --git a/assets/sprites/tarinai_07_sleep.png b/assets/sprites/tarinai_07_sleep.png deleted file mode 100644 index 48c44b2..0000000 Binary files a/assets/sprites/tarinai_07_sleep.png and /dev/null differ diff --git a/assets/sprites/tarinai_07_sleep.webp b/assets/sprites/tarinai_07_sleep.webp new file mode 100644 index 0000000..c378529 Binary files /dev/null and b/assets/sprites/tarinai_07_sleep.webp differ diff --git a/assets/sprites/tarinai_08_pokan.png b/assets/sprites/tarinai_08_pokan.png deleted file mode 100644 index 8c8a24b..0000000 Binary files a/assets/sprites/tarinai_08_pokan.png and /dev/null differ diff --git a/assets/sprites/tarinai_08_pokan.webp b/assets/sprites/tarinai_08_pokan.webp new file mode 100644 index 0000000..e1e6b2a Binary files /dev/null and b/assets/sprites/tarinai_08_pokan.webp differ diff --git a/assets/sprites/tarinai_09_stretch.png b/assets/sprites/tarinai_09_stretch.png deleted file mode 100644 index f6fe904..0000000 Binary files a/assets/sprites/tarinai_09_stretch.png and /dev/null differ diff --git a/assets/sprites/tarinai_09_stretch.webp b/assets/sprites/tarinai_09_stretch.webp new file mode 100644 index 0000000..c8a245b Binary files /dev/null and b/assets/sprites/tarinai_09_stretch.webp differ diff --git a/assets/sprites/tarinai_10_hurt.png b/assets/sprites/tarinai_10_hurt.png deleted file mode 100644 index 0598a61..0000000 Binary files a/assets/sprites/tarinai_10_hurt.png and /dev/null differ diff --git a/assets/sprites/tarinai_10_hurt.webp b/assets/sprites/tarinai_10_hurt.webp new file mode 100644 index 0000000..e53c759 Binary files /dev/null and b/assets/sprites/tarinai_10_hurt.webp differ diff --git a/assets/sprites/tarinai_11_weak.png b/assets/sprites/tarinai_11_weak.png deleted file mode 100644 index 9b27132..0000000 Binary files a/assets/sprites/tarinai_11_weak.png and /dev/null differ diff --git a/assets/sprites/tarinai_11_weak.webp b/assets/sprites/tarinai_11_weak.webp new file mode 100644 index 0000000..4a36e52 Binary files /dev/null and b/assets/sprites/tarinai_11_weak.webp differ diff --git a/assets/sprites/tarinai_12_back.png b/assets/sprites/tarinai_12_back.png deleted file mode 100644 index 4418fb0..0000000 Binary files a/assets/sprites/tarinai_12_back.png and /dev/null differ diff --git a/assets/sprites/tarinai_12_back.webp b/assets/sprites/tarinai_12_back.webp new file mode 100644 index 0000000..3a60bfb Binary files /dev/null and b/assets/sprites/tarinai_12_back.webp differ diff --git a/assets/sprites/tarinai_13_flee.png b/assets/sprites/tarinai_13_flee.png deleted file mode 100644 index d05bcb7..0000000 Binary files a/assets/sprites/tarinai_13_flee.png and /dev/null differ diff --git a/assets/sprites/tarinai_13_flee.webp b/assets/sprites/tarinai_13_flee.webp new file mode 100644 index 0000000..047449c Binary files /dev/null and b/assets/sprites/tarinai_13_flee.webp differ diff --git a/assets/sprites/tarinai_14_zunda_eat.png b/assets/sprites/tarinai_14_zunda_eat.png deleted file mode 100644 index 8e71ba0..0000000 Binary files a/assets/sprites/tarinai_14_zunda_eat.png and /dev/null differ diff --git a/assets/sprites/tarinai_14_zunda_eat.webp b/assets/sprites/tarinai_14_zunda_eat.webp new file mode 100644 index 0000000..06c956b Binary files /dev/null and b/assets/sprites/tarinai_14_zunda_eat.webp differ diff --git a/assets/sprites/tarinai_15_hurt2.png b/assets/sprites/tarinai_15_hurt2.png deleted file mode 100644 index b92d10e..0000000 Binary files a/assets/sprites/tarinai_15_hurt2.png and /dev/null differ diff --git a/assets/sprites/tarinai_15_hurt2.webp b/assets/sprites/tarinai_15_hurt2.webp new file mode 100644 index 0000000..a79cf25 Binary files /dev/null and b/assets/sprites/tarinai_15_hurt2.webp differ diff --git a/assets/sprites/tarinai_16_normal_smirk.png b/assets/sprites/tarinai_16_normal_smirk.png deleted file mode 100644 index 73e343a..0000000 Binary files a/assets/sprites/tarinai_16_normal_smirk.png and /dev/null differ diff --git a/assets/sprites/tarinai_16_normal_smirk.webp b/assets/sprites/tarinai_16_normal_smirk.webp new file mode 100644 index 0000000..4029f70 Binary files /dev/null and b/assets/sprites/tarinai_16_normal_smirk.webp differ diff --git a/assets/sprites/tarinai_17_normal_tongue.png b/assets/sprites/tarinai_17_normal_tongue.png deleted file mode 100644 index 5edf621..0000000 Binary files a/assets/sprites/tarinai_17_normal_tongue.png and /dev/null differ diff --git a/assets/sprites/tarinai_17_normal_tongue.webp b/assets/sprites/tarinai_17_normal_tongue.webp new file mode 100644 index 0000000..8623cec Binary files /dev/null and b/assets/sprites/tarinai_17_normal_tongue.webp differ diff --git a/assets/sprites/tarinai_18_fear.png b/assets/sprites/tarinai_18_fear.png deleted file mode 100644 index aa04096..0000000 Binary files a/assets/sprites/tarinai_18_fear.png and /dev/null differ diff --git a/assets/sprites/tarinai_18_fear.webp b/assets/sprites/tarinai_18_fear.webp new file mode 100644 index 0000000..245190b Binary files /dev/null and b/assets/sprites/tarinai_18_fear.webp differ diff --git a/assets/sprites/tarinai_19_flee_fear2.png b/assets/sprites/tarinai_19_flee_fear2.png deleted file mode 100644 index dddfb4b..0000000 Binary files a/assets/sprites/tarinai_19_flee_fear2.png and /dev/null differ diff --git a/assets/sprites/tarinai_19_flee_fear2.webp b/assets/sprites/tarinai_19_flee_fear2.webp new file mode 100644 index 0000000..f10ac23 Binary files /dev/null and b/assets/sprites/tarinai_19_flee_fear2.webp differ diff --git a/assets/sprites/tarinai_20_sleep2.png b/assets/sprites/tarinai_20_sleep2.png deleted file mode 100644 index 4d6bee7..0000000 Binary files a/assets/sprites/tarinai_20_sleep2.png and /dev/null differ diff --git a/assets/sprites/tarinai_20_sleep2.webp b/assets/sprites/tarinai_20_sleep2.webp new file mode 100644 index 0000000..63b558f Binary files /dev/null and b/assets/sprites/tarinai_20_sleep2.webp differ diff --git a/assets/sprites/tarinai_21_normal_happy.png b/assets/sprites/tarinai_21_normal_happy.png deleted file mode 100644 index b2730c5..0000000 Binary files a/assets/sprites/tarinai_21_normal_happy.png and /dev/null differ diff --git a/assets/sprites/tarinai_21_normal_happy.webp b/assets/sprites/tarinai_21_normal_happy.webp new file mode 100644 index 0000000..2d1df1f Binary files /dev/null and b/assets/sprites/tarinai_21_normal_happy.webp differ diff --git a/assets/sprites/tarinai_22_fear_blue.png b/assets/sprites/tarinai_22_fear_blue.png deleted file mode 100644 index 0d5d167..0000000 Binary files a/assets/sprites/tarinai_22_fear_blue.png and /dev/null differ diff --git a/assets/sprites/tarinai_22_fear_blue.webp b/assets/sprites/tarinai_22_fear_blue.webp new file mode 100644 index 0000000..99eb408 Binary files /dev/null and b/assets/sprites/tarinai_22_fear_blue.webp differ diff --git a/assets/sprites/tarinai_23_fear_cry.png b/assets/sprites/tarinai_23_fear_cry.png deleted file mode 100644 index 869217b..0000000 Binary files a/assets/sprites/tarinai_23_fear_cry.png and /dev/null differ diff --git a/assets/sprites/tarinai_23_fear_cry.webp b/assets/sprites/tarinai_23_fear_cry.webp new file mode 100644 index 0000000..cd7687a Binary files /dev/null and b/assets/sprites/tarinai_23_fear_cry.webp differ diff --git a/assets/sprites/tarinai_24_stress_dizzy.png b/assets/sprites/tarinai_24_stress_dizzy.png deleted file mode 100644 index f9474ca..0000000 Binary files a/assets/sprites/tarinai_24_stress_dizzy.png and /dev/null differ diff --git a/assets/sprites/tarinai_24_stress_dizzy.webp b/assets/sprites/tarinai_24_stress_dizzy.webp new file mode 100644 index 0000000..b0367ed Binary files /dev/null and b/assets/sprites/tarinai_24_stress_dizzy.webp differ diff --git a/assets/sprites/tarinai_25_stress_sweat.png b/assets/sprites/tarinai_25_stress_sweat.png deleted file mode 100644 index 1385ebf..0000000 Binary files a/assets/sprites/tarinai_25_stress_sweat.png and /dev/null differ diff --git a/assets/sprites/tarinai_25_stress_sweat.webp b/assets/sprites/tarinai_25_stress_sweat.webp new file mode 100644 index 0000000..1a537e1 Binary files /dev/null and b/assets/sprites/tarinai_25_stress_sweat.webp differ diff --git a/assets/sprites/tarinai_26_intimidate.png b/assets/sprites/tarinai_26_intimidate.png deleted file mode 100644 index 1813759..0000000 Binary files a/assets/sprites/tarinai_26_intimidate.png and /dev/null differ diff --git a/assets/sprites/tarinai_26_intimidate.webp b/assets/sprites/tarinai_26_intimidate.webp new file mode 100644 index 0000000..4575fb1 Binary files /dev/null and b/assets/sprites/tarinai_26_intimidate.webp differ diff --git a/assets/sprites/tarinai_27_birth_ritual.png b/assets/sprites/tarinai_27_birth_ritual.png deleted file mode 100644 index 7d13ae8..0000000 Binary files a/assets/sprites/tarinai_27_birth_ritual.png and /dev/null differ diff --git a/assets/sprites/tarinai_27_birth_ritual.webp b/assets/sprites/tarinai_27_birth_ritual.webp new file mode 100644 index 0000000..1a75b86 Binary files /dev/null and b/assets/sprites/tarinai_27_birth_ritual.webp differ diff --git a/assets/sprites/tarinai_28_zunchi_slave.png b/assets/sprites/tarinai_28_zunchi_slave.png deleted file mode 100644 index 7722fc4..0000000 Binary files a/assets/sprites/tarinai_28_zunchi_slave.png and /dev/null differ diff --git a/assets/sprites/tarinai_28_zunchi_slave.webp b/assets/sprites/tarinai_28_zunchi_slave.webp new file mode 100644 index 0000000..1842738 Binary files /dev/null and b/assets/sprites/tarinai_28_zunchi_slave.webp differ diff --git a/assets/ui/ecology_hp_stress.png b/assets/ui/ecology_hp_stress.png deleted file mode 100644 index b1b0a7d..0000000 Binary files a/assets/ui/ecology_hp_stress.png and /dev/null differ diff --git a/assets/ui/ecology_hp_stress.webp b/assets/ui/ecology_hp_stress.webp new file mode 100644 index 0000000..f507df7 Binary files /dev/null and b/assets/ui/ecology_hp_stress.webp differ diff --git a/assets/ui/tool_ammo.webp b/assets/ui/tool_ammo.webp new file mode 100644 index 0000000..31b2db8 Binary files /dev/null and b/assets/ui/tool_ammo.webp differ diff --git a/assets/ui/tool_ant_nest.png b/assets/ui/tool_ant_nest.png deleted file mode 100644 index c854138..0000000 Binary files a/assets/ui/tool_ant_nest.png and /dev/null differ diff --git a/assets/ui/tool_ant_nest.webp b/assets/ui/tool_ant_nest.webp new file mode 100644 index 0000000..2047162 Binary files /dev/null and b/assets/ui/tool_ant_nest.webp differ diff --git a/assets/ui/tool_ball.png b/assets/ui/tool_ball.png deleted file mode 100644 index e58011f..0000000 Binary files a/assets/ui/tool_ball.png and /dev/null differ diff --git a/assets/ui/tool_ball.webp b/assets/ui/tool_ball.webp new file mode 100644 index 0000000..91b6621 Binary files /dev/null and b/assets/ui/tool_ball.webp differ diff --git a/assets/ui/tool_bed.png b/assets/ui/tool_bed.png deleted file mode 100644 index c9243ec..0000000 Binary files a/assets/ui/tool_bed.png and /dev/null differ diff --git a/assets/ui/tool_bed.webp b/assets/ui/tool_bed.webp new file mode 100644 index 0000000..5c97940 Binary files /dev/null and b/assets/ui/tool_bed.webp differ diff --git a/assets/ui/tool_delete.png b/assets/ui/tool_delete.png deleted file mode 100644 index e518a51..0000000 Binary files a/assets/ui/tool_delete.png and /dev/null differ diff --git a/assets/ui/tool_delete.webp b/assets/ui/tool_delete.webp new file mode 100644 index 0000000..cf7788c Binary files /dev/null and b/assets/ui/tool_delete.webp differ diff --git a/assets/ui/tool_dwarf_drug.webp b/assets/ui/tool_dwarf_drug.webp new file mode 100644 index 0000000..1e45e0f Binary files /dev/null and b/assets/ui/tool_dwarf_drug.webp differ diff --git a/assets/ui/tool_fence_h.png b/assets/ui/tool_fence_h.png deleted file mode 100644 index 74872d7..0000000 Binary files a/assets/ui/tool_fence_h.png and /dev/null differ diff --git a/assets/ui/tool_fence_h.webp b/assets/ui/tool_fence_h.webp new file mode 100644 index 0000000..cd5f00d Binary files /dev/null and b/assets/ui/tool_fence_h.webp differ diff --git a/assets/ui/tool_fence_v.png b/assets/ui/tool_fence_v.png deleted file mode 100644 index d5bcf85..0000000 Binary files a/assets/ui/tool_fence_v.png and /dev/null differ diff --git a/assets/ui/tool_fence_v.webp b/assets/ui/tool_fence_v.webp new file mode 100644 index 0000000..5015e8a Binary files /dev/null and b/assets/ui/tool_fence_v.webp differ diff --git a/assets/ui/tool_fight_mochi.png b/assets/ui/tool_fight_mochi.png deleted file mode 100644 index 9c54caa..0000000 Binary files a/assets/ui/tool_fight_mochi.png and /dev/null differ diff --git a/assets/ui/tool_fight_mochi.webp b/assets/ui/tool_fight_mochi.webp new file mode 100644 index 0000000..b61a598 Binary files /dev/null and b/assets/ui/tool_fight_mochi.webp differ diff --git a/assets/ui/tool_firecracker.png b/assets/ui/tool_firecracker.png deleted file mode 100644 index c530161..0000000 Binary files a/assets/ui/tool_firecracker.png and /dev/null differ diff --git a/assets/ui/tool_firecracker.webp b/assets/ui/tool_firecracker.webp new file mode 100644 index 0000000..6a65c7d Binary files /dev/null and b/assets/ui/tool_firecracker.webp differ diff --git a/assets/ui/tool_genkotsu.png b/assets/ui/tool_genkotsu.png deleted file mode 100644 index 8160368..0000000 Binary files a/assets/ui/tool_genkotsu.png and /dev/null differ diff --git a/assets/ui/tool_genkotsu.webp b/assets/ui/tool_genkotsu.webp new file mode 100644 index 0000000..427095e Binary files /dev/null and b/assets/ui/tool_genkotsu.webp differ diff --git a/assets/ui/tool_giant_drug.webp b/assets/ui/tool_giant_drug.webp new file mode 100644 index 0000000..9e0e09e Binary files /dev/null and b/assets/ui/tool_giant_drug.webp differ diff --git a/assets/ui/tool_grass.png b/assets/ui/tool_grass.png deleted file mode 100644 index 38020e3..0000000 Binary files a/assets/ui/tool_grass.png and /dev/null differ diff --git a/assets/ui/tool_grass.webp b/assets/ui/tool_grass.webp new file mode 100644 index 0000000..83bd48e Binary files /dev/null and b/assets/ui/tool_grass.webp differ diff --git a/assets/ui/tool_laxative.webp b/assets/ui/tool_laxative.webp new file mode 100644 index 0000000..9c7a599 Binary files /dev/null and b/assets/ui/tool_laxative.webp differ diff --git a/assets/ui/tool_love_mochi.png b/assets/ui/tool_love_mochi.png deleted file mode 100644 index 73cf643..0000000 Binary files a/assets/ui/tool_love_mochi.png and /dev/null differ diff --git a/assets/ui/tool_love_mochi.webp b/assets/ui/tool_love_mochi.webp new file mode 100644 index 0000000..5c8b79f Binary files /dev/null and b/assets/ui/tool_love_mochi.webp differ diff --git a/assets/ui/tool_mercury.webp b/assets/ui/tool_mercury.webp new file mode 100644 index 0000000..b8cdac4 Binary files /dev/null and b/assets/ui/tool_mercury.webp differ diff --git a/assets/ui/tool_mystery_drug.webp b/assets/ui/tool_mystery_drug.webp new file mode 100644 index 0000000..62694ff Binary files /dev/null and b/assets/ui/tool_mystery_drug.webp differ diff --git a/assets/ui/tool_nest_box.png b/assets/ui/tool_nest_box.png deleted file mode 100644 index 4f60156..0000000 Binary files a/assets/ui/tool_nest_box.png and /dev/null differ diff --git a/assets/ui/tool_nest_box.webp b/assets/ui/tool_nest_box.webp new file mode 100644 index 0000000..5cc7cd6 Binary files /dev/null and b/assets/ui/tool_nest_box.webp differ diff --git a/assets/ui/tool_new.png b/assets/ui/tool_new.png deleted file mode 100644 index 3b1e13f..0000000 Binary files a/assets/ui/tool_new.png and /dev/null differ diff --git a/assets/ui/tool_new.webp b/assets/ui/tool_new.webp new file mode 100644 index 0000000..f55ee81 Binary files /dev/null and b/assets/ui/tool_new.webp differ diff --git a/assets/ui/tool_niteropu.webp b/assets/ui/tool_niteropu.webp new file mode 100644 index 0000000..d50ba60 Binary files /dev/null and b/assets/ui/tool_niteropu.webp differ diff --git a/assets/ui/tool_observe.png b/assets/ui/tool_observe.png deleted file mode 100644 index 64f6964..0000000 Binary files a/assets/ui/tool_observe.png and /dev/null differ diff --git a/assets/ui/tool_observe.webp b/assets/ui/tool_observe.webp new file mode 100644 index 0000000..6c2b3ec Binary files /dev/null and b/assets/ui/tool_observe.webp differ diff --git a/assets/ui/tool_protein.webp b/assets/ui/tool_protein.webp new file mode 100644 index 0000000..8d78b1a Binary files /dev/null and b/assets/ui/tool_protein.webp differ diff --git a/assets/ui/tool_sleep_drug.png b/assets/ui/tool_sleep_drug.png deleted file mode 100644 index c8d0f5a..0000000 Binary files a/assets/ui/tool_sleep_drug.png and /dev/null differ diff --git a/assets/ui/tool_sleep_drug.webp b/assets/ui/tool_sleep_drug.webp new file mode 100644 index 0000000..4c4c923 Binary files /dev/null and b/assets/ui/tool_sleep_drug.webp differ diff --git a/assets/ui/tool_stone.png b/assets/ui/tool_stone.png deleted file mode 100644 index 8bf6596..0000000 Binary files a/assets/ui/tool_stone.png and /dev/null differ diff --git a/assets/ui/tool_stone.webp b/assets/ui/tool_stone.webp new file mode 100644 index 0000000..dd0ce27 Binary files /dev/null and b/assets/ui/tool_stone.webp differ diff --git a/assets/ui/tool_sweet.png b/assets/ui/tool_sweet.png deleted file mode 100644 index 5560116..0000000 Binary files a/assets/ui/tool_sweet.png and /dev/null differ diff --git a/assets/ui/tool_sweet.webp b/assets/ui/tool_sweet.webp new file mode 100644 index 0000000..39a38c9 Binary files /dev/null and b/assets/ui/tool_sweet.webp differ diff --git a/assets/ui/tool_water.png b/assets/ui/tool_water.png deleted file mode 100644 index 8270a2a..0000000 Binary files a/assets/ui/tool_water.png and /dev/null differ diff --git a/assets/ui/tool_water.webp b/assets/ui/tool_water.webp new file mode 100644 index 0000000..b8cdac4 Binary files /dev/null and b/assets/ui/tool_water.webp differ diff --git a/assets/ui/tool_water_bowl.png b/assets/ui/tool_water_bowl.png deleted file mode 100644 index d934cfb..0000000 Binary files a/assets/ui/tool_water_bowl.png and /dev/null differ diff --git a/assets/ui/tool_water_bowl.webp b/assets/ui/tool_water_bowl.webp new file mode 100644 index 0000000..3dc7384 Binary files /dev/null and b/assets/ui/tool_water_bowl.webp differ diff --git a/assets/ui/tool_water_hose.png b/assets/ui/tool_water_hose.png deleted file mode 100644 index 31cb91a..0000000 Binary files a/assets/ui/tool_water_hose.png and /dev/null differ diff --git a/assets/ui/tool_water_hose.webp b/assets/ui/tool_water_hose.webp new file mode 100644 index 0000000..0b91bf1 Binary files /dev/null and b/assets/ui/tool_water_hose.webp differ diff --git a/assets/ui/tool_zunchi.png b/assets/ui/tool_zunchi.png deleted file mode 100644 index d7e5168..0000000 Binary files a/assets/ui/tool_zunchi.png and /dev/null differ diff --git a/assets/ui/tool_zunchi.webp b/assets/ui/tool_zunchi.webp new file mode 100644 index 0000000..560f113 Binary files /dev/null and b/assets/ui/tool_zunchi.webp differ diff --git a/assets/ui/tool_zunda_juice.webp b/assets/ui/tool_zunda_juice.webp new file mode 100644 index 0000000..c59324c Binary files /dev/null and b/assets/ui/tool_zunda_juice.webp differ diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..799d6e5 --- /dev/null +++ b/css/base.css @@ -0,0 +1,411 @@ +/* Generated CSS split from styles.css; preserve load order. */ +@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap"); + +:root { + --bg: #f7f5ec; + --panel: rgba(255,251,245,0.90); + --ink: #2a241d; + --muted: #756a5e; + --line: rgba(84, 68, 48, 0.18); + --accent: #9ce84b; + --accent-2: #f5a8b7; + --danger: #b64545; + --shadow: 0 18px 50px rgba(64, 48, 32, 0.14); + --radius: 18px; + --bubble-font: "Yomogi", "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", system-ui, sans-serif; + background: + radial-gradient(circle at 12% 0%, rgba(168, 228, 113, 0.28), transparent 33%), + radial-gradient(circle at 88% 8%, rgba(255, 222, 147, 0.24), transparent 28%), + radial-gradient(circle at 82% 90%, rgba(126, 181, 120, 0.16), transparent 34%), + linear-gradient(180deg, #dff0ff 0%, #edf8ff 12%, #f9f6eb 46%, var(--bg) 100%); + color: var(--ink); + min-height: 100vh; + overflow-x: hidden; + position: relative; +} + +button { + font: inherit; + color: inherit; +} + +.loading-screen { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%), + linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98)); + transition: opacity 180ms ease, visibility 180ms ease; +} +.loading-screen.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} +.loading-panel { + width: min(420px, 92vw); + padding: 24px 22px; + border-radius: 24px; + border: 1px solid rgba(84, 68, 48, 0.16); + background: rgba(255, 252, 246, 0.92); + box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16); + text-align: center; +} +.loading-title { + font-weight: 700; + font-size: 18px; + letter-spacing: 0.04em; + margin-bottom: 8px; +} +.loading-text { + color: var(--muted); + font-size: 13px; + line-height: 1.55; + min-height: 1.55em; +} +.loading-bar { + height: 12px; + margin: 18px 0 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(90, 70, 48, 0.12); +} +.loading-bar-fill { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #9ce84b, #f0d35b); + transition: width 160ms ease; +} +.loading-percent { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +#app { + width: min(1480px, 100vw); + margin: 0 auto; + padding: 18px; + position: relative; +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; + margin-bottom: 16px; +} + +.top-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; + min-width: 0; +} + +.room-link { + text-decoration: none; + white-space: nowrap; + background: rgba(255,255,255,0.86); +} + +h1 { + margin: 0; + font-size: clamp(24px, 3.4vw, 42px); + letter-spacing: 0.03em; + white-space: nowrap; +} + +.subtitle { + margin: 0; + color: var(--muted); + font-size: clamp(11px, 1.1vw, 13px); + line-height: 1.35; + white-space: nowrap; +} + +.top-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.btn, .tool { + border: 1px solid var(--line); + background: rgba(255,255,255,0.76); + border-radius: 999px; + padding: 9px 13px; + cursor: pointer; + box-shadow: 0 4px 14px rgba(80,60,40,0.06); + transition: transform 130ms ease, background 130ms ease, border-color 130ms ease; + user-select: none; +} + +.btn:hover, .tool:hover { transform: translateY(-1px); background: #fff; } +.btn:active, .tool:active { transform: translateY(1px); } + +.btn.primary { + background: linear-gradient(180deg, #a9ef5d, #8bdc3f); + border-color: rgba(89, 128, 47, 0.28); +} + +.btn.danger { + color: #7a2828; +} + +.layout { + display: grid; + grid-template-columns: minmax(520px, 1fr) 380px; + gap: 16px; + align-items: stretch; +} + +.stage-wrap { + position: relative; + background: linear-gradient(180deg, rgba(255,255,255,0.46), rgba(246, 240, 228, 0.56)); + border: 1px solid rgba(84, 68, 48, 0.12); + border-radius: calc(var(--radius) + 8px); + overflow: hidden; + box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24); + min-height: var(--field-min-height, 620px); +} + +#gameCanvas { + width: 100%; + height: var(--field-height, calc(100vh - 140px)); + min-height: var(--field-min-height, 620px); + display: block; + cursor: crosshair; + background: linear-gradient(180deg, #cfe3d3, #d9d0ad); +} + +.field-dialog { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 18px; + background: rgba(34, 29, 22, 0.38); + backdrop-filter: blur(3px); +} +.field-dialog.hidden { display: none; } +.field-dialog-panel { + width: min(440px, 100%); + max-height: calc(100vh - 36px); + overflow: auto; + border-radius: 16px; + padding: 16px; + background: rgba(255, 252, 245, 0.96); + border: 1px solid rgba(84,68,48,0.16); + box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22); +} +.field-dialog-panel h2 { + margin: 0 0 12px; + font-size: 17px; +} +.field-choice-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.field-choice { + min-height: 74px; + border: 1px solid rgba(84,68,48,0.16); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72)); + cursor: pointer; + color: var(--ink); +} +.field-choice:hover { border-color: rgba(112,178,66,0.45); } +.field-choice strong, .field-choice span { display: block; } +.field-choice strong { font-size: 15px; } +.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; } +.field-dialog-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 8px; +} +.archive-reset-dialog-panel { + width: min(420px, 100%); +} +.archive-reset-actions { + justify-content: space-between; + gap: 10px; +} +.archive-reset-actions .btn { + min-width: 132px; +} + + +.ecology-dialog-panel { + width: min(760px, 100%); + max-height: calc(100vh - 36px); + overflow-y: auto; + overscroll-behavior: auto; +} +.ecology-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.ecology-card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 10px; + border-radius: 14px; + background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76)); + border: 1px solid rgba(84,68,48,0.12); +} +.ecology-card img { + width: 72px; + height: 72px; + object-fit: contain; + filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10)); +} +.ecology-card strong { + display: block; + margin-bottom: 4px; + font-size: 15px; +} +.ecology-card p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.40; +} + +.toast { + position: absolute; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + background: rgba(42,36,29,0.86); + color: white; + padding: 10px 16px; + border-radius: 999px; + font-size: 14px; + pointer-events: none; + opacity: 1; + transition: opacity 200ms ease, transform 200ms ease; +} +.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(8px); } + +.panel { + display: flex; + flex-direction: column; + gap: 12px; + height: calc(100vh - 120px); + max-height: calc(100vh - 120px); + overflow-y: auto; + overflow-x: hidden; + padding-right: 3px; +} + +.card { + position: relative; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + box-shadow: 0 10px 30px rgba(64, 48, 32, 0.08); + backdrop-filter: blur(10px); + position: relative; +} + +.card h2 { + margin: 0 0 12px; + font-size: 17px; + letter-spacing: 0.03em; +} + +.tool-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 8px; +} + +.tool { + border-radius: 14px; + display: flex; + align-items: center; + gap: 7px; + justify-content: flex-start; + padding: 8px 8px 8px 42px; + min-height: 46px; + position: relative; + overflow: hidden; + background-repeat: no-repeat; + background-position: 7px center, center; + background-size: 30px 30px, cover; +} +.tool::before { + content: ""; + position: absolute; + left: 7px; + top: 50%; + width: 30px; + height: 30px; + transform: translateY(-50%); + background: var(--tool-icon) center / contain no-repeat; + opacity: 0.92; + filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); +} +.tool::after { + content: ""; + position: absolute; + inset: 0; + background: var(--tool-icon) right -8px center / 54px 54px no-repeat; + opacity: 0.10; + pointer-events: none; +} +.tool > span:not(.tool-size-badge) { display: none; } +.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); } +.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); } +.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.webp"); } +.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.webp"); } +.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.webp"); } +.tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.webp"); } +.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.webp"); } +.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.webp"); } +.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.webp"); } +.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.webp"); } +.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.webp"); } +.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); } +.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); } + +/* v15.19.1: updated parameter item tool icons */ +.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.19.1"); } +.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.19.1"); } +.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.19.1"); } +.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.19.1"); } +.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.19.1"); } +.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.19.1"); } +.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.19.1"); } +.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.19.1"); } +.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.19.1"); } diff --git a/css/components.css b/css/components.css new file mode 100644 index 0000000..6f01648 --- /dev/null +++ b/css/components.css @@ -0,0 +1,411 @@ +/* Generated CSS split from styles.css; preserve load order. */ +.tool-tip-popover { + position: fixed; + z-index: 2147483000; + width: 220px; + max-width: min(220px, calc(100vw - 16px)); + padding: 7px 9px; + border-radius: 10px; + background: rgba(46, 38, 30, 0.78); + color: #fff8e6; + font-family: "Yomogi", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif; + font-size: 12px; + line-height: 1.45; + box-shadow: 0 10px 28px rgba(40, 32, 22, 0.18); + backdrop-filter: blur(1px); + text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55); + pointer-events: none; +} +.tool-tip-popover.hidden { display: none; } +.selected-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 8px 0 4px; +} +.favorite-toggle { + border: 1px solid rgba(128, 102, 56, 0.32); + border-radius: 999px; + background: rgba(255, 248, 221, 0.92); + color: #5a4630; + font-weight: 700; + padding: 5px 10px; + cursor: pointer; +} +.favorite-toggle.active { + background: rgba(255, 229, 122, 0.95); + border-color: rgba(180, 132, 34, 0.55); +} + +.selected-records { + margin-top: 8px; + padding-top: 7px; + border-top: 1px dashed rgba(126, 96, 64, 0.24); +} +.selected-records h3 { + margin: 0 0 5px; + font-size: 12px; + color: var(--muted); +} +.selected-records ol { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 4px; +} +.selected-records li { + display: grid; + grid-template-columns: 38px 1fr; + gap: 6px; + align-items: start; + font-size: 11px; + line-height: 1.35; +} +.selected-records li span { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.selected-records li strong { + font-weight: 600; +} + +.relation-list-row { + align-items: start; +} +.relation-list-row > strong { + min-width: 0; + width: 100%; + justify-content: flex-end; +} +.relation-chip-list { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; + max-width: 100%; +} +.relation-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 138px; + min-width: 0; + border: 1px solid rgba(104, 150, 84, 0.24); + border-radius: 999px; + background: rgba(250, 255, 242, 0.88); + color: #4d6140; + padding: 2px 6px 2px 2px; + font: inherit; + font-size: 11px; + line-height: 1.2; + cursor: pointer; + box-shadow: 0 1px 0 rgba(255,255,255,0.74); +} +.relation-chip.enemy { + border-color: rgba(178, 92, 74, 0.28); + background: rgba(255, 246, 240, 0.92); + color: #754739; +} +.relation-chip:hover, +.relation-chip:focus-visible { + transform: translateY(-1px); + background: rgba(255,255,255,0.98); + outline: 2px solid rgba(118, 186, 72, 0.18); +} +.relation-chip.enemy:hover, +.relation-chip.enemy:focus-visible { + outline-color: rgba(190, 92, 72, 0.18); +} +.relation-chip .relation-face { + width: 22px; + height: 22px; +} +.relation-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.relation-chip em { + font-style: normal; + font-size: 10px; + opacity: 0.72; + flex: 0 0 auto; +} + + +.loading-screen { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 20px; + background: radial-gradient(circle at top, rgba(255,251,243,0.98), rgba(245,238,225,0.96) 56%, rgba(234,225,208,0.98)); + transition: opacity .22s ease, visibility .22s ease; +} +.loading-screen.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} +.loading-kicker { + font-size: 12px; + letter-spacing: 0.14em; + color: rgba(116, 96, 76, 0.82); + margin-bottom: 6px; +} +.loading-title { + margin: 0 0 8px; + font-size: 28px; + color: #3f3429; +} +.loading-text { + margin: 0 0 12px; + color: rgba(90, 74, 58, 0.86); +} +.loading-sprite-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 8px 0 14px; +} +.loading-sprite-card { + border-radius: 16px; + background: linear-gradient(180deg, rgba(255,255,255,0.84), rgba(248,243,232,0.90)); + border: 1px solid rgba(126, 104, 80, 0.12); + min-height: 92px; + display: grid; + place-items: center; + padding: 8px; + box-shadow: 0 8px 18px rgba(83, 63, 44, 0.06); +} +.loading-sprite-card img { + width: 68px; + height: 68px; + object-fit: contain; +} +.loading-progress-track { + width: 100%; + height: 14px; + border-radius: 999px; + overflow: hidden; + background: rgba(132, 164, 217, 0.16); + border: 1px solid rgba(92, 126, 181, 0.14); +} +.loading-progress-bar { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #92d1ff, #a3dfa0 58%, #ffe192); + transition: width .18s ease; +} +.loading-progress-meta { + margin-top: 8px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: rgba(88, 73, 60, 0.82); +} +.loading-hint { + margin: 10px 0 0; + font-size: 12px; + line-height: 1.55; + color: rgba(111, 92, 73, 0.78); +} + +.collapsible-card { + padding-top: 10px; +} +.panel-card-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + border-radius: 14px; + padding: 10px 12px; + cursor: pointer; + font: inherit; + color: var(--ink); + background: rgba(255,255,255,0.48); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.54); +} +.panel-card-title { + font-size: 17px; + font-weight: 700; + letter-spacing: 0.03em; +} +.panel-card-state { + font-size: 12px; + color: rgba(98, 82, 64, 0.78); + padding-left: 16px; + position: relative; +} +.panel-card-state::before { + content: "▾"; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-52%); + color: rgba(98, 82, 64, 0.72); +} +.collapsible-card.collapsed .panel-card-state::before { content: "▸"; } +.collapsible-card.collapsed .panel-card-body { display: none; } +.panel-card-body { padding-top: 12px; } + +.tool-card { + background: linear-gradient(180deg, rgba(255,247,252,0.92), rgba(249,240,249,0.90)); + border-color: rgba(200, 153, 186, 0.34); +} +.colony-card { + background: linear-gradient(180deg, rgba(244,250,238,0.92), rgba(235,246,230,0.90)); + border-color: rgba(132, 178, 128, 0.34); +} +.log-card { + background: linear-gradient(180deg, rgba(255,246,238,0.92), rgba(252,239,226,0.90)); + border-color: rgba(207, 157, 121, 0.34); +} +.tool-card .panel-card-toggle { + background: linear-gradient(180deg, rgba(255,239,248,0.82), rgba(253,234,246,0.84)); +} +.colony-card .panel-card-toggle { + background: linear-gradient(180deg, rgba(240,252,237,0.82), rgba(230,247,228,0.86)); +} +.log-card .panel-card-toggle { + background: linear-gradient(180deg, rgba(255,242,231,0.84), rgba(253,235,220,0.86)); +} +.tool-card .tool-category { + background: rgba(255,255,255,0.40); + border-color: rgba(165,118,150,0.14); +} +.colony-card .stat { + background: linear-gradient(180deg, rgba(255,255,255,0.68), rgba(239,248,234,0.84)); +} +.colony-card .chart-tabs button.active { + background: rgba(202, 239, 193, 0.88); + border-color: rgba(110,168,92,0.38); +} +.log-card .log-entry { + background: rgba(255,250,246,0.86); +} +@media (max-width: 920px) { + .loading-sprite-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + + +.log-push-controls { + margin-top: 10px; + padding: 10px; + border-radius: 14px; + border: 1px solid rgba(192, 138, 96, 0.24); + background: rgba(255, 247, 239, 0.74); +} +.push-master { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; +} +.push-kind-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 8px; + font-size: 11px; + color: rgba(82, 66, 52, 0.88); +} +.push-kind-grid label { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} +.push-master input, +.push-kind-grid input { + accent-color: #dc966c; +} +.log-push-overlay { + position: fixed; + left: 16px; + bottom: 16px; + z-index: 20000; + display: flex; + flex-direction: column; + gap: 8px; + width: min(360px, calc(100vw - 32px)); + pointer-events: none; +} +.log-push-toast { + position: relative; + border-radius: 16px; + padding: 10px 12px 10px 14px; + border: 1px solid rgba(92, 72, 54, 0.18); + background: rgba(255, 251, 244, 0.96); + box-shadow: 0 14px 36px rgba(46, 34, 24, 0.20); + color: #2f271f; + backdrop-filter: blur(8px); + animation: logPushIn .18s ease both; + overflow: hidden; +} +.log-push-toast::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 5px; + background: rgba(146, 122, 92, 0.42); +} +.log-push-toast .log-push-tag { + display: inline-block; + margin-right: 6px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(238, 224, 204, 0.78); + font-size: 10px; + font-weight: 700; +} +.log-push-toast .log-push-time { + color: rgba(96, 78, 62, 0.72); + font-size: 10px; +} +.log-push-toast .log-push-text { + margin-top: 5px; + font-size: 12px; + line-height: 1.45; +} +.log-push-toast.log-push-death::before { background: #8f817a; } +.log-push-toast.log-push-accident::before { background: #ee9e5d; } +.log-push-toast.log-push-birth::before { background: #98cf7a; } +.log-push-toast.log-push-fight::before { background: #e47b80; } +.log-push-toast.log-push-relation::before { background: #b99be0; } +.log-push-toast.log-push-food::before { background: #e9bd61; } +.log-push-toast.log-push-weather::before { background: #7bbbdc; } +.log-push-toast.log-push-grass::before { background: #8cc77a; } +.log-push-toast.leaving { + animation: logPushOut .18s ease both; +} +@keyframes logPushIn { + from { transform: translateY(14px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +@keyframes logPushOut { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(8px); opacity: 0; } +} + +/* v15.12.0: loading / collapsible cards / sound settings */ +.loading-screen { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 20px; + background: radial-gradient(circle at top, rgba(255,251,243,0.98), rgba(245,238,225,0.96) 56%, rgba(234,225,208,0.98)); + transition: opacity .22s ease, visibility .22s ease; +} diff --git a/css/layout.css b/css/layout.css new file mode 100644 index 0000000..1f9f88d --- /dev/null +++ b/css/layout.css @@ -0,0 +1,366 @@ +/* Generated CSS split from styles.css; preserve load order. */ +.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); } +.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); } +.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); } +.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); } +.tool[data-tool="poke"] { --tool-icon: none; } +.tool[data-tool="poke"]::before { + content: "\1F448"; + background: none; + font-size: 25px; + line-height: 1; + filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); +} +.tool[data-tool="poke"]::after { + content: "\1F448"; + background: none; + font-size: 48px; + line-height: 1; + opacity: 0.11; + right: -6px; + left: auto; + top: 50%; + bottom: auto; + transform: translateY(-50%); + width: 54px; + height: 54px; +} + +.tool[data-tool="pinch"] { --tool-icon: none; } +.tool[data-tool="pinch"]::before { + content: "\1F90F"; + background: none; + font-size: 25px; + line-height: 1; + filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); +} +.tool[data-tool="pinch"]::after { + content: "\1F90F"; + background: none; + font-size: 48px; + line-height: 1; + opacity: 0.10; + right: -6px; + left: auto; + top: 50%; + bottom: auto; + transform: translateY(-50%); + width: 54px; + height: 54px; +} +.tool[data-tool="water_hose"] { --tool-icon: none; } +.tool[data-tool="water_hose"]::before { + content: "\1F6BF"; + background: none; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + font-size: 24px; + line-height: 1; + filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); +} +.tool[data-tool="water_hose"]::after { + content: "\1F6BF"; + background: none; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + font-size: 46px; + line-height: 1; + opacity: 0.10; + right: -6px; + left: auto; + top: 50%; + bottom: auto; + transform: translateY(-50%); + width: 54px; + height: 54px; +} + +.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } +.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); } +.tool.selected { + background: linear-gradient(180deg, #ffffff, #efffde); + border-color: rgba(103, 178, 44, 0.52); + box-shadow: inset 0 0 0 1px rgba(156,232,75,0.38), 0 8px 20px rgba(117, 166, 64, 0.13); +} + +.hint { + color: var(--muted); + font-size: 12px; + line-height: 1.40; + margin: 10px 0 0; +} + +.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.tool-head h2 { margin-bottom: 0; } +.btn.mini { padding: 6px 10px; font-size: 12px; } +.tool-body { margin-top: 12px; } +.tool-palette { display: flex; flex-direction: column; gap: 9px; } +.tool-category { + border: 1px solid rgba(84,68,48,0.10); + border-radius: 14px; + background: rgba(255,255,255,0.34); + overflow: hidden; +} +.tool-category-toggle { + width: 100%; + border: 0; + border-bottom: 1px solid rgba(84,68,48,0.08); + background: rgba(255,252,244,0.66); + padding: 8px 10px; + text-align: left; + cursor: pointer; + font-weight: 700; + font-size: 12px; +} +.tool-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } +.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; } +.tool-category-body { padding: 8px; } +.tool-category.collapsed .tool-category-body { display: none; } + +.selected-data-groups { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} +.selected-category { + border: 1px solid rgba(84,68,48,0.10); + border-radius: 14px; + background: rgba(255,255,255,0.30); + overflow: hidden; +} +.selected-category-toggle { + width: 100%; + border: 0; + border-bottom: 1px solid rgba(84,68,48,0.08); + background: rgba(255,252,244,0.66); + padding: 8px 10px; + text-align: left; + cursor: pointer; + font-weight: 700; + font-size: 12px; + color: var(--ink); +} +.selected-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } +.selected-category.collapsed .selected-category-toggle::before { content: "\25b8 "; } +.selected-category-body { padding: 7px 9px; } +.selected-category.collapsed .selected-category-body { display: none; } +.selected-category-body .info-row:last-child { border-bottom: 0; } +.tool-size-badge { + position: absolute; + right: 7px; + bottom: 6px; + min-width: 20px; + padding: 2px 5px; + border-radius: 999px; + background: rgba(42,36,29,0.68); + color: #fff; + font-size: 10px; + line-height: 1; + text-align: center; + box-shadow: 0 1px 4px rgba(42,36,29,0.16); + pointer-events: none; +} +.chart-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin: 3px 0 8px; } +.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; } +.chart-tabs button.active { background: #efffde; border-color: rgba(103,178,44,0.46); font-weight: 700; } +.log-accident { border-left-color: rgba(226, 136, 54, 0.76); } + + +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-bottom: 12px; +} + +.stat { + background: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246, 240, 228, 0.72)); + border: 1px solid var(--line); + border-radius: 13px; + padding: 9px 8px; + text-align: center; +} +.stat span { display: block; font-size: 11px; color: var(--muted); } +.stat strong { font-size: 18px; } + +.meter-row { + display: grid; + grid-template-columns: 72px 1fr; + align-items: center; + gap: 8px; + font-size: 12px; + margin: 6px 0; +} +meter { + width: 100%; + height: 12px; + accent-color: var(--accent); +} + + +.selected-card.hidden { display: none; } +.selected-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.selected-head h2 { margin-bottom: 0; } +.selected-close { + width: 28px; + height: 28px; + border: 1px solid rgba(84,68,48,0.16); + border-radius: 999px; + background: rgba(255,255,255,0.76); + color: rgba(76,58,42,0.78); + font-size: 20px; + line-height: 24px; + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} +.selected-close:hover { background: #fff; } + +.selected-info { + font-size: 13px; + line-height: 1.40; +} +.selected-info.empty { + color: var(--muted); +} + +.selected-name-editor { + margin: 0 0 8px; +} +.selected-name-input { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(103,178,44,0.34); + border-radius: 13px; + background: rgba(246,252,238,0.96); + color: var(--ink); + font-size: 18px; + font-weight: 700; + line-height: 1.25; + padding: 8px 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.82); +} +.selected-name-input:focus { + outline: 2px solid rgba(103,178,44,0.24); + border-color: rgba(103,178,44,0.62); + background: #fff; +} +.selected-effects { + margin: 2px 0 6px; +} +.badge { + display: inline-block; + font-size: 11px; + line-height: 1; + border-radius: 999px; + padding: 5px 8px; + background: #f1f8e9; + border: 1px solid rgba(103, 178, 44, 0.28); + margin: 0 4px 4px 0; +} +.info-row { + display: flex; + justify-content: space-between; + gap: 10px; + border-bottom: 1px dashed rgba(84,68,48,0.16); + padding: 5px 0; +} +.info-row span:first-child { color: var(--muted); } +.personality-values strong { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 2px 8px; + max-width: 68%; + text-align: right; +} +.personality-values strong span { + color: var(--ink); + white-space: nowrap; +} +.personality-tag { + display: inline-block; + border-radius: 999px; + padding: 2px 7px; + background: rgba(103,178,44,0.10); + border: 1px solid rgba(103,178,44,0.22); +} +.personality-tag.neutral { + background: rgba(120,110,90,0.08); + border-color: rgba(120,110,90,0.16); +} + +.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; } +.log { + max-height: 360px; + min-height: 170px; + flex: 1 1 auto; + overflow: auto; + display: flex; + flex-direction: column; + gap: 7px; + font-size: 12px; + overscroll-behavior: auto; +} +.log-entry { + padding: 8px 9px; + border-radius: 12px; + background: rgba(255,252,246,0.82); + border: 1px solid rgba(84,68,48,0.12); + border-left: 4px solid rgba(118, 101, 74, 0.24); +} +.log-tag { + display: inline-block; + min-width: 34px; + margin-right: 6px; + padding: 2px 5px; + border-radius: 999px; + font-size: 9px; + line-height: 1.1; + text-align: center; + background: rgba(84,68,48,0.08); + color: rgba(72,58,43,0.72); + text-transform: uppercase; +} +.log-entry .time { color: var(--muted); margin-right: 6px; } +.log-birth { border-left-color: rgba(115, 185, 75, 0.66); } +.log-death { border-left-color: rgba(91, 83, 75, 0.60); } +.log-fight { border-left-color: rgba(214, 94, 70, 0.68); } +.log-food { border-left-color: rgba(118, 185, 77, 0.68); } +.log-grass { border-left-color: rgba(72, 148, 67, 0.68); } +.log-weather { border-left-color: rgba(92, 142, 210, 0.62); } +.log-relation { border-left-color: rgba(204, 126, 176, 0.62); } +.log-observe { border-left-color: rgba(175, 146, 86, 0.62); } + +@media (max-width: 980px) { + #app { padding: 10px; } + .topbar { align-items: flex-start; flex-direction: column; } + .top-left { align-items: flex-start; flex-direction: column; } + .brand { align-items: center; flex-direction: row; gap: 10px; } + .layout { grid-template-columns: 1fr; } + .panel { max-height: none; overflow: visible; } + #gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); } + .stage-wrap { min-height: var(--field-mobile-min-height, 460px); } + .field-choice-grid { grid-template-columns: 1fr; } +} + + + + +.tree-note { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin: 0 0 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255,255,255,0.54); + border: 1px solid rgba(84,68,48,0.10); +} + diff --git a/css/mobile.css b/css/mobile.css new file mode 100644 index 0000000..bb6cf29 --- /dev/null +++ b/css/mobile.css @@ -0,0 +1,226 @@ +/* Generated CSS split from styles.css; preserve load order. */ +.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; } +.loading-kicker { font-size: 12px; letter-spacing: .14em; color: rgba(116,96,76,.82); margin-bottom: 6px; } +.loading-title { margin: 0 0 8px; font-size: 28px; color: #3f3429; } +.loading-text { margin: 0 0 12px; color: rgba(90,74,58,.86); } +.loading-sprite-strip { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin: 8px 0 14px; } +.loading-sprite-card { border-radius: 16px; background: linear-gradient(180deg,rgba(255,255,255,.84),rgba(248,243,232,.90)); border: 1px solid rgba(126,104,80,.12); min-height: 92px; display: grid; place-items: center; padding: 8px; box-shadow: 0 8px 18px rgba(83,63,44,.06); } +.loading-sprite-card img { width: 68px; height: 68px; object-fit: contain; } +.loading-progress-track { width: 100%; height: 14px; border-radius: 999px; overflow: hidden; background: rgba(132,164,217,.16); border: 1px solid rgba(92,126,181,.14); } +.loading-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg,#92d1ff,#a3dfa0 58%,#ffe192); transition: width .18s ease; } +.loading-progress-meta { margin-top: 8px; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: rgba(88,73,60,.82); } +.loading-hint { margin: 10px 0 0; font-size: 12px; line-height: 1.55; color: rgba(111,92,73,.78); } + +.collapsible-card { padding-top: 10px; } +.panel-card-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 0; border-radius: 14px; padding: 10px 12px; cursor: pointer; font: inherit; color: var(--ink); background: rgba(255,255,255,.48); box-shadow: inset 0 1px 0 rgba(255,255,255,.54); } +.panel-card-title { font-size: 17px; font-weight: 700; letter-spacing: .03em; } +.panel-card-state { font-size: 12px; color: rgba(98,82,64,.78); padding-left: 16px; position: relative; } +.panel-card-state::before { content: "▾"; position: absolute; left: 0; top: 50%; transform: translateY(-52%); color: rgba(98,82,64,.72); } +.collapsible-card.collapsed .panel-card-state::before { content: "▸"; } +.collapsible-card.collapsed .panel-card-body { display: none; } +.panel-card-body { padding-top: 12px; } +.tool-card { background: linear-gradient(180deg, rgba(255,247,252,.92), rgba(249,240,249,.90)); border-color: rgba(200,153,186,.34); } +.colony-card { background: linear-gradient(180deg, rgba(244,250,238,.92), rgba(235,246,230,.90)); border-color: rgba(132,178,128,.34); } +.log-card { background: linear-gradient(180deg, rgba(255,246,238,.92), rgba(252,239,226,.90)); border-color: rgba(207,157,121,.34); } +.tool-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,239,248,.82),rgba(253,234,246,.84)); } +.colony-card .panel-card-toggle { background: linear-gradient(180deg,rgba(240,252,237,.82),rgba(230,247,228,.86)); } +.log-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,242,231,.84),rgba(253,235,220,.86)); } + +.sound-category-controls, +.log-push-controls { + margin-top: 10px; + padding: 10px; + border-radius: 14px; + border: 1px solid rgba(84,68,48,.12); + background: rgba(255,255,255,.46); + font-size: 12px; +} +.control-group-title { font-weight: 700; margin-bottom: 7px; color: rgba(58,48,39,.88); } +.sound-category-controls { display: grid; grid-template-columns: 1fr; gap: 6px; } +.sound-category-controls label, +.push-master, +.push-kind-grid label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; } +.sound-category-controls input, +.push-master input, +.push-kind-grid input { accent-color: #82bf66; } +.log-push-controls { display: flex; flex-direction: column; gap: 8px; } +.push-kind-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px 8px; } +.log-push-overlay { position: fixed; left: 18px; bottom: 18px; z-index: 9998; width: min(320px, calc(100vw - 36px)); display: flex; flex-direction: column; gap: 8px; pointer-events: none; } +.log-push-toast { position: relative; overflow: hidden; padding: 10px 12px 10px 17px; border-radius: 15px; background: rgba(255,252,246,.94); border: 1px solid rgba(84,68,48,.16); box-shadow: 0 12px 32px rgba(44,34,25,.18); animation: logPushIn .18s ease-out; } +.log-push-toast::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 5px; background: #a5c77a; } +.log-push-toast .log-push-tag { display: inline-block; margin-right: 6px; font-size: 11px; font-weight: 700; color: rgba(54,45,38,.88); } +.log-push-toast .log-push-time { font-size: 10px; color: rgba(104,88,72,.72); } +.log-push-toast .log-push-text { margin-top: 3px; font-size: 12px; line-height: 1.35; color: rgba(48,40,33,.92); } +.log-push-toast.log-push-death::before { background: #8f817a; } +.log-push-toast.log-push-accident::before { background: #ee9e5d; } +.log-push-toast.log-push-birth::before { background: #98cf7a; } +.log-push-toast.log-push-fight::before { background: #e47b80; } +.log-push-toast.log-push-relation::before { background: #b99be0; } +.log-push-toast.log-push-food::before { background: #e9bd61; } +.log-push-toast.log-push-weather::before { background: #7bbbdc; } +.log-push-toast.log-push-grass::before { background: #8cc77a; } +.log-push-toast.leaving { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; } +@keyframes logPushIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 920px) { .loading-sprite-strip { grid-template-columns: repeat(2,minmax(0,1fr)); } } + + +/* v15.12.1: top-right sound settings panel */ +.sound-menu { position: relative; display: inline-block; } +.sound-menu-btn.active { background: linear-gradient(180deg, #f3ffe9, #e7f8db); border-color: rgba(103,178,44,.46); } +.sound-panel { + position: absolute; + right: 0; + top: calc(100% + 8px); + z-index: 12000; + min-width: 210px; + padding: 11px; + border-radius: 16px; + border: 1px solid rgba(84,68,48,.16); + background: rgba(255,252,246,.98); + box-shadow: 0 16px 42px rgba(44,34,25,.20); + display: grid; + gap: 7px; + text-align: left; +} +.sound-panel.hidden { display: none; } +.sound-panel label { display: flex; align-items: center; gap: 7px; font-size: 12px; cursor: pointer; white-space: nowrap; } +.sound-panel input { accent-color: #82bf66; } +.sound-panel input:disabled + span { opacity: .55; } +.sound-master { font-weight: 700; } +.sound-panel-divider { height: 1px; background: rgba(84,68,48,.12); margin: 2px 0; } +@media (max-width: 760px) { .sound-panel { right: auto; left: 0; } } + + +/* v15.12.3: collapsed event panel should be genuinely thin */ +.log-card.collapsed { + min-height: 0 !important; + padding: 5px 7px !important; + flex: 0 0 auto !important; +} +.log-card.collapsed .panel-card-toggle { + min-height: 30px; + padding: 5px 9px; + border-radius: 11px; +} +.log-card.collapsed .panel-card-title { + font-size: 13px; + line-height: 1.1; +} +.log-card.collapsed .panel-card-state { + font-size: 10px; +} + + +/* v15.12.4: event log scroll chains to the right menu at top/bottom */ +.log { + overscroll-behavior-y: auto !important; +} + +/* v15.12.4: sound settings live only in the top-right sound menu */ +.log-card .sound-category-controls { + display: none !important; +} + +/* v15.17.0: mobile layout isolation and canvas touch gestures */ +#app { overflow-x: clip; } +.layout { position: relative; z-index: 2; } +.panel { position: relative; z-index: 3; } +.lineage-main-card { + position: relative; + z-index: 1; + overflow: hidden; + contain: layout paint; +} +#gameCanvas { + touch-action: none; + -webkit-user-select: none; + user-select: none; +} +.archive-content, +.lineage-main-content, +.lineage-family-tree { + max-width: 100%; + box-sizing: border-box; +} +@media (max-width: 980px) { + .layout { + position: relative; + z-index: 4; + } + .panel { + position: relative; + z-index: 5; + isolation: isolate; + } + .lineage-main-card { + z-index: 1; + width: 100%; + max-width: 100%; + margin-top: 14px; + clear: both; + } + .lineage-main-content { + height: auto; + min-height: 340px; + max-height: 72vh; + overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y pinch-zoom; + } + .lineage-family-tree { + overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y pinch-zoom; + } + .lineage-family-tree .lineage-tree-panel { + max-width: none; + } +} + +/* v15.18.0: item icons and mobile input mode */ +.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.19.1"); } +.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.19.1"); } +.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.19.1"); } +.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.19.1"); } +.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.19.1"); } +.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.19.1"); } +.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.19.1"); } +.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.19.1"); } +.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.19.1"); } +.mobile-mode-controls { + display: none; + position: absolute; + left: 10px; + bottom: 10px; + z-index: 24; + gap: 5px; + padding: 6px; + border-radius: 14px; + background: rgba(255, 251, 245, 0.90); + box-shadow: 0 8px 22px rgba(80,60,35,.18); + border: 1px solid rgba(120, 95, 60, .16); + backdrop-filter: blur(8px); +} +.mobile-mode-controls button { + border: 0; + border-radius: 10px; + padding: 7px 8px; + font-size: 12px; + font-weight: 800; + color: #65533f; + background: rgba(255,255,255,.78); + box-shadow: inset 0 -1px 0 rgba(0,0,0,.05); +} +.mobile-mode-controls button.active { + color: #3d321f; + background: linear-gradient(180deg, #ffe9a8, #ffd376); + box-shadow: 0 3px 10px rgba(150, 105, 30, .20); +} +@media (pointer: coarse), (max-width: 760px) { + .mobile-mode-controls { display: flex; } + body.mobile-mode-camera #gameCanvas { cursor: grab; } + body.mobile-mode-tool #gameCanvas { cursor: crosshair; } + body.mobile-mode-family .lineage-main-card { z-index: 9; } +} diff --git a/css/panel.css b/css/panel.css new file mode 100644 index 0000000..70d357c --- /dev/null +++ b/css/panel.css @@ -0,0 +1,396 @@ +/* Generated CSS split from styles.css; preserve load order. */ +.lineage-tree { + overflow: auto; + padding: 8px 10px 18px; + contain: content; +} + +.lineage-main-card { + margin-top: 16px; + min-height: 0; +} + +.lineage-main-card h2 { + margin-bottom: 6px; +} + +.lineage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.lineage-head h2 { margin-bottom: 0; } + +.lineage-main-content { + height: min(78vh, 980px); + min-height: 520px; + max-height: calc(100vh - 120px); + overflow: auto; + overscroll-behavior: auto; + padding: 10px 6px 24px; + background: rgba(255,250,244,0.48); + border-radius: 14px; + border: 1px solid rgba(84,68,48,0.10); + scrollbar-gutter: stable both-edges; +} + +.lineage-forest { + display: flex; + flex-direction: column; + gap: 12px; +} + +.lineage-main-content .lineage-tree { + min-width: 360px; + overflow: visible; + padding-right: 2px; +} + + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 8% 20%, rgba(255,255,255,0.62), transparent 16%), + radial-gradient(circle at 92% 18%, rgba(255,255,255,0.45), transparent 18%), + linear-gradient(180deg, transparent 0%, rgba(114, 152, 78, 0.05) 100%); +} + + +.stage-wrap::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 18% 14%, rgba(255,255,255,0.12), transparent 18%), + radial-gradient(circle at 84% 10%, rgba(255,255,255,0.09), transparent 16%), + linear-gradient(180deg, rgba(255,255,255,0.07), transparent 24%); + z-index: 1; +} +#gameCanvas { + position: relative; + z-index: 0; +} + +.card::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.55); +} + +/* Family tree node primitives. The archive deliberately uses these nodes inside an SVG-backed tree, not the old card/grid UI. */ +.family-node { + position: relative; + display: block; + min-width: 0; + min-height: 34px; + padding: 6px 8px; + border-radius: 10px; + background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90)); + border: 1px solid rgba(84,68,48,0.14); + box-shadow: 0 3px 8px rgba(64,48,32,0.045); + overflow: hidden; + isolation: isolate; +} +.family-node.child { + border-color: rgba(103, 178, 44, 0.38); + box-shadow: inset 0 0 0 1px rgba(156,232,75,0.17), 0 3px 8px rgba(64,48,32,0.045); +} +.family-node[data-family-tarinai-id] { cursor: pointer; transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; } +.family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); } +.family-node.dead { opacity: 0.9; filter: grayscale(0.32); } +.family-node.missing { opacity: 0.48; border-style: dashed; } +.family-portrait { + position: absolute; + right: -6px; + bottom: -8px; + width: 64px; + height: 64px; + z-index: 0; + pointer-events: none; +} +.family-node.alive .family-portrait { opacity: 0.80; } +.family-node.dead .family-portrait { opacity: 0.20; } +.family-portrait img { + width: 100%; + height: 100%; + object-fit: contain; + filter: saturate(0.95); +} +.family-copy { position: relative; z-index: 1; min-width: 0; line-height: 1.12; max-width: calc(100% - 8px); } +.family-copy strong, .family-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.family-copy .death-reason { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.12; + margin-top: 1px; + color: rgba(126, 80, 64, 0.92); +} +.family-copy strong { font-size: 11px; } +.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; } + + +.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; } +.colony-stats { + grid-template-columns: repeat(2, 1fr); + margin-bottom: 8px; +} +.colony-chart { + display: block; + width: 100%; + height: 188px; + min-height: 188px; + border: 1px solid rgba(84, 68, 48, 0.14); + border-radius: 14px; + background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54)); + overflow: hidden; +} +.colony-card h2 { line-height: 1.45; margin-bottom: 10px; } +.colony-chart svg { display: block; width: 100%; height: 100%; } +.colony-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + margin: 4px 0 8px; + font-size: 11px; + color: var(--muted); + line-height: 1.25; +} +.colony-chart-legend span { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} +.colony-chart-legend i { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10); +} + +.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; } +.relation-face { + width: 24px; + height: 24px; + object-fit: contain; + border-radius: 999px; + background: rgba(255,255,255,0.66); + border: 1px solid rgba(84,68,48,0.12); + vertical-align: middle; + flex: 0 0 auto; +} + + +.lineage-family { + padding: 10px; + margin: 0 0 12px; + border-radius: 15px; + background: rgba(255,253,248,0.62); + border: 1px solid rgba(84,68,48,0.12); +} +.lineage-family h3 { + margin: 0 0 8px; + font-size: 13px; + color: rgba(64,52,38,0.86); +} +/* Family archive: actual top-down tree diagram. */ +.lineage-family-tree { + overflow-x: auto; + overflow-y: visible; + padding: 10px 10px 22px; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; +} +.lineage-family-tree h3 { + position: sticky; + left: 0; + z-index: 3; + width: max-content; + padding-right: 12px; + background: rgba(255,253,248,0.82); + border-radius: 8px; +} +.lineage-tree-hint { + position: sticky; + left: 0; + z-index: 3; + width: max-content; + max-width: 100%; + margin: -2px 0 8px; + padding: 5px 8px; + border-radius: 9px; + color: var(--muted); + font-size: 11px; + background: rgba(255,255,255,0.62); + border: 1px solid rgba(84,68,48,0.08); +} +.lineage-family-tree .lineage-tree-panel { + position: relative; + overflow: visible; + min-width: 360px; + margin-top: 6px; + border-radius: 14px; + background: + linear-gradient(90deg, rgba(84,68,48,0.045) 1px, transparent 1px), + linear-gradient(180deg, rgba(84,68,48,0.040) 1px, transparent 1px), + rgba(255,250,244,0.48); + background-size: 34px 34px, 34px 34px, auto; + border: 1px solid rgba(84,68,48,0.10); +} +.lineage-family-tree .lineage-links { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + z-index: 1; + isolation: isolate; +} + +.lineage-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +.btn.active { + background: #efffde; + border-color: rgba(103,178,44,0.46); + font-weight: 700; +} + +.lineage-render-status { + position: sticky; + top: 0; + left: 0; + z-index: 6; + width: max-content; + margin: 0 0 8px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + color: var(--muted); + background: rgba(255,255,255,0.88); + border: 1px solid rgba(84,68,48,0.10); + box-shadow: 0 4px 10px rgba(64,48,32,0.06); +} +.lineage-stale-status { + position: sticky; + top: 0; + left: 0; + z-index: 6; + width: max-content; + max-width: calc(100% - 12px); + margin: 0 0 8px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + color: rgba(92, 75, 52, 0.82); + background: rgba(255, 249, 232, 0.92); + border: 1px solid rgba(150, 115, 46, 0.16); + box-shadow: 0 4px 10px rgba(64,48,32,0.05); +} +.lineage-family-tree .lineage-links path { + fill: none; + stroke: rgba(107, 88, 61, 0.58); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.lineage-family-tree .lineage-links .partner-link { + stroke-width: 2.4; + stroke: rgba(98, 80, 56, 0.68); + stroke-dasharray: 5 4; +} +.lineage-family-tree .lineage-links .child-link { + stroke-width: 2; + stroke: rgba(107, 88, 61, 0.58); +} +.lineage-family-tree .tree-node { + position: absolute; + width: 132px; + height: 68px; + min-height: 68px; + box-sizing: border-box; + align-items: flex-start; + background: linear-gradient(180deg, #fffdf8, #f8f1e5); + box-shadow: 0 2px 5px rgba(64,48,32,0.035); + contain: layout paint style; + z-index: 2; + isolation: isolate; +} +.lineage-family-tree .tree-node.child { + border-color: rgba(103,178,44,0.34); +} +.lineage-family-tree .tree-node.dead { + opacity: 1; + filter: none; +} +.lineage-family-tree .tree-node[data-family-tarinai-id]:hover { + transform: none; +} +.lineage-family-tree .tree-node .family-portrait { + right: 0; + bottom: -6px; + width: 58px; + height: 58px; +} +.lineage-family-tree .tree-generation-label { + position: absolute; + left: 10px; + width: 54px; + padding: 3px 0; + border-radius: 999px; + font-size: 10px; + color: var(--muted); + text-align: center; + background: rgba(255,255,255,0.70); + border: 1px solid rgba(84,68,48,0.08); + z-index: 2; +} +.lineage-family-tree .lineage-empty-family { + position: absolute; + left: 72px; + bottom: 8px; + padding: 5px 8px; + border-radius: 9px; + background: rgba(255,255,255,0.70); +} + +@media (max-width: 720px) { + .ecology-grid { grid-template-columns: 1fr; } + .ecology-card { grid-template-columns: 68px minmax(0, 1fr); } + .ecology-card img { width: 68px; height: 68px; } +} + +@media (max-width: 720px) { + .lineage-main-content { + height: 70vh; + min-height: 420px; + max-height: none; + } +} + + +.tool[data-tip] { position: relative; } diff --git a/css/styles.css b/css/styles.css index c41a5e0..73fb48b 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,1293 +1 @@ -@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap"); - -:root { - --bg: #f7f5ec; - --panel: rgba(255,251,245,0.88); - --ink: #2a241d; - --muted: #756a5e; - --line: rgba(84, 68, 48, 0.18); - --accent: #9ce84b; - --accent-2: #f5a8b7; - --danger: #b64545; - --shadow: 0 18px 50px rgba(64, 48, 32, 0.14); - --radius: 18px; - --bubble-font: "Yomogi", "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, sans-serif; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - font-family: "Zen Maru Gothic", "M PLUS Rounded 1c", ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", "Meiryo", system-ui, sans-serif; - background: - radial-gradient(circle at 12% 0%, rgba(168, 228, 113, 0.28), transparent 33%), - radial-gradient(circle at 88% 8%, rgba(255, 222, 147, 0.24), transparent 28%), - radial-gradient(circle at 82% 90%, rgba(126, 181, 120, 0.16), transparent 34%), - linear-gradient(180deg, #dff0ff 0%, #edf8ff 12%, #f9f6eb 46%, var(--bg) 100%); - color: var(--ink); - min-height: 100vh; - overflow-x: hidden; - position: relative; -} - -button { - font: inherit; - color: inherit; -} - -.loading-screen { - position: fixed; - inset: 0; - z-index: 9999; - display: grid; - place-items: center; - padding: 24px; - background: - radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%), - linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98)); - transition: opacity 180ms ease, visibility 180ms ease; -} -.loading-screen.hidden { - opacity: 0; - visibility: hidden; - pointer-events: none; -} -.loading-panel { - width: min(420px, 92vw); - padding: 24px 22px; - border-radius: 24px; - border: 1px solid rgba(84, 68, 48, 0.16); - background: rgba(255, 252, 246, 0.92); - box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16); - text-align: center; -} -.loading-title { - font-weight: 700; - font-size: 18px; - letter-spacing: 0.04em; - margin-bottom: 8px; -} -.loading-text { - color: var(--muted); - font-size: 13px; - line-height: 1.55; - min-height: 1.55em; -} -.loading-bar { - height: 12px; - margin: 18px 0 8px; - border-radius: 999px; - overflow: hidden; - background: rgba(90, 70, 48, 0.12); -} -.loading-bar-fill { - width: 0%; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #9ce84b, #f0d35b); - transition: width 160ms ease; -} -.loading-percent { - color: var(--muted); - font-size: 12px; - font-variant-numeric: tabular-nums; -} - -#app { - width: min(1480px, 100vw); - margin: 0 auto; - padding: 18px; - position: relative; -} - -.topbar { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: center; - margin-bottom: 16px; -} - -.top-left { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.brand { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: nowrap; - min-width: 0; -} - -.room-link { - text-decoration: none; - white-space: nowrap; - background: rgba(255,255,255,0.86); -} - -h1 { - margin: 0; - font-size: clamp(24px, 3.4vw, 42px); - letter-spacing: 0.03em; - white-space: nowrap; -} - -.subtitle { - margin: 0; - color: var(--muted); - font-size: clamp(11px, 1.1vw, 13px); - line-height: 1.35; - white-space: nowrap; -} - -.top-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.btn, .tool { - border: 1px solid var(--line); - background: rgba(255,255,255,0.76); - border-radius: 999px; - padding: 9px 13px; - cursor: pointer; - box-shadow: 0 4px 14px rgba(80,60,40,0.06); - transition: transform 130ms ease, background 130ms ease, border-color 130ms ease; - user-select: none; -} - -.btn:hover, .tool:hover { transform: translateY(-1px); background: #fff; } -.btn:active, .tool:active { transform: translateY(1px); } - -.btn.primary { - background: linear-gradient(180deg, #a9ef5d, #8bdc3f); - border-color: rgba(89, 128, 47, 0.28); -} - -.btn.danger { - color: #7a2828; -} - -.layout { - display: grid; - grid-template-columns: minmax(520px, 1fr) 380px; - gap: 16px; - align-items: stretch; -} - -.stage-wrap { - position: relative; - background: linear-gradient(180deg, rgba(255,255,255,0.46), rgba(246, 240, 228, 0.56)); - border: 1px solid rgba(84, 68, 48, 0.12); - border-radius: calc(var(--radius) + 8px); - overflow: hidden; - box-shadow: 0 16px 42px rgba(42, 45, 80, 0.12), inset 0 0 0 1px rgba(255,255,255,0.24); - min-height: var(--field-min-height, 620px); -} - -#gameCanvas { - width: 100%; - height: var(--field-height, calc(100vh - 140px)); - min-height: var(--field-min-height, 620px); - display: block; - cursor: crosshair; - background: linear-gradient(180deg, #cfe3d3, #d9d0ad); -} - -.field-dialog { - position: fixed; - inset: 0; - z-index: 30; - display: grid; - place-items: center; - padding: 18px; - background: rgba(34, 29, 22, 0.38); - backdrop-filter: blur(3px); -} -.field-dialog.hidden { display: none; } -.field-dialog-panel { - width: min(440px, 100%); - max-height: calc(100vh - 36px); - overflow: auto; - border-radius: 16px; - padding: 16px; - background: rgba(255, 252, 245, 0.96); - border: 1px solid rgba(84,68,48,0.16); - box-shadow: 0 18px 46px rgba(40, 32, 24, 0.22); -} -.field-dialog-panel h2 { - margin: 0 0 12px; - font-size: 17px; -} -.field-choice-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -} -.field-choice { - min-height: 74px; - border: 1px solid rgba(84,68,48,0.16); - border-radius: 12px; - background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(241,235,221,0.72)); - cursor: pointer; - color: var(--ink); -} -.field-choice:hover { border-color: rgba(112,178,66,0.45); } -.field-choice strong, .field-choice span { display: block; } -.field-choice strong { font-size: 15px; } -.field-choice span { margin-top: 4px; color: var(--muted); font-size: 12px; } -.field-dialog-actions { - margin-top: 12px; - display: flex; - justify-content: flex-end; - gap: 8px; -} -.archive-reset-dialog-panel { - width: min(420px, 100%); -} -.archive-reset-actions { - justify-content: space-between; - gap: 10px; -} -.archive-reset-actions .btn { - min-width: 132px; -} - - -.ecology-dialog-panel { - width: min(760px, 100%); - max-height: calc(100vh - 36px); - overflow-y: auto; - overscroll-behavior: contain; -} -.ecology-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} -.ecology-card { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - gap: 10px; - align-items: center; - padding: 10px; - border-radius: 14px; - background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76)); - border: 1px solid rgba(84,68,48,0.12); -} -.ecology-card img { - width: 72px; - height: 72px; - object-fit: contain; - filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10)); -} -.ecology-card strong { - display: block; - margin-bottom: 4px; - font-size: 15px; -} -.ecology-card p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.40; -} - -.toast { - position: absolute; - left: 50%; - bottom: 22px; - transform: translateX(-50%); - background: rgba(42,36,29,0.86); - color: white; - padding: 10px 16px; - border-radius: 999px; - font-size: 14px; - pointer-events: none; - opacity: 1; - transition: opacity 200ms ease, transform 200ms ease; -} -.toast.hidden { opacity: 0; transform: translateX(-50%) translateY(8px); } - -.panel { - display: flex; - flex-direction: column; - gap: 12px; - height: calc(100vh - 120px); - max-height: calc(100vh - 120px); - overflow-y: auto; - overflow-x: hidden; - padding-right: 3px; -} - -.card { - position: relative; - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 14px; - box-shadow: 0 10px 30px rgba(64, 48, 32, 0.08); - backdrop-filter: blur(10px); - position: relative; -} - -.card h2 { - margin: 0 0 12px; - font-size: 17px; - letter-spacing: 0.03em; -} - -.tool-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0,1fr)); - gap: 8px; -} - -.tool { - border-radius: 14px; - display: flex; - align-items: center; - gap: 7px; - justify-content: flex-start; - padding: 8px 8px 8px 42px; - min-height: 46px; - position: relative; - overflow: hidden; - background-repeat: no-repeat; - background-position: 7px center, center; - background-size: 30px 30px, cover; -} -.tool::before { - content: ""; - position: absolute; - left: 7px; - top: 50%; - width: 30px; - height: 30px; - transform: translateY(-50%); - background: var(--tool-icon) center / contain no-repeat; - opacity: 0.92; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool::after { - content: ""; - position: absolute; - inset: 0; - background: var(--tool-icon) right -8px center / 54px 54px no-repeat; - opacity: 0.10; - pointer-events: none; -} -.tool > span:not(.tool-size-badge) { display: none; } -.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); } -.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.png"); } -.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.png"); } -.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); } -.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.png"); } -.tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.png"); } -.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); } -.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); } -.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); } -.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); } -.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.png"); } -.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.png"); } -.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.png"); } -.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.png"); } -.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); } -.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.png"); } -.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); } -.tool[data-tool="poke"] { --tool-icon: none; } -.tool[data-tool="poke"]::before { - content: "\1F448"; - background: none; - font-size: 25px; - line-height: 1; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool[data-tool="poke"]::after { - content: "\1F448"; - background: none; - font-size: 48px; - line-height: 1; - opacity: 0.11; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} - -.tool[data-tool="pinch"] { --tool-icon: none; } -.tool[data-tool="pinch"]::before { - content: "\1F90F"; - background: none; - font-size: 25px; - line-height: 1; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool[data-tool="pinch"]::after { - content: "\1F90F"; - background: none; - font-size: 48px; - line-height: 1; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} -.tool[data-tool="water_hose"] { --tool-icon: none; } -.tool[data-tool="water_hose"]::before { - content: "\1F6BF"; - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 24px; - line-height: 1; - filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); -} -.tool[data-tool="water_hose"]::after { - content: "\1F6BF"; - background: none; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; - font-size: 46px; - line-height: 1; - opacity: 0.10; - right: -6px; - left: auto; - top: 50%; - bottom: auto; - transform: translateY(-50%); - width: 54px; - height: 54px; -} - -.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.png"); } -.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.png"); } -.tool.selected { - background: linear-gradient(180deg, #ffffff, #efffde); - border-color: rgba(103, 178, 44, 0.52); - box-shadow: inset 0 0 0 1px rgba(156,232,75,0.38), 0 8px 20px rgba(117, 166, 64, 0.13); -} - -.hint { - color: var(--muted); - font-size: 12px; - line-height: 1.40; - margin: 10px 0 0; -} - -.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } -.tool-head h2 { margin-bottom: 0; } -.btn.mini { padding: 6px 10px; font-size: 12px; } -.tool-body { margin-top: 12px; } -.tool-palette { display: flex; flex-direction: column; gap: 9px; } -.tool-category { - border: 1px solid rgba(84,68,48,0.10); - border-radius: 14px; - background: rgba(255,255,255,0.34); - overflow: hidden; -} -.tool-category-toggle { - width: 100%; - border: 0; - border-bottom: 1px solid rgba(84,68,48,0.08); - background: rgba(255,252,244,0.66); - padding: 8px 10px; - text-align: left; - cursor: pointer; - font-weight: 700; - font-size: 12px; -} -.tool-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } -.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; } -.tool-category-body { padding: 8px; } -.tool-category.collapsed .tool-category-body { display: none; } - -.selected-data-groups { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 8px; -} -.selected-category { - border: 1px solid rgba(84,68,48,0.10); - border-radius: 14px; - background: rgba(255,255,255,0.30); - overflow: hidden; -} -.selected-category-toggle { - width: 100%; - border: 0; - border-bottom: 1px solid rgba(84,68,48,0.08); - background: rgba(255,252,244,0.66); - padding: 8px 10px; - text-align: left; - cursor: pointer; - font-weight: 700; - font-size: 12px; - color: var(--ink); -} -.selected-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); } -.selected-category.collapsed .selected-category-toggle::before { content: "\25b8 "; } -.selected-category-body { padding: 7px 9px; } -.selected-category.collapsed .selected-category-body { display: none; } -.selected-category-body .info-row:last-child { border-bottom: 0; } -.tool-size-badge { - position: absolute; - right: 7px; - bottom: 6px; - min-width: 20px; - padding: 2px 5px; - border-radius: 999px; - background: rgba(42,36,29,0.68); - color: #fff; - font-size: 10px; - line-height: 1; - text-align: center; - box-shadow: 0 1px 4px rgba(42,36,29,0.16); - pointer-events: none; -} -.chart-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; margin: 3px 0 8px; } -.chart-tabs button { border: 1px solid var(--line); background: rgba(255,255,255,0.62); border-radius: 999px; padding: 5px 4px; font-size: 11px; cursor: pointer; } -.chart-tabs button.active { background: #efffde; border-color: rgba(103,178,44,0.46); font-weight: 700; } -.log-accident { border-left-color: rgba(226, 136, 54, 0.76); } - - -.stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - margin-bottom: 12px; -} - -.stat { - background: linear-gradient(180deg, rgba(255,255,255,0.58), rgba(246, 240, 228, 0.72)); - border: 1px solid var(--line); - border-radius: 13px; - padding: 9px 8px; - text-align: center; -} -.stat span { display: block; font-size: 11px; color: var(--muted); } -.stat strong { font-size: 18px; } - -.meter-row { - display: grid; - grid-template-columns: 72px 1fr; - align-items: center; - gap: 8px; - font-size: 12px; - margin: 6px 0; -} -meter { - width: 100%; - height: 12px; - accent-color: var(--accent); -} - - -.selected-card.hidden { display: none; } -.selected-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} -.selected-head h2 { margin-bottom: 0; } -.selected-close { - width: 28px; - height: 28px; - border: 1px solid rgba(84,68,48,0.16); - border-radius: 999px; - background: rgba(255,255,255,0.76); - color: rgba(76,58,42,0.78); - font-size: 20px; - line-height: 24px; - cursor: pointer; - display: grid; - place-items: center; - padding: 0; -} -.selected-close:hover { background: #fff; } - -.selected-info { - font-size: 13px; - line-height: 1.40; -} -.selected-info.empty { - color: var(--muted); -} - -.selected-name-editor { - margin: 0 0 8px; -} -.selected-name-input { - width: 100%; - box-sizing: border-box; - border: 1px solid rgba(103,178,44,0.34); - border-radius: 13px; - background: rgba(246,252,238,0.96); - color: var(--ink); - font-size: 18px; - font-weight: 700; - line-height: 1.25; - padding: 8px 10px; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.82); -} -.selected-name-input:focus { - outline: 2px solid rgba(103,178,44,0.24); - border-color: rgba(103,178,44,0.62); - background: #fff; -} -.selected-effects { - margin: 2px 0 6px; -} -.badge { - display: inline-block; - font-size: 11px; - line-height: 1; - border-radius: 999px; - padding: 5px 8px; - background: #f1f8e9; - border: 1px solid rgba(103, 178, 44, 0.28); - margin: 0 4px 4px 0; -} -.info-row { - display: flex; - justify-content: space-between; - gap: 10px; - border-bottom: 1px dashed rgba(84,68,48,0.16); - padding: 5px 0; -} -.info-row span:first-child { color: var(--muted); } -.personality-values strong { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 2px 8px; - max-width: 68%; - text-align: right; -} -.personality-values strong span { - color: var(--ink); - white-space: nowrap; -} -.personality-tag { - display: inline-block; - border-radius: 999px; - padding: 2px 7px; - background: rgba(103,178,44,0.10); - border: 1px solid rgba(103,178,44,0.22); -} -.personality-tag.neutral { - background: rgba(120,110,90,0.08); - border-color: rgba(120,110,90,0.16); -} - -.log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; } -.log { - max-height: 360px; - min-height: 170px; - flex: 1 1 auto; - overflow: auto; - display: flex; - flex-direction: column; - gap: 7px; - font-size: 12px; - overscroll-behavior: contain; -} -.log-entry { - padding: 8px 9px; - border-radius: 12px; - background: rgba(255,252,246,0.82); - border: 1px solid rgba(84,68,48,0.12); - border-left: 4px solid rgba(118, 101, 74, 0.24); -} -.log-tag { - display: inline-block; - min-width: 34px; - margin-right: 6px; - padding: 2px 5px; - border-radius: 999px; - font-size: 9px; - line-height: 1.1; - text-align: center; - background: rgba(84,68,48,0.08); - color: rgba(72,58,43,0.72); - text-transform: uppercase; -} -.log-entry .time { color: var(--muted); margin-right: 6px; } -.log-birth { border-left-color: rgba(115, 185, 75, 0.66); } -.log-death { border-left-color: rgba(91, 83, 75, 0.60); } -.log-fight { border-left-color: rgba(214, 94, 70, 0.68); } -.log-food { border-left-color: rgba(118, 185, 77, 0.68); } -.log-grass { border-left-color: rgba(72, 148, 67, 0.68); } -.log-weather { border-left-color: rgba(92, 142, 210, 0.62); } -.log-relation { border-left-color: rgba(204, 126, 176, 0.62); } -.log-observe { border-left-color: rgba(175, 146, 86, 0.62); } - -@media (max-width: 980px) { - #app { padding: 10px; } - .topbar { align-items: flex-start; flex-direction: column; } - .top-left { align-items: flex-start; flex-direction: column; } - .brand { align-items: center; flex-direction: row; gap: 10px; } - .layout { grid-template-columns: 1fr; } - .panel { max-height: none; overflow: visible; } - #gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); } - .stage-wrap { min-height: var(--field-mobile-min-height, 460px); } - .field-choice-grid { grid-template-columns: 1fr; } -} - - - - -.tree-note { - color: var(--muted); - font-size: 12px; - line-height: 1.45; - margin: 0 0 10px; - padding: 8px 10px; - border-radius: 10px; - background: rgba(255,255,255,0.54); - border: 1px solid rgba(84,68,48,0.10); -} - -.lineage-tree { - overflow: auto; - padding: 8px 10px 18px; - contain: content; -} - -.lineage-main-card { - margin-top: 16px; - min-height: 0; -} - -.lineage-main-card h2 { - margin-bottom: 6px; -} - -.lineage-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 8px; - flex-wrap: wrap; -} - -.lineage-head h2 { margin-bottom: 0; } - -.lineage-main-content { - height: min(78vh, 980px); - min-height: 520px; - max-height: calc(100vh - 120px); - overflow: auto; - overscroll-behavior: contain; - padding: 10px 6px 24px; - background: rgba(255,250,244,0.48); - border-radius: 14px; - border: 1px solid rgba(84,68,48,0.10); - scrollbar-gutter: stable both-edges; -} - -.lineage-forest { - display: flex; - flex-direction: column; - gap: 12px; -} - -.lineage-main-content .lineage-tree { - min-width: 360px; - overflow: visible; - padding-right: 2px; -} - - -body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - background: - radial-gradient(circle at 8% 20%, rgba(255,255,255,0.62), transparent 16%), - radial-gradient(circle at 92% 18%, rgba(255,255,255,0.45), transparent 18%), - linear-gradient(180deg, transparent 0%, rgba(114, 152, 78, 0.05) 100%); -} - - -.stage-wrap::before { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: - radial-gradient(circle at 18% 14%, rgba(255,255,255,0.12), transparent 18%), - radial-gradient(circle at 84% 10%, rgba(255,255,255,0.09), transparent 16%), - linear-gradient(180deg, rgba(255,255,255,0.07), transparent 24%); - z-index: 1; -} -#gameCanvas { - position: relative; - z-index: 0; -} - -.card::after { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - border-radius: inherit; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.55); -} - -/* Family tree node primitives. The archive deliberately uses these nodes inside an SVG-backed tree, not the old card/grid UI. */ -.family-node { - position: relative; - display: block; - min-width: 0; - min-height: 34px; - padding: 6px 8px; - border-radius: 10px; - background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90)); - border: 1px solid rgba(84,68,48,0.14); - box-shadow: 0 3px 8px rgba(64,48,32,0.045); - overflow: hidden; - isolation: isolate; -} -.family-node.child { - border-color: rgba(103, 178, 44, 0.38); - box-shadow: inset 0 0 0 1px rgba(156,232,75,0.17), 0 3px 8px rgba(64,48,32,0.045); -} -.family-node[data-family-tarinai-id] { cursor: pointer; transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; } -.family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); } -.family-node.dead { opacity: 0.9; filter: grayscale(0.32); } -.family-node.missing { opacity: 0.48; border-style: dashed; } -.family-portrait { - position: absolute; - right: -6px; - bottom: -8px; - width: 64px; - height: 64px; - z-index: 0; - pointer-events: none; -} -.family-node.alive .family-portrait { opacity: 0.80; } -.family-node.dead .family-portrait { opacity: 0.20; } -.family-portrait img { - width: 100%; - height: 100%; - object-fit: contain; - filter: saturate(0.95); -} -.family-copy { position: relative; z-index: 1; min-width: 0; line-height: 1.12; max-width: calc(100% - 8px); } -.family-copy strong, .family-copy span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.family-copy .death-reason { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.12; - margin-top: 1px; - color: rgba(126, 80, 64, 0.92); -} -.family-copy strong { font-size: 11px; } -.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; } - - -.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; } -.colony-stats { - grid-template-columns: repeat(2, 1fr); - margin-bottom: 8px; -} -.colony-chart { - display: block; - width: 100%; - height: 188px; - min-height: 188px; - border: 1px solid rgba(84, 68, 48, 0.14); - border-radius: 14px; - background: linear-gradient(180deg, rgba(255,255,255,0.70), rgba(246,240,228,0.54)); - overflow: hidden; -} -.colony-card h2 { line-height: 1.45; margin-bottom: 10px; } -.colony-chart svg { display: block; width: 100%; height: 100%; } -.colony-chart-legend { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - align-items: center; - margin: 4px 0 8px; - font-size: 11px; - color: var(--muted); - line-height: 1.25; -} -.colony-chart-legend span { - display: inline-flex; - align-items: center; - gap: 4px; - white-space: nowrap; -} -.colony-chart-legend i { - width: 8px; - height: 8px; - border-radius: 999px; - display: inline-block; - box-shadow: 0 0 0 1px rgba(70, 52, 36, 0.10); -} - -.relation-row strong { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; } -.relation-face { - width: 24px; - height: 24px; - object-fit: contain; - border-radius: 999px; - background: rgba(255,255,255,0.66); - border: 1px solid rgba(84,68,48,0.12); - vertical-align: middle; - flex: 0 0 auto; -} - - -.lineage-family { - padding: 10px; - margin: 0 0 12px; - border-radius: 15px; - background: rgba(255,253,248,0.62); - border: 1px solid rgba(84,68,48,0.12); -} -.lineage-family h3 { - margin: 0 0 8px; - font-size: 13px; - color: rgba(64,52,38,0.86); -} -/* Family archive: actual top-down tree diagram. */ -.lineage-family-tree { - overflow-x: auto; - overflow-y: visible; - padding: 10px 10px 22px; - -webkit-overflow-scrolling: touch; - overscroll-behavior-x: contain; -} -.lineage-family-tree h3 { - position: sticky; - left: 0; - z-index: 3; - width: max-content; - padding-right: 12px; - background: rgba(255,253,248,0.82); - border-radius: 8px; -} -.lineage-tree-hint { - position: sticky; - left: 0; - z-index: 3; - width: max-content; - max-width: 100%; - margin: -2px 0 8px; - padding: 5px 8px; - border-radius: 9px; - color: var(--muted); - font-size: 11px; - background: rgba(255,255,255,0.62); - border: 1px solid rgba(84,68,48,0.08); -} -.lineage-family-tree .lineage-tree-panel { - position: relative; - overflow: visible; - min-width: 360px; - margin-top: 6px; - border-radius: 14px; - background: - linear-gradient(90deg, rgba(84,68,48,0.045) 1px, transparent 1px), - linear-gradient(180deg, rgba(84,68,48,0.040) 1px, transparent 1px), - rgba(255,250,244,0.48); - background-size: 34px 34px, 34px 34px, auto; - border: 1px solid rgba(84,68,48,0.10); -} -.lineage-family-tree .lineage-links { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: visible; - pointer-events: none; - z-index: 1; - isolation: isolate; -} - -.lineage-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} -.btn.active { - background: #efffde; - border-color: rgba(103,178,44,0.46); - font-weight: 700; -} - -.lineage-render-status { - position: sticky; - top: 0; - left: 0; - z-index: 6; - width: max-content; - margin: 0 0 8px; - padding: 6px 10px; - border-radius: 999px; - font-size: 12px; - color: var(--muted); - background: rgba(255,255,255,0.88); - border: 1px solid rgba(84,68,48,0.10); - box-shadow: 0 4px 10px rgba(64,48,32,0.06); -} -.lineage-stale-status { - position: sticky; - top: 0; - left: 0; - z-index: 6; - width: max-content; - max-width: calc(100% - 12px); - margin: 0 0 8px; - padding: 6px 10px; - border-radius: 999px; - font-size: 12px; - color: rgba(92, 75, 52, 0.82); - background: rgba(255, 249, 232, 0.92); - border: 1px solid rgba(150, 115, 46, 0.16); - box-shadow: 0 4px 10px rgba(64,48,32,0.05); -} -.lineage-family-tree .lineage-links path { - fill: none; - stroke: rgba(107, 88, 61, 0.58); - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} -.lineage-family-tree .lineage-links .partner-link { - stroke-width: 2.4; - stroke: rgba(98, 80, 56, 0.68); - stroke-dasharray: 5 4; -} -.lineage-family-tree .lineage-links .child-link { - stroke-width: 2; - stroke: rgba(107, 88, 61, 0.58); -} -.lineage-family-tree .tree-node { - position: absolute; - width: 132px; - height: 68px; - min-height: 68px; - box-sizing: border-box; - align-items: flex-start; - background: linear-gradient(180deg, #fffdf8, #f8f1e5); - box-shadow: 0 2px 5px rgba(64,48,32,0.035); - contain: layout paint style; - z-index: 2; - isolation: isolate; -} -.lineage-family-tree .tree-node.child { - border-color: rgba(103,178,44,0.34); -} -.lineage-family-tree .tree-node.dead { - opacity: 1; - filter: none; -} -.lineage-family-tree .tree-node[data-family-tarinai-id]:hover { - transform: none; -} -.lineage-family-tree .tree-node .family-portrait { - right: 0; - bottom: -6px; - width: 58px; - height: 58px; -} -.lineage-family-tree .tree-generation-label { - position: absolute; - left: 10px; - width: 54px; - padding: 3px 0; - border-radius: 999px; - font-size: 10px; - color: var(--muted); - text-align: center; - background: rgba(255,255,255,0.70); - border: 1px solid rgba(84,68,48,0.08); - z-index: 2; -} -.lineage-family-tree .lineage-empty-family { - position: absolute; - left: 72px; - bottom: 8px; - padding: 5px 8px; - border-radius: 9px; - background: rgba(255,255,255,0.70); -} - -@media (max-width: 720px) { - .ecology-grid { grid-template-columns: 1fr; } - .ecology-card { grid-template-columns: 68px minmax(0, 1fr); } - .ecology-card img { width: 68px; height: 68px; } -} - -@media (max-width: 720px) { - .lineage-main-content { - height: 70vh; - min-height: 420px; - max-height: none; - } -} - - -.tool[data-tip] { position: relative; } -.tool-tip-popover { - position: fixed; - z-index: 2147483000; - width: 220px; - max-width: min(220px, calc(100vw - 16px)); - padding: 7px 9px; - border-radius: 10px; - background: rgba(46, 38, 30, 0.78); - color: #fff8e6; - font-family: "Yomogi", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif; - font-size: 12px; - line-height: 1.45; - box-shadow: 0 10px 28px rgba(40, 32, 22, 0.18); - backdrop-filter: blur(1px); - text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55); - pointer-events: none; -} -.tool-tip-popover.hidden { display: none; } -.selected-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin: 8px 0 4px; -} -.favorite-toggle { - border: 1px solid rgba(128, 102, 56, 0.32); - border-radius: 999px; - background: rgba(255, 248, 221, 0.92); - color: #5a4630; - font-weight: 700; - padding: 5px 10px; - cursor: pointer; -} -.favorite-toggle.active { - background: rgba(255, 229, 122, 0.95); - border-color: rgba(180, 132, 34, 0.55); -} - -.selected-records { - margin-top: 8px; - padding-top: 7px; - border-top: 1px dashed rgba(126, 96, 64, 0.24); -} -.selected-records h3 { - margin: 0 0 5px; - font-size: 12px; - color: var(--muted); -} -.selected-records ol { - margin: 0; - padding: 0; - list-style: none; - display: grid; - gap: 4px; -} -.selected-records li { - display: grid; - grid-template-columns: 38px 1fr; - gap: 6px; - align-items: start; - font-size: 11px; - line-height: 1.35; -} -.selected-records li span { - color: var(--muted); - font-variant-numeric: tabular-nums; -} -.selected-records li strong { - font-weight: 600; -} - -.relation-list-row { - align-items: start; -} -.relation-list-row > strong { - min-width: 0; - width: 100%; - justify-content: flex-end; -} -.relation-chip-list { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 5px; - max-width: 100%; -} -.relation-chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 138px; - min-width: 0; - border: 1px solid rgba(104, 150, 84, 0.24); - border-radius: 999px; - background: rgba(250, 255, 242, 0.88); - color: #4d6140; - padding: 2px 6px 2px 2px; - font: inherit; - font-size: 11px; - line-height: 1.2; - cursor: pointer; - box-shadow: 0 1px 0 rgba(255,255,255,0.74); -} -.relation-chip.enemy { - border-color: rgba(178, 92, 74, 0.28); - background: rgba(255, 246, 240, 0.92); - color: #754739; -} -.relation-chip:hover, -.relation-chip:focus-visible { - transform: translateY(-1px); - background: rgba(255,255,255,0.98); - outline: 2px solid rgba(118, 186, 72, 0.18); -} -.relation-chip.enemy:hover, -.relation-chip.enemy:focus-visible { - outline-color: rgba(190, 92, 72, 0.18); -} -.relation-chip .relation-face { - width: 22px; - height: 22px; -} -.relation-chip-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} -.relation-chip em { - font-style: normal; - font-size: 10px; - opacity: 0.72; - flex: 0 0 auto; -} +/* v15.18.0: split into base.css, layout.css, panel.css, components.css, mobile.css. Keep this file as a compatibility shim only. */ diff --git a/docs/AI_CONTEXT.md b/docs/AI_CONTEXT.md index 53984ed..08c3f36 100644 --- a/docs/AI_CONTEXT.md +++ b/docs/AI_CONTEXT.md @@ -2,11 +2,11 @@ Purpose: dense orientation for code-editing agents. Browser-only simulation; no build system. Source uses globals and strict script order, not ESM. -Load order from `index.html`: `data -> math -> assets -> audio -> render -> sim_core -> items -> health -> tarinai -> world -> text_catalog -> ui -> ui_charts -> ui_family -> main`. +Load order from `index.html`: `version -> event_bus -> performance_manager -> data -> math -> assets -> audio -> render -> sim_core -> items -> ants -> health -> tarinai core/mixins -> world core/mixins -> world_bootstrap -> text_catalog -> ui state/helpers/bind -> ui_charts -> ui_family modules -> main -> debug_tools`. Primary objects: -- `World` (`js/world.js`): owns field size, time/weather, tarinai array, items, effects, logs, archive, placement, collision, interaction dispatch. -- `Tarinai` (`js/tarinai.js`): agent state and AI. Use helper files for shared rules; avoid making this file larger. +- `World` (`js/world.js` + `js/world_*.js`): constructor is in `world.js`; methods are split by concern into `world_view`, `world_family_social`, `world_combat_effects`, `world_environment`, `world_spatial_budget`, `world_ants_system`, `world_update`, and `world_placement_log`. +- `Tarinai` (`js/tarinai.js` + `js/tarinai_*.js`): constructor is in `tarinai.js`; methods are split into identity/social, disease/nest, needs/items, social/move/life, and render modules. - `Item` (`js/items.js`): item state, draw, lifecycle. - `HEALTH` (`js/health.js`): damage memory, major-damage window, death-cause classification, damage application. - `TEXT_CATALOG` (`js/text_catalog.js`): Japanese tooltips and ecology cards. Keep Japanese escaped. diff --git a/docs/INTERNAL_SYSTEMS.md b/docs/INTERNAL_SYSTEMS.md new file mode 100644 index 0000000..cdfefce --- /dev/null +++ b/docs/INTERNAL_SYSTEMS.md @@ -0,0 +1,69 @@ +# Internal systems v15.18.0 + +Implemented systems except full save/load serialization. + +## Version/cache +- `js/version.js` owns the runtime app version. +- `index.html` and `service-worker.js` use `15.18.0` cache-busting consistently. +- `service-worker.js` is registered with `updateViaCache: "none"`. +- `.htaccess` marks `service-worker.js` as no-cache/no-store and static assets as immutable. + +## Event bus +- `js/event_bus.js` exposes `window.TarinaiEvents`. +- `World.emit/on` forward to the shared event bus. +- Current emitted events include `log:entry`, `world:phase`, `tool:place`, `terrain:dirty`, `performance:level`, `ant:nest-built`, and `audio:sample-error`. + +## Performance manager +- `js/performance_manager.js` exposes `window.TarinaiPerformance`. +- FPS samples are averaged and converted into `level` 0-3. +- Ant update stride, effect cap, terrain redraw interval, and UI/stats intervals can now be tuned from one place. + +## Terrain dirty cache +- Terrain cache no longer hashes every cached terrain item every frame. +- `World.markTerrainDirty(reason)` increments `terrainVersion`. +- The cached terrain layer redraws from `terrainVersion`, light bucket, field, and canvas size. + +## Spatial grid +- `SpatialGrid` now maintains `itemCells`, `tarinaiCells`, `antCells`, `obstacleCells`, `foodCells`, and `hazardCells`. +- `World.nearbyAnts/nearbyObstacles/nearbyFood/nearbyHazards` are available for future behavior/collision work. + +## Debug mode +- Add `?debug=1` to the URL to show a lightweight overlay with FPS, performance level, entity counts, terrain/spatial versions, and recent event bus entries. + +## Audio system +- Audio has per-ID gain, per-ID minimum gap, a concurrent voice cap, sample load failure reporting, and a test event hook: `TarinaiEvents.emit("audio:test", { id: "sfx_notify" })`. + + +## File split in 15.18.0 + +Large legacy files were split into mixin-style modules. `world.js` and `tarinai.js` now define constructors only; prototype methods are attached by adjacent `world_*` and `tarinai_*` files before `world_bootstrap.js` creates the global `world` instance. This preserves the no-build browser deployment model while reducing edit conflicts and improving maintainability. + +## v15.18.0 internal system pass + +### Static manifest +- `app_manifest.json` is the source of truth for CSS and JS loading order. +- `scripts/generate_app_files.py` regenerates `index.html` link/script tags and `service-worker.js` cache lists. +- This prevents drift between HTML, Service Worker, and versioned cache assets. + +### Effect registry +- `js/effect_registry.js` defines item/disease effect metadata in one place: label, color, effect kind, exclusivity group, and display text. +- `tarinai_item_effects.js` now reads labels, colors, random-effect candidates, and selected-data display strings from `TarinaiEffectRegistry`. +- Future item effects should be registered here first, then only behavior-specific logic should be added to the Tarinai methods. + +### Sound pack +- `js/sound_pack.js` defines category gains, ID gains, cooldowns, and optional audio file paths. +- `js/audio.js` consumes `window.TARINAI_SOUND_PACK` at startup. +- Material replacement should normally edit `sound_pack.js`, not core audio synthesis code. + +### CSS split +- `css/styles.css` is now a compatibility shim. +- Actual styles are loaded in order: `base.css`, `layout.css`, `panel.css`, `components.css`, `mobile.css`. +- Preserve this order unless selector precedence is audited. + +### Mobile input modes +- Coarse pointer/mobile displays show a small canvas mode selector. +- Modes: automatic, camera, tool, family-tree priority. +- Camera mode treats one-finger drag as camera pan; tool mode suppresses accidental panning and favors tap/tool use. + +### Diagnostics +- `?debug=1` now shows cache name, Service Worker control state, sound pack version, recent audio IDs, and registered effect count. diff --git a/docs/SOUND_IDS.md b/docs/SOUND_IDS.md new file mode 100644 index 0000000..901e357 --- /dev/null +++ b/docs/SOUND_IDS.md @@ -0,0 +1,55 @@ +# 効果音ID一覧 v15.12.0 + +音素材を差し替える場合は `js/audio.js` の `samplePaths` に、下記IDとファイルパスを追加してください。未指定IDはブラウザ内蔵音で仮再生します。 + +## たりないの声 +- `voice_hau` +- `voice_flee` +- `voice_poop` +- `voice_stress` +- `sfx_eat` +- `sfx_sleep` +- `sfx_wake` +- `sfx_birth` +- `sfx_death` +- `sfx_damage_soft` +- `sfx_damage_heavy` +- `sfx_fight_hit` +- `sfx_fight_finish` +- `sfx_disease` +- `sfx_heal` +- `sfx_ant_drag` +- `sfx_ant_die` +- `sfx_queen_ant` + +## 通知 +- `sfx_notify` +- `sfx_log_push` +- `sfx_phase` + +## 操作 +- `sfx_ui_click` +- `sfx_ui_fold` +- `sfx_place` +- `sfx_delete` +- `sfx_poke` +- `sfx_grab` +- `sfx_drop` +- `sfx_water_hose` +- `sfx_water_clean` +- `sfx_drink` +- `sfx_ball_hit` +- `sfx_stone_impact` +- `sfx_firecracker_fuse` +- `sfx_firecracker_explode` +- `sfx_genkotsu_fall` +- `sfx_genkotsu_impact` +- `sfx_ground_rumble` +- `sfx_grass` +- `sfx_zunchi` +- `sfx_ant_nest` + +## v15.18.0 sound pack + +Sound paths, per-ID volume multipliers, category multipliers, and minimum replay intervals are now centralized in `js/sound_pack.js`. +For normal sound replacement, edit `TARINAI_SOUND_PACK.samplePaths` with the same stable sound ID instead of changing `js/audio.js`. diff --git a/docs/code_map.md b/docs/code_map.md index 5ae1a80..2737394 100644 --- a/docs/code_map.md +++ b/docs/code_map.md @@ -9,17 +9,17 @@ Use this as the first lookup table before opening large files. | Lazy image loading | `js/assets.js` / `loadInitialImages`, `loadDeferredImages`, `getRenderableImage` | | UI Japanese text catalog | `js/text_catalog.js` / `TEXT_CATALOG` | | Core agent state | `js/tarinai.js` / `class Tarinai` constructor | -| Agent AI target choice | `js/tarinai.js` / `resolveNeeds`, target helpers | -| Disease state transitions | `js/tarinai.js` / `infect*`, `recover*`, `update*Disease*` | +| Agent AI target choice | `js/tarinai_needs_items.js` / `resolveNeeds`, target helpers | +| Disease state transitions | `js/tarinai_disease_nest.js` / `infect*`, `recover*`, `update*Disease*` | | Damage/death cause rules | `js/health.js` / `HEALTH` | | Item creation/lifecycle/draw | `js/items.js` / `class Item` | -| Cleaning, rain, placement | `js/world.js` / placement/update interaction helpers | -| Fence collision | `js/world.js` / `resolveFenceCollision` | -| Logs/archive | `js/world.js` | +| Cleaning, rain, placement | `js/world_combat_effects.js`, `js/world_update.js`, `js/world_placement_log.js` | +| Fence collision | `js/world_environment.js` / `resolveFenceCollision` | +| Logs/archive | `js/world_placement_log.js`, `js/ui_family*.js` | | Main renderer | `js/render.js` / `render` helpers | -| Agent drawing | `js/tarinai.js` / `draw` | -| Input/tools/dialogs | `js/ui.js` | +| Agent drawing | `js/tarinai_render.js` / `draw` | +| Input/tools/dialogs | `js/ui.js`, `js/ui_bind.js`, `js/ui_layout_dialogs.js`, `js/ui_tools.js` | | Statistics UI | `js/ui_charts.js` | -| Family tree UI | `js/ui_family.js` | +| Family tree UI | `js/ui_family_data.js`, `js/ui_family_async.js`, `js/ui_family_layout.js`, `js/ui_family_paths.js`, `js/ui_family_render.js` | Refactor guidance: extract isolated rule sets into small helper files, then delegate from `Tarinai`/`World`. Keep shared domain terms centralized (`HEALTH`, `TEXT_CATALOG`, `CONFIG`) to reduce repeated prompt context. diff --git a/index.html b/index.html index c95f7b3..3594cce 100644 --- a/index.html +++ b/index.html @@ -3,18 +3,23 @@ - - たりない観察 - + + + + + -
+
-
たりない観察を読み込み中
-
通信量を抑えて初期化しています…
- -
0%
+
たりないコロニー
+

読み込み中…

+

準備しています

+ +
+
進行状況0%
+

初期表示に必要な画像だけ先に読み込み、残りは後から順次ロードします。

@@ -29,7 +34,16 @@ - +
+ + +
@@ -38,6 +52,12 @@
+
+ + + + +
@@ -50,8 +70,9 @@
未選択
-
-

道具

+
+ +
@@ -74,6 +95,15 @@ + + + + + + + + +
@@ -81,11 +111,11 @@
- + @@ -94,22 +124,40 @@

同じ道具ボタンをもう一度押すと、対応道具の大きさが小・中・大で切り替わります。

+ -
-

コロニー

+
+ +
個体数0
累計死亡数0
-
+
+
-
-

イベント

-
+
+ +
+
+
+ +
+ + + + + + + + +
+
+
@@ -178,21 +226,53 @@ - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/ants.js b/js/ants.js index afbc83f..ffe7f18 100644 --- a/js/ants.js +++ b/js/ants.js @@ -3,15 +3,15 @@ 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; function clampAntHp(v) { return clamp(Number(v) || 0, 0, ANT_WORKER_HP); @@ -43,6 +43,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 +86,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; @@ -134,7 +183,7 @@ class AntActor { updateWorker(dt) { const home = this.homeNest(); if (!home) { - this.dead = true; + this.updateWanderNoNest(dt); return; } if (this.hp <= 0) { @@ -158,6 +207,7 @@ 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"; + audio.antDrag?.(); this.world.drawListDirty = true; return; } @@ -180,7 +230,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)); @@ -262,6 +314,7 @@ class AntActor { dieAsCorpse(home = null) { if (this.dead) return; this.dead = true; + audio.antDie?.(); const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; corpse.workerSprite = this.workerSpriteId(); @@ -288,9 +341,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 +403,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 +411,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; diff --git a/js/assets.js b/js/assets.js index efd8b2b..9929c39 100644 --- a/js/assets.js +++ b/js/assets.js @@ -138,7 +138,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; } diff --git a/js/audio.js b/js/audio.js index 24b20cc..d0fb872 100644 --- a/js/audio.js +++ b/js/audio.js @@ -1,88 +1,394 @@ "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: {}, + activeVoices: 0, + maxConcurrent: 96, + failedSamples: {}, + lastPlayedId: "", + lastPlayedAt: 0, + soundPackVersion: "inline", + masterGainNode: null, + compressorNode: null, + _synthIdGain: 1, + 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, + }, + categoryEnabled: { voice: false, notify: true, ops: true }, + categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, 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", + voice_hau: "assets/sounds/voice_001_hau.wav", + voice_flee: "assets/sounds/voice_002_flee.wav", + voice_poop: "assets/sounds/voice_003_po.wav", + sfx_eat: "assets/sounds/voice_004_eat.wav", + voice_stress: "assets/sounds/voice_005_stress.wav", + sfx_sleep: "assets/sounds/voice_006_sleep.wav", + sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3", + + // Optional replacement slots. Missing files are never requested unless you add a path. + // sfx_notify: "assets/sounds/sfx_notify.mp3", + // sfx_ui_click: "assets/sounds/sfx_ui_click.mp3", + // sfx_genkotsu_fall: "assets/sounds/sfx_genkotsu_fall.mp3", + // sfx_genkotsu_impact: "assets/sounds/sfx_genkotsu_impact.mp3", + }, + soundCategories: { + voice: "たりないの声", + notify: "通知", + ops: "操作", + }, + 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", + }, + 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 }; + 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("もぐ")) return this.playSample("sfx_eat", 0.20, "voice") || this.play("sfx_eat", { category: "voice", minGap: 0.20 }); + if (s.includes("ぶり")) return this.playSample("voice_poop", 0.28, "voice") || this.play("sfx_zunchi", { category: "ops", minGap: 0.28 }); + if (s.includes("はっ")) 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("はぁ") || s.includes("はあ")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 }); + if (s.includes("はう") || s.includes("はぅ")) 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..e228a5f 100644 --- a/js/data.js +++ b/js/data.js @@ -1,70 +1,80 @@ "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 }, ]; 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" }, ]; 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" }, + 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" }, + 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: "下剤", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "食べると一定時間、3秒ごとにずんちを放出し、食べても空腹度が下がらなくなる。" }, + protein: { id: "protein", itemType: "protein", label: "プロテイン", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "永続。与えるダメージ1.75倍、病気発症確率半減。ンイテロプとは最新効果だけ有効。" }, + niteropu: { id: "niteropu", itemType: "niteropu", label: "ンイテロプ", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "永続。与えるダメージ0.5倍、病気発症確率2倍。プロテインとは最新効果だけ有効。" }, + ammo: { id: "ammo", itemType: "ammo", label: "弾薬", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "効果時間中、歩行速度10倍。移動中にたりないやアリへ衝突すると弾き飛ばす。" }, + mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "怪しい薬", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "行動を変えるアイテム効果と病気の中からランダムで2つ付与する。" }, + mercury: { id: "mercury", itemType: "mercury", label: "水銀", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "食べるたびに寿命が1.3倍ずつ乗算される。ずんだ汁で解除できる。" }, + giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "巨大薬", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "永続。サイズ3倍、攻撃力2倍、移動速度1.5倍。毎秒1ダメージ。矮小薬とは最新効果だけ有効。" }, + dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "矮小薬", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "永続。サイズ0.25倍、攻撃力0.25倍、移動速度0.5倍。毎秒1ダメージ。巨大薬とは最新効果だけ有効。" }, + zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "ずんだ汁", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "食べると全てのアイテム効果を除去する。" }, + 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: "上から正義げんこつを高速で落とし、アリとたりないに大ダメージと吹っ飛びを与える。" }, + 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" }, + 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" }, + 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 }, }); function toolDefinition(id = "") { diff --git a/js/debug_tools.js b/js/debug_tools.js new file mode 100644 index 0000000..df85fc4 --- /dev/null +++ b/js/debug_tools.js @@ -0,0 +1,37 @@ +"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) || {}; + 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 || "?"}`, + `audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`, + `effects ${window.TarinaiEffectRegistry?.debugSnapshot?.().length || 0} registered`, + `events ${lastEvents.join(", ")}`, + ].join("\n"); + }, 500); +})(); diff --git a/js/effect_registry.js b/js/effect_registry.js new file mode 100644 index 0000000..6fc4bf7 --- /dev/null +++ b/js/effect_registry.js @@ -0,0 +1,85 @@ +"use strict"; + +(function (global) { + const rawDefinitions = { + laxative: { + label: "下剤", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, + tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}秒 / 3秒ごとにずんち / 食事の空腹回復なし`, + }, + ammo: { + label: "弾薬", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, + tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}秒 / 歩行速度10倍 / 衝突で弾き飛ばす`, + }, + protein: { + label: "プロテイン", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "永続 / 与ダメージ1.75倍 / 病気確率0.5倍", + }, + niteropu: { + label: "ンイテロプ", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "永続 / 与ダメージ0.5倍 / 病気確率2倍", + }, + mercury: { + label: "水銀", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", + tags: ["item", "life"], display: (_timer, t) => `永続 / 寿命x${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}(食べるたび乗算)`, + }, + giant_drug: { + label: "巨大薬", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "永続 / サイズ3倍 / 攻撃2倍 / 速度1.5倍 / 毎秒1ダメージ", + }, + dwarf_drug: { + label: "矮小薬", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "永続 / サイズ0.25倍 / 攻撃0.25倍 / 速度0.5倍 / 毎秒1ダメージ", + }, + love_mochi: { + label: "へこ餅", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}秒 / 繁殖行動が起きやすい`, + }, + fight_mochi: { + label: "けんか餅", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}秒 / 喧嘩が起きやすい`, + }, + sleep_drug: { + label: "ねむり薬", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], + }, + mystery_drug: { + label: "怪しい薬", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], + }, + zunda_juice: { + label: "ずんだ汁", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], + }, + disease_zunchi: { label: "ずんち病", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, + disease_sleep: { label: "ねむり病", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_explosion: { label: "爆発病", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_fight: { label: "きずつき病", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, + }; + + class EffectRegistry { + constructor(defs) { this.defs = new Map(Object.entries(defs || {})); } + get(id = "") { return this.defs.get(String(id || "")) || null; } + has(id = "") { return this.defs.has(String(id || "")); } + label(id = "") { return this.get(id)?.label || id || "不明"; } + color(id = "") { return this.get(id)?.color || "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 || ""); + } + labels() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.label])); } + colors() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.color]).filter(([, color]) => color)); } + randomPool() { + return [...this.defs.entries()] + .filter(([, def]) => (def.tags || []).includes("item") || (def.tags || []).includes("behavior") || (def.tags || []).includes("disease")) + .map(([id]) => id) + .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); + } + 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/health.js b/js/health.js index 33fc663..7a8eca0 100644 --- a/js/health.js +++ b/js/health.js @@ -9,6 +9,7 @@ const HEALTH = (() => { stress: "\u30b9\u30c8\u30ec\u30b9\u904e\u591a", poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f", 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 +19,8 @@ const HEALTH = (() => { fightDisease: "\u304d\u305a\u3064\u304d\u75c5", zunchiDisease: "\u305a\u3093\u3061\u75c5", antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f", + giantDrug: "巨大薬", + dwarfDrug: "矮小薬", }); const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; @@ -26,6 +29,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 (/巨大薬|giant/.test(text)) return CAUSES.giantDrug; + if (/矮小薬|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; @@ -108,7 +114,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; } @@ -122,6 +128,7 @@ const HEALTH = (() => { if (actualLoss > 0.01) { cause = rememberDamage(t, actualLoss, 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); diff --git a/js/items.js b/js/items.js index 30c8d86..a7f3467 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(["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(["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,19 @@ 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 = item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1); + return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; +} class Item { constructor(type, x, y) { @@ -81,12 +98,15 @@ class Item { this.dropMax = 0; this.dropImpactDone = false; this.impactFlash = 0; + this.lingerTimer = 0; + this.lingerDuration = 2.0; } 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; } } @@ -101,7 +121,34 @@ class Item { } } 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 (isServingFoodType(this.type)) { + 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; + worldRef?.registerFoodSpoilage?.(lost * PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + 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) { + worldRef?.registerFoodSpoilage?.(lost * 0.002, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + } + } + } + } 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,8 +161,12 @@ 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 (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); @@ -571,6 +622,104 @@ class Item { ctx.fill(); } } + } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { + const meta = { + laxative: { fill: "#eef0b8", stroke: "#879046", accent: "#dfe770", kind: "pill" }, + protein: { fill: "#f4a24f", stroke: "#b45b27", accent: "#ffe2b9", kind: "powder" }, + niteropu: { fill: "#8a79d0", stroke: "#4c3f91", accent: "#d8d1ff", kind: "powder" }, + ammo: { fill: "#ffe8b6", stroke: "#b37a26", accent: "#d0942f", kind: "box" }, + mystery_drug: { fill: "#e9ffe8", stroke: "#4a9b65", accent: "#9de7ad", kind: "pill" }, + mercury: { fill: "rgba(216,219,220,0.98)", stroke: "rgba(198,202,204,0.98)", accent: "rgba(162,169,174,0.98)", kind: "droplet" }, + giant_drug: { fill: "#ffe3cf", stroke: "#c9632f", accent: "#ffb47b", kind: "pill" }, + dwarf_drug: { fill: "#e9e9ff", stroke: "#6967ac", accent: "#b8b5f4", kind: "pill" }, + zunda_juice: { fill: "rgba(134,224,91,0.98)", stroke: "rgba(76,168,57,0.98)", accent: "rgba(202,255,186,0.98)", kind: "droplet" }, + }[this.type] || { fill: "#fffaf1", stroke: "#8d6f48", accent: "#ddd", kind: "pill" }; + const localR = this.r * (this.type === "giant_drug" ? 1.08 : (this.type === "dwarf_drug" ? 0.92 : 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") { + ctx.save(); + ctx.lineCap = "round"; + ctx.strokeStyle = meta.stroke; + ctx.lineWidth = Math.max(2.2, localR * 0.16); + ctx.globalAlpha = 0.92; + const lean = -localR * 0.34; + ctx.beginPath(); + ctx.moveTo(localR * 0.18, -localR * 0.90); + ctx.lineTo(localR * 0.18 + lean, localR * 0.42); + ctx.stroke(); + ctx.globalAlpha = 0.70; + ctx.fillStyle = meta.fill; + ctx.beginPath(); + ctx.ellipse(localR * 0.18 + lean, localR * 0.54, localR * 0.24, localR * 0.38, -0.18, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 0.50; + ctx.strokeStyle = meta.accent; + ctx.lineWidth = Math.max(1.0, localR * 0.07); + ctx.beginPath(); + ctx.moveTo(localR * 0.10, -localR * 0.72); + ctx.lineTo(localR * 0.10 + lean * 0.72, localR * 0.18); + ctx.stroke(); + ctx.restore(); + } 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)"; @@ -581,11 +730,10 @@ class Item { 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); + ctx.fillStyle = "rgba(255,255,255,0.58)"; + ctx.beginPath(); + ctx.ellipse(-this.r * 0.36, -this.r * 0.16, this.r * 0.25, this.r * 0.09, -0.20, 0, Math.PI * 2); + ctx.fill(); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); @@ -638,14 +786,22 @@ class Item { ctx.fillStyle = "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.save(); + ctx.lineCap = "round"; + ctx.strokeStyle = "rgba(198, 202, 204, 0.98)"; + ctx.lineWidth = Math.max(2.0, this.r * 0.16); + const lean = -this.r * 0.34; + ctx.globalAlpha = 0.86; 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(); + ctx.moveTo(this.r * 0.18, -this.r * 0.90); + ctx.lineTo(this.r * 0.18 + lean, this.r * 0.42); + ctx.stroke(); + ctx.globalAlpha = 0.58; + ctx.fillStyle = "rgba(216, 219, 220, 0.98)"; + ctx.beginPath(); + ctx.ellipse(this.r * 0.18 + lean, this.r * 0.54, this.r * 0.24, this.r * 0.38, -0.18, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); } 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"; diff --git a/js/main.js b/js/main.js index 3b9286f..747a502 100644 --- a/js/main.js +++ b/js/main.js @@ -7,6 +7,54 @@ 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", +]; +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 +68,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 +83,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 +99,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 +107,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 +130,11 @@ 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.19.1"; + navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); + }); +} + diff --git a/js/performance_manager.js b/js/performance_manager.js new file mode 100644 index 0000000..d7c9b3f --- /dev/null +++ b/js/performance_manager.js @@ -0,0 +1,62 @@ +"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; + const antCount = (worldRef?.ants || []).reduce((n, a) => n + (a && !a.dead ? 1 : 0), 0); + 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 ? 42 : level >= 2 ? 64 : level >= 1 ? 84 : 112, + 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/render.js b/js/render.js index 1b59734..6c1cd05 100644 --- a/js/render.js +++ b/js/render.js @@ -232,6 +232,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.type === "grass" || it.type === "trace" || it.type === "splat") return true; + if (it.type === "zunchi") return Math.hypot(it.vx || 0, it.vy || 0) < 0.08; + 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) { @@ -556,7 +652,7 @@ function drawRain(ctx, w, h, t) { 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.strokeStyle = "rgba(196, 200, 202, 0.92)"; ctx.lineWidth = randSeed(18700 + i, 1.0, 1.8); ctx.beginPath(); ctx.moveTo(x, y); @@ -564,7 +660,7 @@ function drawRain(ctx, w, h, t) { ctx.stroke(); if (perf < 3 && i % 2 === 0) { ctx.globalAlpha = 0.18 + 0.18 * alpha; - ctx.fillStyle = "rgba(215, 237, 246, 0.92)"; + ctx.fillStyle = "rgba(213, 216, 218, 0.92)"; ctx.beginPath(); ctx.ellipse(x - tilt * 0.9, y + len + 2, 1.8, 3.0, -0.28, 0, Math.PI * 2); ctx.fill(); @@ -579,7 +675,7 @@ function drawRain(ctx, w, h, t) { 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.strokeStyle = "rgba(198, 202, 204, 0.98)"; ctx.lineWidth = randSeed(19300 + i, 1.2, 2.2); ctx.beginPath(); ctx.moveTo(x, topY); @@ -588,7 +684,7 @@ function drawRain(ctx, w, h, t) { 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.fillStyle = "rgba(216, 219, 220, 0.98)"; ctx.fill(); } } @@ -620,7 +716,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; @@ -650,19 +748,21 @@ function render() { 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); + if (isCachedTerrainItem(it)) continue; + if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { - if (it.type === "zunchi") it.draw(ctx, world.time, lighting); + if (it.type === "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } ctx.save(); @@ -670,8 +770,9 @@ function render() { 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); @@ -681,9 +782,11 @@ function render() { ctx.restore(); const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; - for (const t of sorted) t.draw(ctx, world.time, lighting); + for (const t of sorted) { + if (isEntityVisibleInRect(t, visibleRect)) t.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(); diff --git a/js/sim_core.js b/js/sim_core.js index a8330e9..c9ff611 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -420,11 +420,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 +442,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 +452,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 (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h") this.add(this.obstacleCells, it); + if (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 (type === "firecracker" || type === "genkotsu" || type === "sleep_drug" || type === "zunchi" || type === "splat" || type === "mystery_drug" || type === "laxative" || type === "niteropu" || type === "mercury" || type === "giant_drug" || type === "dwarf_drug") 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 +479,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/sound_pack.js b/js/sound_pack.js new file mode 100644 index 0000000..b187823 --- /dev/null +++ b/js/sound_pack.js @@ -0,0 +1,49 @@ +"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.wav", + voice_flee: "assets/sounds/voice_002_flee.wav", + voice_poop: "assets/sounds/voice_003_po.wav", + sfx_eat: "assets/sounds/voice_004_eat.wav", + voice_stress: "assets/sounds/voice_005_stress.wav", + sfx_sleep: "assets/sounds/voice_006_sleep.wav", + sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3" + }, + categories: { + voice: "たりないの声", + notify: "通知", + ops: "操作" + } + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index 65532f1..2ce8000 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 || ""; @@ -162,2379 +161,25 @@ class Tarinai { this.visibleSpriteId = opts.visibleSpriteId || null; 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) : []; + if (this.refreshEffectiveSize) this.refreshEffectiveSize(); } } + +if (typeof window !== "undefined") window.Tarinai = Tarinai; +if (typeof globalThis !== "undefined") globalThis.Tarinai = Tarinai; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js new file mode 100644 index 0000000..a4bf180 --- /dev/null +++ b/js/tarinai_disease_nest.js @@ -0,0 +1,344 @@ +"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)"); + 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)"); + 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.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"; + 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] }); + 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"; + 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] }); + 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"; + 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] }); + 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] }); + 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] }); + 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] }); + 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() < (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..3cf436c --- /dev/null +++ b/js/tarinai_identity_social.js @@ -0,0 +1,395 @@ +"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; + }, + + 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), + }; + 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"; + } + })); +})(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..11cc936 --- /dev/null +++ b/js/tarinai_item_effects.js @@ -0,0 +1,393 @@ +"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 || "不明"; + } + + function sizeDurationScale(item = null) { + const size = item?.toolSize || "medium"; + if (size === "small") return 0.74; + if (size === "large") return 1.45; + return 1; + } + + 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); + }, + + itemSizeMultiplier() { + this.ensureItemEffectState(); + if (this.sizeItemMode === "giant_drug") return 3; + if (this.sizeItemMode === "dwarf_drug") return 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.bubble(mode === "protein" ? "ムキッ" : "へにゃ", 1.0, ITEM_EFFECT_COLORS[mode]); + this.spawnPowerItemEffect(mode, 1.5); + 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.bubble(mode === "giant_drug" ? "でかっ" : "ちまっ", 1.0, ITEM_EFFECT_COLORS[mode]); + this.spawnPowerItemEffect(mode, mode === "giant_drug" ? 1.9 : 1.2); + 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.bubble(`Hg x${this.mercuryLifeMultiplier.toFixed(2)}`, 1.2, ITEM_EFFECT_COLORS.mercury); + this.spawnPowerItemEffect("mercury", 0.95); + 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.bubble("すっきり", 1.2, ITEM_EFFECT_COLORS.zunda_juice); + this.spawnPowerItemEffect("zunda_juice", 1.4); + return true; + }, + + applyParamItemEffect(type = "", item = null, options = {}) { + this.ensureItemEffectState(); + const scale = sizeDurationScale(item); + let applied = []; + if (type === "laxative") { + this.setTimedItemEffect("laxative", Math.round(60 * scale)); + this.bubble("ぐるる", 1.2, ITEM_EFFECT_COLORS.laxative); + this.spawnPowerItemEffect("laxative", 0.75); + applied.push("laxative"); + } else if (type === "protein") { + this.setPowerItemMode("protein"); + applied.push("protein"); + } else if (type === "niteropu") { + this.setPowerItemMode("niteropu"); + applied.push("niteropu"); + } else if (type === "ammo") { + this.setTimedItemEffect("ammo", Math.round(18 * scale)); + this.bubble("ドン", 0.9, ITEM_EFFECT_COLORS.ammo); + this.spawnPowerItemEffect("ammo", 1.1); + applied.push("ammo"); + } else if (type === "mercury") { + this.setMercuryEffect(); + applied.push("mercury"); + } else if (type === "giant_drug") { + this.setSizeItemMode("giant_drug"); + applied.push("giant_drug"); + } else if (type === "dwarf_drug") { + this.setSizeItemMode("dwarf_drug"); + applied.push("dwarf_drug"); + } else if (type === "zunda_juice") { + this.clearAllItemEffects(); + applied.push("zunda_juice"); + } else 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 { + return false; + } + if (!options.silentLog && this.world?.log) { + const label = type === "mystery_drug" ? `怪しい薬(${applied.map(effectLabel).join(" + ")})` : effectLabel(type); + this.world.log(`${this.name}に${label}の効果が出た。`, "food", { participants: [this] }); + } + return true; + }, + + applyRandomizedBehaviorEffect(effect = "", item = null) { + const scale = sizeDurationScale(item); + if (effect === "love_mochi") { + this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 * scale + rand(4, 12)); + this.stress = clamp(this.stress - 8, 0, 130); + return true; + } + if (effect === "fight_mochi") { + this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 * scale + rand(10, 18)); + this.stress = clamp(this.stress + 10, 0, 130); + return true; + } + if (effect === "sleep_drug") return this.infectSleepDisease?.(item) || true; + if (effect === "laxative") { this.setTimedItemEffect("laxative", Math.round(60 * scale)); return true; } + if (effect === "ammo") { this.setTimedItemEffect("ammo", Math.round(18 * scale)); return true; } + if (effect === "protein") return this.setPowerItemMode("protein"); + if (effect === "niteropu") return this.setPowerItemMode("niteropu"); + if (effect === "mercury") return this.setMercuryEffect(); + if (effect === "giant_drug") return this.setSizeItemMode("giant_drug"); + if (effect === "dwarf_drug") return this.setSizeItemMode("dwarf_drug"); + if (effect === "disease_zunchi") return this.infectZunchiDisease?.(item, true) || true; + if (effect === "disease_sleep") return this.infectSleepDisease?.(item) || true; + if (effect === "disease_explosion") return this.infectExplosionDisease?.(item) || true; + if (effect === "disease_fight") return this.infectFightDisease?.(item) || true; + return false; + }, + + diseaseChance(base = 0) { + const b = Math.max(0, Number(base) || 0); + if (this.powerItemMode === "protein") return b * 0.5; + if (this.powerItemMode === "niteropu") return b * 2.0; + return b; + }, + + outgoingDamage(amount = 0) { + let value = Number(amount) || 0; + if (this.powerItemMode === "protein") value *= 1.75; + if (this.powerItemMode === "niteropu") value *= 0.5; + if (this.sizeItemMode === "giant_drug") value *= 2.0; + if (this.sizeItemMode === "dwarf_drug") value *= 0.25; + return value; + }, + + itemSpeedMultiplier() { + let value = this.hasItemEffect("ammo") ? 10 : 1; + if (this.sizeItemMode === "giant_drug") value *= 1.5; + if (this.sizeItemMode === "dwarf_drug") value *= 0.5; + return value; + }, + + applyFoodHungerRelief(amount = 0) { + if (this.hasItemEffect("laxative")) return 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 += 3; + this.forceLaxativePoop(); + } + } 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; + this.damage?.(1, this.sizeItemMode === "giant_drug" ? "巨大薬" : "矮小薬"); + if (this.world?.effects) this.spawnPowerItemEffect(this.sizeItemMode, 0.25); + } + } 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; + 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); + this.bubble("ぶりゅっ", 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}は下剤の効果でずんちを放出した。`, "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); + 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(); + const rows = []; + const add = (id, timer = 0) => { + const label = effectLabel(id); + const detail = EFFECT_REGISTRY?.display?.(id, timer, this) || ""; + rows.push({ id, label, detail }); + }; + if (this.powerItemMode === "protein") add("protein"); + if (this.powerItemMode === "niteropu") add("niteropu"); + if (this.lifeItemMode === "mercury") add("mercury"); + if (this.sizeItemMode === "giant_drug") add("giant_drug"); + if (this.sizeItemMode === "dwarf_drug") add("dwarf_drug"); + if ((this.itemEffectTimers.laxative || 0) > 0.04) add("laxative", this.itemEffectTimers.laxative); + if ((this.itemEffectTimers.ammo || 0) > 0.04) add("ammo", this.itemEffectTimers.ammo); + if ((this.loveMochiTimer || 0) > 0.1) add("love_mochi", this.loveMochiTimer); + if ((this.fightMochiTimer || 0) > 0.1) add("fight_mochi", this.fightMochiTimer); + return rows; + }, + })); + + 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..3a254a6 --- /dev/null +++ b/js/tarinai_needs_items.js @@ -0,0 +1,1003 @@ +"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) { + 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.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); + 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); + if (this.refreshEffectiveSize) this.refreshEffectiveSize(); + else 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); + if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(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, 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; + 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", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"], 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.state = "seek_food"; + this.target = specialMochi; + this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); + this.thought = `${toolLabel(specialMochi.type)}が少し気になっている`; + 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 > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).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", "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.applyFoodHungerRelief ? this.applyFoodHungerRelief(eating * (it.type === "sweet" ? 0.75 : 0.95)) : (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" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && 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.applyFoodHungerRelief ? this.applyFoodHungerRelief(eating * 0.82) : (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 if (it.type === "sleep_drug") { + 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"; + const sleepChance = this.diseaseChance ? this.diseaseChance(Math.min(0.98, 0.35 + eating * 0.075)) : Math.min(0.98, 0.35 + eating * 0.075); + if (!this.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) this.infectSleepDisease(it); + } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type)) { + if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it); + const color = it.type === "protein" ? "#f28d3d" : it.type === "niteropu" ? "#6255a8" : it.type === "ammo" ? "#d0942f" : it.type === "laxative" ? "#788c3b" : it.type === "mercury" ? "#6e95a8" : it.type === "giant_drug" ? "#e47b3a" : it.type === "dwarf_drug" ? "#7773c8" : it.type === "zunda_juice" ? "#61b94f" : "#41aa66"; + this.lastMealColor = color; + } + 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}は${toolLabel(it.type)}をしばらく味わった。`, "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.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 && 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.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.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.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.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.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); + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_render.js b/js/tarinai_render.js new file mode 100644 index 0000000..0dd426c --- /dev/null +++ b/js/tarinai_render.js @@ -0,0 +1,360 @@ +"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(); + } + } + 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; + } + })); +})(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..2d7a50c --- /dev/null +++ b/js/tarinai_social_move_life.js @@ -0,0 +1,366 @@ +"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 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(o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2, "\u55a7\u5629"); + o.damage(this.outgoingDamage ? this.outgoingDamage(1.6) : 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 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 === "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 - (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); + 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; + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/text_catalog.js b/js/text_catalog.js index 7e58f38..51aa4b2 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -5,47 +5,47 @@ 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", }, ], diff --git a/js/ui.js b/js/ui.js index 993a464..89d26bc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -7,6 +7,9 @@ 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"), resetBtn: document.getElementById("resetBtn"), creditsDialog: document.getElementById("creditsDialog"), @@ -18,6 +21,11 @@ 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"), @@ -41,6 +49,8 @@ const uiCache = { selectedSnapshot: "", selectedEmpty: false, selectedCollapsedCategories: new Set(), + logPushEnabled: false, + logPushKinds: new Set(["death", "accident", "birth"]), archiveUpdateEnabled: false, toolButtons: [], archiveVersion: "", @@ -85,1041 +95,18 @@ const uiCache = { grabLastWorldX: 0, grabLastWorldY: 0, grabLastSpatialAt: 0, + mobileInputMode: localStorage.getItem("tarinai_mobile_input_mode_v1") || "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..4893707 --- /dev/null +++ b/js/ui_bind.js @@ -0,0 +1,752 @@ +"use strict"; + +function bindUI() { + loadLogPushSettings(); + syncLogPushControls(); + syncAudioControls(); + applyToolTips(); + renderEcologyCards(); + 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 ? "展開" : "折り畳み"; + }); + } + 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 ? "ログのプッシュ通知をONにしました。" : "ログのプッシュ通知をOFFにしました。"); + }); + 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 ? "効果音をONにしました。" : "効果音をOFFにしました。"); + }); + 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 isTouchDevice = () => matchMedia?.("(pointer: coarse)")?.matches || navigator.maxTouchPoints > 0; + const setMobileInputMode = (mode = "auto") => { + if (!["auto", "camera", "tool", "family"].includes(mode)) mode = "auto"; + uiCache.mobileInputMode = mode; + try { localStorage.setItem("tarinai_mobile_input_mode_v1", mode); } catch (_) {} + 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 mobileInputMode = () => uiCache.mobileInputMode || "auto"; + 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(`スマホ操作: ${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 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; + }; + 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"; + 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.state = "idle"; + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + } else { + 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; + 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; + } + 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); + } 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"; + 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.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; + } + } + 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; + 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_family.js b/js/ui_family.js index b4a6d6a..8df68fb 100644 --- a/js/ui_family.js +++ b/js/ui_family.js @@ -1,1663 +1,3 @@ "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; +// Split into ui_family_data.js, ui_family_async.js, ui_family_layout.js, ui_family_paths.js, ui_family_render.js. diff --git a/js/ui_family_async.js b/js/ui_family_async.js new file mode 100644 index 0000000..7506f08 --- /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\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"; +} diff --git a/js/ui_family_data.js b/js/ui_family_data.js new file mode 100644 index 0000000..99f6e7a --- /dev/null +++ b/js/ui_family_data.js @@ -0,0 +1,152 @@ +"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; +} diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js new file mode 100644 index 0000000..784559c --- /dev/null +++ b/js/ui_family_layout.js @@ -0,0 +1,538 @@ +"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 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 }; +} diff --git a/js/ui_family_paths.js b/js/ui_family_paths.js new file mode 100644 index 0000000..e09bae7 --- /dev/null +++ b/js/ui_family_paths.js @@ -0,0 +1,361 @@ +"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, + 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..395a005 --- /dev/null +++ b/js/ui_family_render.js @@ -0,0 +1,427 @@ +"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 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_layout_dialogs.js b/js/ui_layout_dialogs.js new file mode 100644 index 0000000..d881b63 --- /dev/null +++ b/js/ui_layout_dialogs.js @@ -0,0 +1,216 @@ +"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; + 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}`; + 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..0d1e5c0 --- /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 ? "音設定 ON" : "音設定 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..09a0b0a --- /dev/null +++ b/js/ui_selected.js @@ -0,0 +1,276 @@ +"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 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 itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : []; + 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, + itemEffectSummary.map(e => `${e.id}:${e.detail}`).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 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 itemEffectHtml = itemEffectSummary.length + ? itemEffectSummary.map(e => `
    ${escapeHtml(e.label)}${escapeHtml(e.detail)}
    `).join("") + : `
    アイテム効果なし
    `; + 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("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", `

    \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; +} diff --git a/js/ui_tools.js b/js/ui_tools.js new file mode 100644 index 0000000..e200c47 --- /dev/null +++ b/js/ui_tools.js @@ -0,0 +1,46 @@ +"use strict"; + +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; + } +} diff --git a/js/version.js b/js/version.js new file mode 100644 index 0000000..118e484 --- /dev/null +++ b/js/version.js @@ -0,0 +1,23 @@ +"use strict"; + +(function () { + const APP_VERSION = "15.19.1"; + const APP_BUILD = "systems-split"; + 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/world.js b/js/world.js index ec04b10..c641d85 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; @@ -18,11 +17,13 @@ class World { 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 +45,40 @@ 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.foodSpoilagePenalty = 0; + this.foodSpoilageEvents = 0; this.countsTimer = 0; this.compactTimer = 0; this.drawSortTimer = 0; this.drawListDirty = true; + this.antUpdatePhase = 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..1024ab5 --- /dev/null +++ b/js/world_ants_system.js @@ -0,0 +1,121 @@ +"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); + 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.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); + audio.queenAnt?.(); + 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; + } + })); +})(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..f8d534d --- /dev/null +++ b/js/world_combat_effects.js @@ -0,0 +1,832 @@ +"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; + }, + + 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() < (t.diseaseChance ? t.diseaseChance(0.15) : 0.15)) 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 && q > 0.18 && Math.random() < (o.diseaseChance ? o.diseaseChance(0.15) : 0.15)) 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; + audio.place?.("zunchi"); + 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(132, (it.r || 88) * 1.74) : (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(360, 520) : (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 ? 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 || 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.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.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, + }); + 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] }); + } + } + } + })); +})(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..d69364c --- /dev/null +++ b/js/world_family_social.js @@ -0,0 +1,748 @@ +"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); + 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(b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7), "\u55a7\u5629"); + b.damage(a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : 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; + } + } + })); +})(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..9ffae69 --- /dev/null +++ b/js/world_placement_log.js @@ -0,0 +1,378 @@ +"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 : 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 }); + if (dropped) audio.place?.(item.type); + this.rebuildSpatial(true); + 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; + 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) { + 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("配置") || s.includes("設置") || 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 /決着|勝|負|覚え/.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 (/配置|置いた|設置/.test(text)) return audio.place?.(/爆竹/.test(text) ? "firecracker" : ""); + if (/げんこつ/.test(text)) return audio.genkotsuImpact?.(); + if (/爆竹|爆発/.test(text)) return audio.explode?.(); + if (/石/.test(text)) return audio.stoneImpact?.(); + if (/ボール/.test(text)) return audio.ballHit?.(); + return audio.damage?.(18); + } + if (kind === "event") { + if (/女王アリ/.test(text)) return audio.queenAnt?.(); + if (/アリ|巣/.test(text)) return audio.antNest?.(); + if (/病|発病/.test(text)) return audio.disease?.(); + if (/回復|治/.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..d812ad8 --- /dev/null +++ b/js/world_spatial_budget.js @@ -0,0 +1,184 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js"); + 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 = (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, 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..a237dbc --- /dev/null +++ b/js/world_update.js @@ -0,0 +1,325 @@ +"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; + }, + + 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.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"); + } + } + + 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; + this.markTerrainDirty?.("rain-water"); + } + + 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)}`); + } + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_view.js b/js/world_view.js new file mode 100644 index 0000000..820a296 --- /dev/null +++ b/js/world_view.js @@ -0,0 +1,318 @@ +"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 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.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 = "") { + 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; + } + })); +})(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/service-worker.js b/service-worker.js new file mode 100644 index 0000000..9b0823d --- /dev/null +++ b/service-worker.js @@ -0,0 +1,57 @@ +"use strict"; + +const APP_VERSION = "15.19.1"; +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", "effect_registry", "sound_pack", "data", "math", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_selected", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family", "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; + }))); + } +});