From 434f07cc4ee08d5c810fa350466225e8df308306 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 25 Jun 2026 14:51:58 +0900 Subject: [PATCH] not bad --- LAG_OPTIMIZATION_NOTES.md | 54 - PATCH_NOTES.md | 52 - README.md | 55 - app_manifest.json | 39 +- assets/objects/ant_corpse.webp | Bin 5366 -> 0 bytes assets/objects/ant_worker_mid.webp | Bin 3248 -> 0 bytes assets/objects/ant_worker_small.webp | Bin 2788 -> 0 bytes assets/objects/ant_worker_small_b.webp | Bin 2790 -> 0 bytes assets/ui/tool_water.webp | Bin 278 -> 0 bytes assets/ui/tool_water_hose.webp | Bin 1220 -> 0 bytes css/base.css | 55 +- css/components.css | 35 +- css/mobile.css | 15 - docs/ARCHITECTURE.md | 77 - docs/COMPACT_SAVE_V2_REPORT.md | 42 - docs/CONTENT_GUIDE.md | 64 - docs/MAINTENANCE.md | 45 - docs/PLAYER_VISIBLE_DIFF_REPORT.md | 52 - docs/REGRESSION_CHECKLIST.md | 45 - docs/REMAINING_WORK_SPLIT.md | 46 - docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md | 63 - docs/SOCIAL_BALANCE_FIX_REPORT.md | 28 - docs/prompt.txt | 860 --------- index.html | 198 +- js/audio.js | 22 - js/data.js | 147 +- js/debug_tools.js | 12 +- js/effect_registry.js | 280 --- js/food_registry.js | 57 - js/freeze_panel.js | 162 ++ js/freeze_snapshot_adapter.js | 97 + js/freeze_store.js | 45 + js/freeze_system.js | 318 +--- js/ground_types.js | 76 + js/item_lifecycle_runtime.js | 626 +++++++ js/item_registry.js | 682 +++++++ js/item_render_runtime.js | 533 ++++++ js/item_type_initializers.js | 115 ++ js/item_update_scheduler.js | 200 +++ js/item_visual_registry.js | 144 -- js/items.js | 1399 +-------------- js/main.js | 8 +- js/perf_profiler.js | 115 ++ js/performance_manager.js | 55 - js/relation_event_system.js | 90 - js/render.js | 304 +++- js/restore_coordinator.js | 35 + js/save_codec.js | 139 ++ js/save_storage.js | 47 + js/save_system.js | 162 +- js/sim_core.js | 190 +- js/snapshot_system.js | 111 +- js/structure_lifecycle.js | 90 + js/structures.js | 20 +- js/tarinai.js | 12 +- js/tarinai_action_definitions.js | 558 ++++++ js/tarinai_action_spec.js | 8 - js/tarinai_action_state.js | 100 +- js/tarinai_behavior_state.js | 465 ++--- js/tarinai_behavior_text.js | 168 ++ js/tarinai_building_behavior.js | 116 ++ js/tarinai_consumable_behavior.js | 150 ++ js/tarinai_disease_nest.js | 14 - js/tarinai_forced_behavior.js | 466 +++++ js/tarinai_identity_social.js | 36 +- js/tarinai_item_effects.js | 6 +- js/tarinai_item_targeting.js | 344 ++++ js/tarinai_needs_core.js | 298 +++ js/tarinai_needs_items.js | 2290 ++---------------------- js/tarinai_render.js | 19 +- js/tarinai_social_action_runtime.js | 121 ++ js/tarinai_social_move_life.js | 14 +- js/text_catalog.js | 42 +- js/ui.js | 5 + js/ui_bind.js | 621 +------ js/ui_charts.js | 16 +- js/ui_family_data.js | 49 - js/ui_family_paths.js | 31 - js/ui_family_render.js | 5 - js/ui_ground.js | 57 + js/ui_input_mouse.js | 81 + js/ui_input_shared.js | 378 ++++ js/ui_input_touch.js | 133 ++ js/ui_layout_dialogs.js | 38 +- js/ui_selected.js | 51 +- js/ui_tools.js | 3 +- js/ui_tooltips.js | 70 + js/version.js | 4 +- js/world.js | 3 +- js/world_ants_system.js | 3 +- js/world_combat_effects.js | 65 +- js/world_environment.js | 19 - js/world_event_effects.js | 75 + js/world_family_social.js | 111 +- js/world_placement_log.js | 70 +- js/world_spatial_budget.js | 184 +- js/world_tool_actions.js | 46 + js/world_update.js | 301 +--- js/world_update_phases.js | 429 +++++ js/world_view.js | 84 +- scripts/generate_item_icons.py | 4 +- scripts/regression_check.py | 284 ++- service-worker.js | 4 +- 103 files changed, 8396 insertions(+), 8161 deletions(-) delete mode 100644 LAG_OPTIMIZATION_NOTES.md delete mode 100644 PATCH_NOTES.md delete mode 100644 README.md delete mode 100644 assets/objects/ant_corpse.webp delete mode 100644 assets/objects/ant_worker_mid.webp delete mode 100644 assets/objects/ant_worker_small.webp delete mode 100644 assets/objects/ant_worker_small_b.webp delete mode 100644 assets/ui/tool_water.webp delete mode 100644 assets/ui/tool_water_hose.webp delete mode 100644 docs/ARCHITECTURE.md delete mode 100644 docs/COMPACT_SAVE_V2_REPORT.md delete mode 100644 docs/CONTENT_GUIDE.md delete mode 100644 docs/MAINTENANCE.md delete mode 100644 docs/PLAYER_VISIBLE_DIFF_REPORT.md delete mode 100644 docs/REGRESSION_CHECKLIST.md delete mode 100644 docs/REMAINING_WORK_SPLIT.md delete mode 100644 docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md delete mode 100644 docs/SOCIAL_BALANCE_FIX_REPORT.md delete mode 100644 docs/prompt.txt delete mode 100644 js/effect_registry.js delete mode 100644 js/food_registry.js create mode 100644 js/freeze_panel.js create mode 100644 js/freeze_snapshot_adapter.js create mode 100644 js/freeze_store.js create mode 100644 js/ground_types.js create mode 100644 js/item_lifecycle_runtime.js create mode 100644 js/item_registry.js create mode 100644 js/item_render_runtime.js create mode 100644 js/item_type_initializers.js create mode 100644 js/item_update_scheduler.js delete mode 100644 js/item_visual_registry.js create mode 100644 js/perf_profiler.js delete mode 100644 js/performance_manager.js delete mode 100644 js/relation_event_system.js create mode 100644 js/restore_coordinator.js create mode 100644 js/save_codec.js create mode 100644 js/save_storage.js create mode 100644 js/structure_lifecycle.js create mode 100644 js/tarinai_action_definitions.js create mode 100644 js/tarinai_behavior_text.js create mode 100644 js/tarinai_building_behavior.js create mode 100644 js/tarinai_consumable_behavior.js create mode 100644 js/tarinai_forced_behavior.js create mode 100644 js/tarinai_item_targeting.js create mode 100644 js/tarinai_needs_core.js create mode 100644 js/tarinai_social_action_runtime.js create mode 100644 js/ui_ground.js create mode 100644 js/ui_input_mouse.js create mode 100644 js/ui_input_shared.js create mode 100644 js/ui_input_touch.js create mode 100644 js/ui_tooltips.js create mode 100644 js/world_event_effects.js create mode 100644 js/world_tool_actions.js create mode 100644 js/world_update_phases.js diff --git a/LAG_OPTIMIZATION_NOTES.md b/LAG_OPTIMIZATION_NOTES.md deleted file mode 100644 index b3ade34..0000000 --- a/LAG_OPTIMIZATION_NOTES.md +++ /dev/null @@ -1,54 +0,0 @@ -# Lag optimization pass 16.00.01 - -## Intent -Observation-game pacing allows delayed non-critical scans. This pass removes dynamic load-quality checks and moves broad scans to fixed, low-frequency schedules. - -## Changes - -- Removed dynamic load-based quality calculation from hot paths. - - `performance_manager.js` is now a compatibility shim only. - - No workload count over tarinai / ants / items / effects is performed by performance quality. - - Main loop, renderer, DPR resize, rain density, tarinai render decorations, social scan limits, and item scan limits no longer call load quality. - -- Tarinai decision cadence reduced. - - Normal AI/action resolution: about 1.0-1.35s per tarinai, staggered. - - Urgent AI state: about 0.35s. - - Need recalculation: about 1.0-1.35s normally. - - Environmental comfort scan: about 3.0-4.4s per tarinai, staggered. - -- Non-critical item updates are scheduled. - - Realtime: balls, pins, firecrackers, falling genkotsu, moving items. - - Grass: about 3.0-5.2s staggered. - - Zunchi lifecycle: about 2.4s unless moving. - - Duplicator: about 2.0s. - - Water: about 1.8s. - - Trace/splat: about 1.2s. - - Serving-food passive decay: about 5.0s. - - Fully static items are not updated every frame. - -- Spatial rebuilds are capped. - - `ensureSpatial()` now rebuilds at most once per frame. - - Movement or item changes after that use the previous grid until the next frame. - -- Other broad scans are delayed or cached. - - Bed conflict check: 3.2s. - - High-speed tarinai collision scan: 1.8s. - - Compaction: 2.5s. - - Item/effect count rebuild: 2.0s or structural change. - - Out-of-bounds sanitation: 5.0s. - - Environment score cache: 3.0s. - - Terrain periodic dirtying: 3.0s. - - Stats UI interval: 2.5s. - - Ant nest management/spawn sync: 3.0s. - -- ID/spatial replacements. - - Pushpin lodged owner lookup uses `liveTarinaiById()` instead of scanning tarinai. - - Loose pushpin hit testing uses `nearbyTarinai()` instead of full tarinai scan. - - Ant home nest lookup uses `itemById()`. - - Ant target choice uses `nearbyTarinai()`. - - Several tarinai target lookups use `liveTarinaiById()`. - -## Validation - -- `python3 scripts/regression_check.py` passed. -- `node --check` passed for all JavaScript files. diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md deleted file mode 100644 index 444b67a..0000000 --- a/PATCH_NOTES.md +++ /dev/null @@ -1,52 +0,0 @@ -# 15.52.00 - 草上限復活・日光浴描画修正 - -- 草だけ上限を復活。上限到達後は、手動配置と自然繁殖の新規追加を止める。既存草は上限処理では削除しない。 -- 日光浴開始位置をアンカー化し、発生後に中央へ寄らないよう固定。 -- 日光浴スプライト描画を通常描画経路に一本化し、未ロード時は透明化せず読み込み済み候補または通常表情へフォールバック。 -- 到達不能だった日光浴専用直接描画メソッドと、未使用のアイテム上限メソッドを削除。 - -# 15.24.53 - -- 排泄直後のずんちを通常個体が即座に食べないよう、ずんち専用の摂食保護時間を追加。 -- 通常個体のずんち摂食条件をさらに厳格化し、他の食料が近い場合はずんちを食べないよう再調整。 -- ずんちの風化・乾燥・分解・肥土化の進行速度と量減少速度を低下。 -- 日光浴スプライトの切替を `updateSunbath` に一本化し、`spriteId()` が毎フレーム再抽選しないよう修正。 -- 日光浴中はキャッシュ済み透明キャンバスではなく、読み込み済みの `sunbath` 画像を直接描画するよう修正。 - -# 15.24.52 - -- 日光浴中の専用直接描画を停止し、通常のたりない描画経路で `sunbath` スプライトを常時表示するよう修正。 -- 日光浴中は通常描画直前の `spriteId()` で0.5秒ごとに `sunbath` 系スプライトをランダム切替するよう統一。 -- 切替フレーム以外で日光浴スプライトが消える問題を抑止。 -- 持ち主不明のかんたんベッドが放置で徐々に劣化・消滅する処理を追加。 - -# 15.24.52 - -- 日光浴中の描画を通常表情描画から分離し、毎フレーム必ず sunbath 系スプライトを直接描画するよう修正。 -- 0.5秒ごとのランダム切替は維持しつつ、切替フレーム以外でスプライトが消える問題を抑止。 -- sunbath画像が未ロードの瞬間も、読み込み済み候補またはフォールバックで描画を継続。 - -# 15.24.50 - -- 日光浴中のスプライトを、通常表情ロックとは別枠で固定。 -- sunbath 系スプライトを初期ロード対象へ追加。 - - -## 15.40.00 - アイテム上限廃止 -- アイテム総数・種類別上限による自動削除を廃止。 -- ずんち生成時に古いずんちを押し出し削除する処理を削除。 -- 草・柵の配置数上限と、草の繁殖数上限を削除。 -- compactItems() はプレイヤー削除・腐敗・枯死・摂食などで dead になったアイテムだけを片付ける。 - - -## 15.56.00 -- 日光浴中は通常描画経路へフォールバックしないようにし、描画座標を本体座標に固定。 -- 日光浴終了後に sunbath sprite ID が通常描画へ残らないよう、visibleSpriteId と spriteLockUntil をリセット。 -- 日光浴スプライト切替時は未ロード画像へ切り替えず、準備済みの直前スプライトまたは通常スプライトを同座標で描画して透明フレームを避ける。 - -## 16.00.00 - Sunbath voice finish -- Added `assets/sounds/voice_007_sunbath_pokapoka.wav` from the provided sunbath voice asset. -- Registered `voice_sunbath` in `sound_pack.js`. -- Routed bubbles containing `ぽか` through `audio.bubble(text)` to play `voice_sunbath` at the same moment the bubble is spawned. -- Kept the existing sunbath rendering stabilization untouched. - diff --git a/README.md b/README.md deleted file mode 100644 index 6ba173d..0000000 --- a/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Tarinai Colony Observation Game - -Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/audio.js b/js/audio.js index 1e2ac20..666f224 100644 --- a/js/audio.js +++ b/js/audio.js @@ -107,27 +107,11 @@ const audio = { 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)); @@ -137,11 +121,6 @@ const audio = { 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(); @@ -324,7 +303,6 @@ const audio = { 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); }, diff --git a/js/data.js b/js/data.js index fb02751..27990c9 100644 --- a/js/data.js +++ b/js/data.js @@ -46,150 +46,6 @@ const DECOR_ASSETS = [ { id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" }, ]; -const TOOL_DEFINITIONS = Object.freeze({ - observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.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\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\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.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, - sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, - love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, - fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, - water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, - sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, - - laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" }, - protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" }, - niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" }, - ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" }, - mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" }, - mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" }, - giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" }, - dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" }, - zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" }, - grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, - stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, - genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, - bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, - nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, - ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, - ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, - duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" }, - firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, - pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, - oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" }, - fence_v: { id: "fence_v", itemType: "fence_v", label: "\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: 4, amount: 24, simulationOnly: true }, -}); - - -const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]), - food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), - hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), - pin: new Set(["pushpin", "oshibyo"]), - kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), - sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]), -}); - -function itemHasTrait(type = "", trait = "") { - return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); -} - -function itemTraitsFor(type = "") { - const out = []; - for (const [trait, set] of Object.entries(ITEM_TRAITS)) if (set.has(String(type || ""))) out.push(trait); - return out; -} - -function isPinType(type = "") { return itemHasTrait(type, "pin"); } -function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } -function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } -function lodgedPinFor(tarinai = null, worldRef = null) { - if (!tarinai?.stuckPushpinId) return null; - const items = worldRef?.items || tarinai.world?.items || []; - return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; -} -function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } -function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } - -const PIN_BEHAVIORS = Object.freeze({ - pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, - oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, -}); - -function pinBehaviorFor(type = "") { - return PIN_BEHAVIORS[String(type || "")] || null; -} - - -const TOOL_CATEGORIES = Object.freeze([ - { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, - { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] }, - { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] }, - { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] }, -]); - -function toolCategories() { - return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0)); -} - -function toolDefinition(id = "") { - return TOOL_DEFINITIONS[id] || null; -} - -function toolLabel(id = "") { - const def = toolDefinition(id); - return def?.label || id || ""; -} - -function toolItemType(id = "") { - const def = toolDefinition(id); - return def?.placeable ? (def.itemType || def.id) : null; -} - -function itemDefinition(type = "") { - return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; -} - -function itemRadiusFor(type = "", fallback = 12) { - return itemDefinition(type)?.radius ?? fallback; -} - -function itemAmountFor(type = "", fallback = 80) { - return itemDefinition(type)?.amount ?? fallback; -} - -const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); -const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }); -const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); -function normalizedToolSizeFor(worldRef = null, type = "") { - const def = itemDefinition(type); - if (!def?.scalable) return "medium"; - const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; - return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; -} -function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } -function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } - -function scalableToolIds() { - return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); -} - -function toolTipsFromDefinitions() { - return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); -} - const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, @@ -237,7 +93,8 @@ const CONFIG = { maxPopulation: Number.MAX_SAFE_INTEGER, maxFenceCollisionChecks: 18, effectLimit: 78, - grassLimit: 360, + grassLimit: 99, + grassLimitsByField: { cage: 19, garden: 99, park: 299 }, worldPadding: 30, hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, diff --git a/js/debug_tools.js b/js/debug_tools.js index cfbb02e..fd39c72 100644 --- a/js/debug_tools.js +++ b/js/debug_tools.js @@ -19,16 +19,16 @@ window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; }); window.setInterval(() => { const w = window.world; - const perf = window.TarinaiPerformance?.debugSnapshot(w) || {}; const input = window.TarinaiInputMode?.snapshot?.() || {}; const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {}; const behaviorCounts = diag.behavior?.counts || {}; const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", "); - const quality = {}; + const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {}; + const perfTop = (perf.entries || []).slice(0, 5).map(e => `${e.label}:${e.avg}ms`).join(", "); panel.textContent = [ `tarinai v${window.TARINAI_VERSION || "?"}`, - `fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`, - `live ${diag.performance?.liveTarinai ?? (w?.tarinai || []).length} load-check removed dpr ${diag.performance?.dpr || uiCache?.canvasDpr || "?"}`, + `fps ${window.__tarinaiFps ?? "?"}`, + `live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} dpr ${diag.runtime?.dpr || uiCache?.canvasDpr || "?"}`, `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}`, @@ -36,12 +36,14 @@ `drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`, `item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`, `work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`, + `scheduler heap:${diag.scheduler?.heap ?? 0} realtime:${diag.scheduler?.realtime ?? 0} ran:${diag.scheduler?.lastRan ?? 0} rebuilds:${diag.scheduler?.rebuilds ?? 0}`, + `perf ${perf.tier || "?"} dprScale:${perf.dprScale || 1} ${perfTop || "-"}`, `behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`, `dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`, `sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`, `input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`, `audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`, - `effects ${window.TarinaiEffectRegistry?.debugSnapshot?.().length || 0} registered`, + `effects ${window.TarinaiItemRegistry?.effect?.debugSnapshot?.().length || 0} registered`, `events ${lastEvents.join(", ")}`, ].join("\n"); }, 500); diff --git a/js/effect_registry.js b/js/effect_registry.js deleted file mode 100644 index 115ffb9..0000000 --- a/js/effect_registry.js +++ /dev/null @@ -1,280 +0,0 @@ -"use strict"; - -(function (global) { - const rawDefinitions = { - laxative: { - label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, - tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`, - }, - ammo: { - label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, - tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`, - }, - protein: { - label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", - tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d", - }, - niteropu: { - label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", - tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d", - }, - mercury: { - label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", - tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`, - }, - giant_drug: { - label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", - tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", - }, - dwarf_drug: { - label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", - tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", - }, - love_mochi: { - label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", - tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`, - }, - fight_mochi: { - label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", - tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`, - }, - sleep_drug: { - label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], - }, - mystery_drug: { - label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], - }, - zunda_juice: { - label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], - }, - disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, - disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, - disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, - disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, - }; - - Object.assign(rawDefinitions.laxative, { - modifiers: { hungerReliefMultiplier: 0 }, - tickInterval: 3, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.ammo, { - modifiers: { speedMultiplier: 10 }, - visualEffectId: "fall", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.protein, { - modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 }, - visualEffectId: "fall_up_red", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.niteropu, { - modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 }, - visualEffectId: "fall_down_purple", - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.mercury, { - modifiers: { lifeMultiplierPerUse: 1.3 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.giant_drug, { - modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - Object.assign(rawDefinitions.dwarf_drug, { - modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 }, - bubblePolicy: "none", - mysteryEligible: true, - zundaJuiceRemovable: true, - }); - for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) { - Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" }); - } - Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false }); - - Object.assign(rawDefinitions.love_mochi, { - onApply(tarinai, context = {}) { - const scale = context.scale ?? 1; - tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); - if (context.source === "eating") { - if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); - } - return true; - }, - }); - Object.assign(rawDefinitions.fight_mochi, { - onApply(tarinai, context = {}) { - const scale = context.scale ?? 1; - tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); - if (context.source === "eating") { - tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); - if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); - } - return true; - }, - }); - Object.assign(rawDefinitions.sleep_drug, { - onApply(tarinai, context = {}) { - if (context.source === "eating") { - const nutrition = Number(context.nutrition) || 0; - if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); - tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); - tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); - tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); - const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); - const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; - if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); - if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; - return true; - } - return tarinai.infectSleepDisease?.(context.item || null) || true; - }, - }); - Object.assign(rawDefinitions.laxative, { - onApply(tarinai, context = {}) { - return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1))); - }, - onTick(tarinai) { - tarinai.forceLaxativePoop?.(); - return true; - }, - }); - Object.assign(rawDefinitions.ammo, { - onApply(tarinai, context = {}) { - return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1))); - }, - }); - Object.assign(rawDefinitions.protein, { - onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); }, - }); - Object.assign(rawDefinitions.niteropu, { - onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); }, - }); - Object.assign(rawDefinitions.mercury, { - onApply(tarinai) { return tarinai.setMercuryEffect?.(); }, - }); - Object.assign(rawDefinitions.giant_drug, { - onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); }, - onTick(tarinai) { - tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); - if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25); - return true; - }, - }); - Object.assign(rawDefinitions.dwarf_drug, { - onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); }, - onTick(tarinai) { - tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); - if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25); - return true; - }, - }); - Object.assign(rawDefinitions.zunda_juice, { - onApply(tarinai) { return tarinai.clearAllItemEffects?.(); }, - }); - Object.assign(rawDefinitions.disease_zunchi, { - onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; }, - }); - Object.assign(rawDefinitions.disease_sleep, { - onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; }, - }); - Object.assign(rawDefinitions.disease_explosion, { - onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; }, - }); - Object.assign(rawDefinitions.disease_fight, { - onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, - }); - - class EffectRegistry extends global.TarinaiRegistryBase { - label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } - color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } - display(id = "", timer = 0, tarinai = null) { - const def = this.get(id); - if (!def) return ""; - return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || ""); - } - apply(id = "", tarinai = null, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onApply !== "function") return false; - return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this })); - } - tick(id = "", tarinai = null, dt = 0, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onTick !== "function") return false; - return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this })); - } - remove(id = "", tarinai = null, context = {}) { - const def = this.get(id); - if (!def || !tarinai || typeof def.onRemove !== "function") return false; - return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this })); - } - modifier(id = "", key = "", fallback = 1) { - const value = this.get(id)?.modifiers?.[key]; - return Number.isFinite(value) ? value : fallback; - } - timerValue(tarinai = null, id = "") { - const def = this.get(id); - if (!def || !tarinai) return 0; - if (def.timerProp) return Number(tarinai[def.timerProp] || 0); - if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0); - return 0; - } - isActive(tarinai = null, id = "") { - if (!tarinai) return false; - const def = this.get(id); - if (!def) return false; - if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id; - if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id; - if (id === "mercury") return tarinai.lifeItemMode === "mercury"; - return this.timerValue(tarinai, id) > 0.04; - } - activeSummary(tarinai = null) { - const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"]; - return order - .filter(id => this.isActive(tarinai, id)) - .map(id => ({ - id, - label: this.label(id), - detail: this.display(id, this.timerValue(tarinai, id), tarinai), - })); - } - labels() { return super.labels(); } - colors() { return super.colors(); } - randomPool() { - const itemEffects = [...this.defs.entries()] - .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) - .map(([id]) => id) - .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); - const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || []; - return [...new Set([...itemEffects, ...diseases])]; - } - permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); } - timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); } - debugSnapshot() { - return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" })); - } - } - - const registry = new EffectRegistry(rawDefinitions); - global.TarinaiEffectRegistry = registry; - global.TARINAI_EFFECT_DEFINITIONS = Object.freeze(rawDefinitions); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/food_registry.js b/js/food_registry.js deleted file mode 100644 index 72b468a..0000000 --- a/js/food_registry.js +++ /dev/null @@ -1,57 +0,0 @@ -"use strict"; - -(function (global) { - const rawDefinitions = { - food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, - sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, - love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, - fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, - sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, - laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, - protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, - niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, - ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, - mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, - mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, - giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, - dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, - zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, - grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } - }; - - class FoodRegistry extends global.TarinaiRegistryBase { - servingTypes() { - return [...this.defs.entries()] - .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) - .map(([id]) => id); - } - typesByInterest(...interests) { - const wanted = new Set(interests.flat().filter(Boolean)); - return [...this.defs.entries()] - .filter(([, def]) => wanted.has(def.interest)) - .map(([id]) => id); - } - paramEffectTypes() { - return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); - } - nutrition(type = "", fallback = 0) { - const value = this.get(type)?.nutrition; - return Number.isFinite(value) ? value : fallback; - } - hungerRelief(type = "", fallback = 0) { - const value = this.get(type)?.hungerRelief; - return Number.isFinite(value) ? value : fallback; - } - effectId(type = "") { return this.get(type)?.effectId || ""; } - passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } - hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } - debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } - } - - const registry = new FoodRegistry(rawDefinitions); - global.TarinaiFoodRegistry = registry; - global.TARINAI_FOOD_DEFINITIONS = Object.freeze(rawDefinitions); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/freeze_panel.js b/js/freeze_panel.js new file mode 100644 index 0000000..02563d2 --- /dev/null +++ b/js/freeze_panel.js @@ -0,0 +1,162 @@ +"use strict"; + +(function (global) { + const htmlEscape = global.TarinaiUIHelpers.htmlEscape; + + function spritePathFor(type = "smile") { + const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || [])); + const meta = sprites.find(s => s.id === type) || sprites[0]; + return meta?.path || "assets/sprites/tarinai_01_smile.webp"; + } + + function ensureStyles() { + if (document.getElementById("freezeSystemStyles")) return; + const style = document.createElement("style"); + style.id = "freezeSystemStyles"; + style.textContent = ` + .frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; } + .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; } + .frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; } + .frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; } + .frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; } + .frozen-panel-count { font-size:12px; opacity:.72; } + .frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; } + .frozen-panel.collapsed .frozen-list { display:none; } + .frozen-panel.collapsed { padding-bottom:7px; } + .frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } + .frozen-card:active { cursor:grabbing; } + .frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); } + .frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } + .frozen-card-main { min-width:0; flex:1; } + .frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; } + canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; } + `; + document.head.appendChild(style); + } + + function ensurePanel() { + ensureStyles(); + let panel = document.getElementById("frozenPanel"); + if (panel) return panel; + panel = document.createElement("section"); + panel.id = "frozenPanel"; + panel.className = "frozen-panel"; + panel.innerHTML = ` +
+
+ `; + const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed"); + const collapsedByDefault = storedCollapse !== "0"; + if (collapsedByDefault) { + panel.classList.add("collapsed"); + panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false"); + } else { + panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true"); + } + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); + const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette"); + if (operateBody) operateBody.appendChild(panel); + else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); + else document.body.appendChild(panel); + return panel; + } + + function render(entries = []) { + const panel = ensurePanel(); + const listEl = panel.querySelector("#frozenList"); + const countEl = panel.querySelector("#frozenPanelCount"); + const list = Array.isArray(entries) ? entries : []; + if (countEl) countEl.textContent = `${list.length}`; + if (!listEl) return; + if (!list.length) { + listEl.innerHTML = `
\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002
`; + return; + } + listEl.innerHTML = list.map(entry => { + const data = entry.data || entry; + const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : ""; + const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || ""); + return ``; + }).join(""); + } + + function markPending(id = "") { + const panel = ensurePanel(); + for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c.dataset.frozenId === id); + } + + function clearPending() { + document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw")); + } + + function bind({ onSelect, onDrop } = {}) { + const panel = ensurePanel(); + if (panel.dataset.freezeBound !== "1") { + panel.dataset.freezeBound = "1"; + panel.addEventListener("dragstart", (e) => { + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + const id = card.dataset.frozenId || ""; + e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`); + e.dataTransfer?.setData("application/x-tarinai-frozen", id); + e.dataTransfer.effectAllowed = "move"; + global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready"); + }); + panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready")); + panel.addEventListener("click", (e) => { + const toggle = e.target.closest("#frozenPanelToggle"); + if (toggle) { + const collapsed = panel.classList.toggle("collapsed"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {} + global.audio?.uiFold?.(); + return; + } + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + const id = card.dataset.frozenId || ""; + markPending(id); + onSelect?.(id); + }); + } + const canvas = global.canvas || document.getElementById("gameCanvas"); + if (canvas && canvas.dataset.freezeDropBound !== "1") { + canvas.dataset.freezeDropBound = "1"; + canvas.addEventListener("dragover", (e) => { + const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain"); + if (!text) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + canvas.classList.add("freeze-drop-ready"); + }); + canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready")); + canvas.addEventListener("drop", (e) => { + const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, ""); + if (!id) return; + e.preventDefault(); + canvas.classList.remove("freeze-drop-ready"); + const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const worldRef = global.world; + return worldRef?.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true }; + })(); + onDrop?.(id, p.x, p.y); + }); + } + } + + global.TarinaiFreezePanel = { + ensurePanel, + render, + bind, + markPending, + clearPending, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/freeze_snapshot_adapter.js b/js/freeze_snapshot_adapter.js new file mode 100644 index 0000000..d5a463c --- /dev/null +++ b/js/freeze_snapshot_adapter.js @@ -0,0 +1,97 @@ +"use strict"; + +(function (global) { + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_snapshot_adapter.js"); + const clonePlain = Snapshot.clonePlain; + + function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { + const data = Snapshot.copyOwnData(entity, skip); + const targetRef = Snapshot.refFor(worldRef, entity?.target); + const panicTargetRef = Snapshot.refFor(worldRef, entity?.panicTarget); + const targetRefObject = Snapshot.refFor(worldRef, entity?.targetRef); + if (targetRef) data.__targetRef = targetRef; + if (panicTargetRef) data.__panicTargetRef = panicTargetRef; + if (targetRefObject) data.__targetRefObject = targetRefObject; + return data; + } + + function restoreTarinaiData(worldRef, data, opts = {}) { + const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); + if (!TarinaiClass) throw new Error("Tarinai is not available"); + const t = new TarinaiClass(worldRef, data || {}); + Snapshot.applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"])); + t.world = worldRef; + if (opts.clearTransient !== false) { + t.target = null; + t.panicTarget = null; + t.sleeping = false; + t.insideNestBoxId = ""; + } + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + return t; + } + + function makeEntry(t, worldRef = global.world) { + const data = snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"])); + data.sleeping = false; + data.insideNestBoxId = ""; + data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle"); + data.targetKey = ""; + const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`); + return { + freezeId: id, + frozenAt: Date.now(), + worldTime: worldRef?.time || 0, + name: t.name || "\u305f\u308a\u306a\u3044", + familyKey: t.familyKey || "", + liveId: t.id || "", + liveToken: t.liveToken || 0, + type: t.type || "smile", + data, + }; + } + + function updateLiveIdAfterThaw(worldRef, t) { + if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map(); + const id = t.id || ""; + const conflict = id && worldRef.liveTarinai.has(id); + if (!id || conflict) { + worldRef.assignTarinaiLiveId?.(t); + return; + } + worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t }); + const m = /^t(\d+)$/.exec(id); + if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1); + worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0); + } + + function thawEntry(entry, x, y, worldRef = global.world) { + const data = clonePlain(entry?.data || entry) || {}; + const t = restoreTarinaiData(worldRef, data, { clearTransient: true }); + t.dead = false; + t.deathReason = ""; + t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); + t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); + t.vx = 0; + t.vy = 0; + t.target = null; + t.panicTarget = null; + t.insideNestBoxId = ""; + t.sleeping = false; + if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b"); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); + t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b"; + t.frozenAt = 0; + updateLiveIdAfterThaw(worldRef, t); + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + return t; + } + + global.TarinaiFreezeSnapshotAdapter = { + snapshotEntity, + restoreTarinaiData, + makeEntry, + thawEntry, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/freeze_store.js b/js/freeze_store.js new file mode 100644 index 0000000..349e3d9 --- /dev/null +++ b/js/freeze_store.js @@ -0,0 +1,45 @@ +"use strict"; + +(function (global) { + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_store.js"); + const STORAGE_KEY = "tarinai_frozen_storage_v1"; + const clonePlain = Snapshot.clonePlain; + + function frozenList(worldRef = global.world) { + if (!worldRef) return []; + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + return worldRef.frozenTarinai; + } + + function save(worldRef = global.world) { + try { + global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef))); + } catch (_) {} + } + + function load(worldRef = global.world) { + if (!worldRef) return []; + try { + const raw = global.localStorage?.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || []; + } catch (_) { + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + } + return frozenList(worldRef); + } + + function ensure(worldRef = global.world) { + if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = []; + return frozenList(worldRef); + } + + global.TarinaiFreezeStore = { + STORAGE_KEY, + frozenList, + load, + save, + ensure, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/freeze_system.js b/js/freeze_system.js index 7c6c1d4..9e31848 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -1,333 +1,103 @@ "use strict"; (function (global) { - const STORAGE_KEY = "tarinai_frozen_storage_v1"; - const MAX_PENDING_LOSSES = 16; - const Snapshot = global.TarinaiSnapshot; - if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js"); - const clonePlain = Snapshot.clonePlain; + const Store = global.TarinaiFreezeStore; + const Adapter = global.TarinaiFreezeSnapshotAdapter; + const Panel = global.TarinaiFreezePanel; + if (!Store || !Adapter || !Panel) throw new Error("freeze_system.js dependencies are not available"); - const htmlEscape = global.TarinaiUIHelpers.htmlEscape; + let pendingThawId = ""; function frozenList(worldRef = global.world) { - if (!worldRef) return []; - if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; - return worldRef.frozenTarinai; - } - - function saveFrozenStore(worldRef = global.world) { - try { - global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef))); - } catch (_) {} - } - - function loadFrozenStore(worldRef = global.world) { - if (!worldRef) return []; - try { - const raw = global.localStorage?.getItem(STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : []; - if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || []; - } catch (_) { - if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; - } - return frozenList(worldRef); - } - - function spritePathFor(type = "smile") { - const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || [])); - const meta = sprites.find(s => s.id === type) || sprites[0]; - return meta?.path || "assets/sprites/tarinai_01_smile.webp"; - } - - function ensureStyles() { - if (document.getElementById("freezeSystemStyles")) return; - const style = document.createElement("style"); - style.id = "freezeSystemStyles"; - style.textContent = ` - .frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; } - .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; } - .frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; } - .frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; } - .frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; } - .frozen-panel-count { font-size:12px; opacity:.72; } - .frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; } - .frozen-panel.collapsed .frozen-list { display:none; } - .frozen-panel.collapsed { padding-bottom:7px; } - .frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } - .frozen-card:active { cursor:grabbing; } - .frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); } - .frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } - .frozen-card-main { min-width:0; flex:1; } - .frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; } - canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; } - `; - document.head.appendChild(style); - } - - function ensurePanel() { - ensureStyles(); - let panel = document.getElementById("frozenPanel"); - if (panel) return panel; - panel = document.createElement("section"); - panel.id = "frozenPanel"; - panel.className = "frozen-panel"; - panel.innerHTML = ` -
-
- `; - const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed"); - const collapsedByDefault = storedCollapse !== "0"; - if (collapsedByDefault) { - panel.classList.add("collapsed"); - panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false"); - } else { - panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true"); - } - const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); - const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette"); - if (operateBody) operateBody.appendChild(panel); - else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); - else document.body.appendChild(panel); - return panel; + return Store.frozenList(worldRef); } function renderFrozenPanel(worldRef = global.world) { - const panel = ensurePanel(); - const listEl = panel.querySelector("#frozenList"); - const countEl = panel.querySelector("#frozenPanelCount"); - const list = frozenList(worldRef); - if (countEl) countEl.textContent = `${list.length}`; - if (!listEl) return; - if (!list.length) { - listEl.innerHTML = `
\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002
`; - return; - } - listEl.innerHTML = list.map(entry => { - const data = entry.data || entry; - const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / \u8a03\u5831${entry.pendingLosses.length}` : ""; - const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : ""; - const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || ""); - return ``; - }).join(""); + Panel.render(frozenList(worldRef)); } - function makeFrozenEntry(t, worldRef = global.world) { - const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"])); - data.sleeping = false; - data.insideNestBoxId = ""; - data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle"); - data.targetKey = ""; - const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`); - return { - freezeId: id, - frozenAt: Date.now(), - worldTime: worldRef?.time || 0, - name: t.name || "\u305f\u308a\u306a\u3044", - familyKey: t.familyKey || "", - liveId: t.id || "", - liveToken: t.liveToken || 0, - type: t.type || "smile", - data, - pendingLosses: [], - }; + function saveFrozenStore(worldRef = global.world) { + Store.save(worldRef); } - function findTarinaiAt(worldRef, x, y) { - const arr = worldRef?.tarinai || []; - for (let i = arr.length - 1; i >= 0; i--) { - const t = arr[i]; - if (!t || t.dead) continue; - if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t; - } - return null; + function loadFrozenStore(worldRef = global.world) { + return Store.load(worldRef); } - function freezeTarinai(t, worldRef = global.world) { - if (!t || t.dead || !worldRef) return false; - const entry = makeFrozenEntry(t, worldRef); - worldRef.clearLiveReferencesTo?.(t); - if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id); - worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t); - if (worldRef.selected === t) worldRef.selected = null; - frozenList(worldRef).push(entry); + function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) { + if (!worldRef) return; + if (selected !== undefined) worldRef.selected = selected; worldRef.drawListDirty = true; worldRef.familyTreeDirty = true; worldRef.updateItemCounts?.(); worldRef.rebuildSpatial?.(true); saveFrozenStore(worldRef); - renderFrozenPanel(worldRef); + worldRef.emit?.("freeze:changed", { world: worldRef, selected, renderWorld: true }); + } + + function freezeTarinai(t, worldRef = global.world) { + if (!t || t.dead || !worldRef) return false; + const entry = Adapter.makeEntry(t, worldRef); + worldRef.clearLiveReferencesTo?.(t); + if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id); + worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t); + if (worldRef.selected === t) worldRef.selected = null; + frozenList(worldRef).push(entry); + refreshWorldAfterFreezeChange(worldRef, worldRef.selected); global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`); worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] }); - global.renderStats?.(); - global.render?.(); + worldRef.emit?.("tarinai:frozen", { entry, tarinai: t }); return true; } - function updateLiveIdAfterThaw(worldRef, t) { - if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map(); - const id = t.id || ""; - const conflict = id && worldRef.liveTarinai.has(id); - if (!id || conflict) { - worldRef.assignTarinaiLiveId?.(t); - return; - } - worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t }); - const m = /^t(\d+)$/.exec(id); - if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1); - worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0); - } - - function applyPendingLossesOnThaw(t, entry, worldRef) { - const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : []; - if (!losses.length) return; - const unique = []; - const seen = new Set(); - for (const loss of losses) { - const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss); - if (seen.has(key)) continue; - seen.add(key); - unique.push(loss); - } - const relPriority = unique.some(l => l.relation === "\u89aa" || l.relation === "\u5b50") ? 1 : 0; - const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); - if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); - else { - if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 }); - t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, wake: true }); - } - t.bubble?.("\u3044\u306a\u3044", 1.1, "rgba(60,50,68,0.82)"); - t.recordChangeCause?.("\u51b7\u51cd\u4e2d\u306e\u8a03\u5831", "\u30b9\u30c8\u30ec\u30b9", { value: stress }); - t.addRecord?.(`\u51b7\u51cd\u4e2d\u306b${unique.map(l => l.name || "\u8ab0\u304b").slice(0, 3).join("\u3001")}\u304c\u6b7b\u4ea1\u3057\u3066\u3044\u305f\u3053\u3068\u306b\u6c17\u3065\u3044\u305f\u3002`, "death"); - worldRef.log?.(`${t.name}\u306f\u89e3\u51cd\u6642\u306b\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u304d\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death", { participants: [t] }); - } - function thawFrozen(freezeId, x, y, worldRef = global.world) { const list = frozenList(worldRef); const index = list.findIndex(e => (e.freezeId || e.id) === freezeId); if (index < 0 || !worldRef) return false; const entry = list[index]; - const data = clonePlain(entry.data || entry) || {}; let t = null; try { - t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true }); + t = Adapter.thawEntry(entry, x, y, worldRef); } catch (_) { return false; } - t.dead = false; - t.deathReason = ""; - t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); - t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); - t.vx = 0; - t.vy = 0; - t.target = null; - t.panicTarget = null; - t.insideNestBoxId = ""; - t.sleeping = false; - if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b"); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); - t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b"; - t.frozenAt = 0; - updateLiveIdAfterThaw(worldRef, t); - if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); worldRef.tarinai.push(t); worldRef.recordFamily?.(t); - applyPendingLossesOnThaw(t, entry, worldRef); list.splice(index, 1); - worldRef.selected = t; - worldRef.drawListDirty = true; - worldRef.familyTreeDirty = true; - worldRef.updateItemCounts?.(); - worldRef.rebuildSpatial?.(true); - saveFrozenStore(worldRef); - renderFrozenPanel(worldRef); + refreshWorldAfterFreezeChange(worldRef, t); global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`); worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] }); - global.renderStats?.(); - global.render?.(); + worldRef.emit?.("tarinai:thawed", { entry, tarinai: t }); return true; } function bindFreezeSystem() { const worldRef = global.world; loadFrozenStore(worldRef); - renderFrozenPanel(worldRef); - const panel = ensurePanel(); - if (panel.dataset.freezeBound !== "1") { - panel.dataset.freezeBound = "1"; - panel.addEventListener("dragstart", (e) => { - const card = e.target.closest(".frozen-card[data-frozen-id]"); - if (!card) return; - const id = card.dataset.frozenId || ""; - e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`); - e.dataTransfer?.setData("application/x-tarinai-frozen", id); - e.dataTransfer.effectAllowed = "move"; - global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready"); - }); - panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready")); - panel.addEventListener("click", (e) => { - const toggle = e.target.closest("#frozenPanelToggle"); - if (toggle) { - const collapsed = panel.classList.toggle("collapsed"); - toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); - try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {} - global.audio?.uiFold?.(); - return; - } - const card = e.target.closest(".frozen-card[data-frozen-id]"); - if (!card) return; - pendingThawId = card.dataset.frozenId || ""; - for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card); + worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false }); + Panel.bind({ + onSelect(id) { + pendingThawId = id || ""; global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002"); - }); - } - const canvas = global.canvas || document.getElementById("gameCanvas"); - if (canvas && canvas.dataset.freezeDropBound !== "1") { - canvas.dataset.freezeDropBound = "1"; - canvas.addEventListener("dragover", (e) => { - const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain"); - if (!text) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - canvas.classList.add("freeze-drop-ready"); - }); - canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready")); - canvas.addEventListener("drop", (e) => { - const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, ""); - if (!id) return; - e.preventDefault(); - canvas.classList.remove("freeze-drop-ready"); - const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => { - const rect = canvas.getBoundingClientRect(); - const sx = e.clientX - rect.left; - const sy = e.clientY - rect.top; - return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true }; - })(); - thawFrozen(id, p.x, p.y, worldRef); - }); - } + }, + onDrop(id, x, y) { + thawFrozen(id, x, y, global.world); + }, + }); } - let pendingThawId = ""; - function tryPendingThawAt(x, y, worldRef = global.world) { if (!pendingThawId) return false; const id = pendingThawId; pendingThawId = ""; - document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw")); + Panel.clearPending(); return thawFrozen(id, x, y, worldRef); } - function afterSnapshotRestored(worldRef = global.world) { - if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = []; + function afterWorldRestored(worldRef = global.world) { + Store.ensure(worldRef); saveFrozenStore(worldRef); - renderFrozenPanel(worldRef); + worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false }); } function patchWorldPrototype() { @@ -355,6 +125,6 @@ frozenList, loadFrozenStore, saveFrozenStore, - afterSnapshotRestored, + afterWorldRestored, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ground_types.js b/js/ground_types.js new file mode 100644 index 0000000..b83791a --- /dev/null +++ b/js/ground_types.js @@ -0,0 +1,76 @@ +"use strict"; + +const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage"]); +const GROUND_TYPES = Object.freeze({ + soil: Object.freeze({ id: "soil", label: "土", description: "いつものじめん", stressMultiplier: 1.0, grassMultiplier: 1.0 }), + concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassLimit: 0 }), + blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0 }), + foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0 }), +}); + +function groundDefinition(id = "soil") { + return GROUND_TYPES[id] || GROUND_TYPES.soil; +} + +function groundExists(id = "soil") { + return Boolean(GROUND_TYPES[id]); +} + +function groundLabel(id = "soil") { + return groundDefinition(id).label || "土"; +} + +function groundDescription(id = "soil") { + return groundDefinition(id).description || ""; +} + +function normalizedGroundStressMultiplier(id = "soil") { + const mult = Number(groundDefinition(id).stressMultiplier); + return Number.isFinite(mult) && mult > 0 ? mult : 1; +} + +function normalizedGroundGrassMultiplier(id = "soil") { + const mult = Number(groundDefinition(id).grassMultiplier); + return Number.isFinite(mult) && mult >= 0 ? mult : 1; +} + +function groundLimitForField(fieldType = "garden") { + const config = typeof CONFIG !== "undefined" ? CONFIG : null; + const byField = config?.grassLimitsByField || { cage: 19, garden: 99, park: 299 }; + const fieldLimit = byField[fieldType || "garden"]; + return Number(Number.isFinite(fieldLimit) ? fieldLimit : (config?.grassLimit ?? 99)); +} + +function groundGrassLimit(id = "soil", fieldType = "garden") { + const def = groundDefinition(id); + if (Number.isFinite(def.grassLimit)) return Math.max(0, Math.floor(Number(def.grassLimit))); + const mult = normalizedGroundGrassMultiplier(def.id); + if (mult <= 0) return 0; + const limit = groundLimitForField(fieldType) * mult; + return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity; +} + +function nextGroundTypeId(current = "soil") { + const index = GROUND_TYPE_ORDER.indexOf(current || "soil"); + return GROUND_TYPE_ORDER[(index + 1 + GROUND_TYPE_ORDER.length) % GROUND_TYPE_ORDER.length] || "soil"; +} + +function groundTooltipText(id = "soil") { + const def = groundDefinition(id); + const desc = def.description || "クリックで切り替え"; + return `${def.label || "じめん"}\n${desc}`; +} + +window.TarinaiGround = Object.freeze({ + definitions: GROUND_TYPES, + order: GROUND_TYPE_ORDER, + definition: groundDefinition, + exists: groundExists, + label: groundLabel, + description: groundDescription, + stressMultiplier: normalizedGroundStressMultiplier, + grassMultiplier: normalizedGroundGrassMultiplier, + grassLimit: groundGrassLimit, + nextId: nextGroundTypeId, + tooltipText: groundTooltipText, +}); diff --git a/js/item_lifecycle_runtime.js b/js/item_lifecycle_runtime.js new file mode 100644 index 0000000..179b504 --- /dev/null +++ b/js/item_lifecycle_runtime.js @@ -0,0 +1,626 @@ +"use strict"; + +// Item update/lifecycle and type-specific runtime methods. + +const DUPLICATOR_STORABLE_TYPES = new Set([ + "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", + "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", + "zunda_juice", "grass" +]); + +function duplicatorLoadTypeForItem(item) { + if (!item || item.dead || item.type === "duplicator") return ""; + const type = String(item.type || ""); + if (!type || type === "water_bowl" || type === "water") return ""; + // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が + // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 + if (DUPLICATOR_STORABLE_TYPES.has(type)) return type; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type; + if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type; + const foodDef = window.TarinaiItemRegistry?.food?.get?.(type); + if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type; + if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type; + return ""; +} + +function syncDuplicatorRoles(item) { + if (!item || item.type !== "duplicator") return; + const type = String(item.storedFoodType || ""); + item.roles.food = Boolean(type); + item.roles.medicine = Boolean(type && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))); + item.roles.drink = Boolean(type === "water" || type === "zunda_juice"); +} + +Object.assign(Item.prototype, { +update(dt, worldRef = world) { + this.age += dt; + if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt); + if (this.dropTimer > 0) { + const beforeDrop = this.dropTimer; + this.dropTimer = Math.max(0, this.dropTimer - dt); + if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) { + this.dropImpactDone = true; + if (worldRef.itemDropImpact) worldRef.itemDropImpact(this); + } + } + if (this.type === "water") this.amount -= dt * 0.9; + 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; + const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; + worldRef?.registerFoodSpoilage?.(lost * penalty, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); + if (this.foodServingsRemaining <= 0.015) this.amount = 0; + } + } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) { + const before = this.amount || 0; + const lost = Math.min(before, interval * 0.12); + this.amount -= lost; + if (lost > 0) { + const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? 0.002; + worldRef?.registerFoodSpoilage?.(lost * penalty, this); + worldRef?.markTerrainDirty?.("food-passive-decay"); + worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); + } + } + } + } + if (this.type === "trace") this.amount -= dt * 1.35; + if (this.type === "splat") this.amount -= dt * 1.05; + if (this.type === "ant_corpse") this.amount -= dt * 0.018; + if (this.type === "firecracker") { + this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt); + if (this.fuseTimer <= 0) { + if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this); + else this.amount = 0; + } + } + if (this.type === "genkotsu") { + this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); + 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 === "duplicator") this.updateDuplicator(dt, worldRef); + if (isPinType(this.type)) this.updatePushpin(dt, worldRef); + if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); + if (this.type === "grass") { + // Grass is intentionally not simulated per item. World.update randomly + // advances one grass by one discrete stage every few seconds. + normalizeGrassStage(this); + } else if (this.type === "zunchi") { + this.lifecycleTimer += dt; + const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45); + if (this.lifecycleTimer >= interval) { + const lifecycleDt = Math.min(this.lifecycleTimer, 2.4); + this.lifecycleTimer = 0; + this.updateZunchi(lifecycleDt, worldRef); + } + } + }, + +updateDuplicator(dt, worldRef) { + this.amount = 999; + syncDuplicatorRoles(this); + if (!worldRef?.nearbyItems) return; + const pickupRadius = Math.max(96, (this.r || 34) * 3.0); + const maxContact = item => Math.max(68, (this.r || 34) + (item?.r || 14) + 24); + const candidates = Array.from(worldRef.nearbyItems(this.x, this.y, pickupRadius, true) || []); + // SpatialGrid の分類漏れがあっても、複製機の近接投入だけは取りこぼさない。 + // 全件を「候補にする」のではなく、投入半径付近にあるものだけ補完する。 + if (worldRef.items) { + for (const item of worldRef.items) { + if (!item || candidates.includes(item) || item === this || item.dead) continue; + const approxReach = pickupRadius + Math.max(18, item.r || 12); + if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item); + } + } + let best = null, bestD = Infinity, bestType = ""; + for (const it of candidates) { + if (!it || it === this || it.dead) continue; + const loadType = duplicatorLoadTypeForItem(it); + if (!loadType) continue; + if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; + const d = distXY(this.x, this.y, it.x, it.y); + if (d < bestD && d <= maxContact(it)) { best = it; bestD = d; bestType = loadType; } + } + if (!best || !bestType) return; + const def = typeof itemDefinition === "function" ? itemDefinition(bestType) : null; + const newLabel = def?.label || bestType; + const changed = this.storedFoodType !== bestType; + if (!changed && this.storedFoodType) return; + this.storedFoodType = bestType; + this.storedFoodLabel = newLabel; + syncDuplicatorRoles(this); + this.loadedAt = worldRef.time || 0; + best.amount = 0; + best.foodServingsRemaining = 0; + // Item.dead is a getter based on amount. Do not assign to it. + worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed }); + worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food"); + }, + +updateZunchiMotion(dt, worldRef) { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); + } else { + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } + const slow = Math.pow(0.68, dt * 2.4); + this.vx *= slow; + this.vy *= slow; + worldRef.drawListDirty = true; + } + this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed); + this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); + }, + +resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { + if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return; + const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); + for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue; + const now = worldRef.time || 0; + if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue; + this.lastHitTarinaiAt = this.lastHitTarinaiAt || {}; + this.lastHitTarinaiAt[t.id] = now; + const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 1.15, 150, 620); + t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); + t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); + t.fallTimer = Math.max(t.fallTimer || 0, 0.85); + t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); + else { + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true }); + if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); + } + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); + worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + this.vx *= -0.18; + this.vy *= -0.18; + break; + } + }, + +updateBall(dt, worldRef) { + this.prevX = this.x; + this.prevY = this.y; + const moving = Math.hypot(this.vx || 0, this.vy || 0); + if (moving > 0.04) { + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } + this.resolveBallObstacleCollisions(dt, worldRef); + this.x = clamp(this.x, p, worldRef.w - p); + this.y = clamp(this.y, p, worldRef.h - p); + const groundFriction = Math.pow(0.72, dt); + this.vx *= groundFriction; + this.vy *= groundFriction; + } + this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18); + this.spinVelocity *= Math.pow(0.65, dt); + if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; } + }, + +resolveBallObstacleCollisions(dt, worldRef) { + if (!worldRef?.nearbyItems) return; + const speed = Math.hypot(this.vx || 0, this.vy || 0); + const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); + const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || []; + for (const it of items) { + if (!it || it === this || it.dead) continue; + if (it.type === "bed") { + this.applyBallHayDrag(it, dt, worldRef); + } else if (it.type === "stone") { + this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef); + } + const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; + if (!rects.length) continue; + for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it); + } + }, + +applyBallHayDrag(bed, dt, worldRef) { + const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40); + const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34); + const dx = this.x - bed.x; + const dy = this.y - bed.y; + const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; + if (!inside) return; + const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016)); + this.vx *= slow; + this.vy *= slow; + this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); + if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) { + this.lastHaySlowLogAt = worldRef.time || 0; + worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); + } + }, + +resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { + let dx = this.x - obstacle.x; + let dy = this.y - obstacle.y; + let d = Math.hypot(dx, dy); + if (d >= hitRadius) { + const px = this.prevX; + const py = this.prevY; + if (!Number.isFinite(px) || !Number.isFinite(py)) return; + const sx = this.x - px; + const sy = this.y - py; + const len2 = sx * sx + sy * sy; + if (len2 <= 0.0001) return; + const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1); + const cx = px + sx * t; + const cy = py + sy * t; + dx = cx - obstacle.x; + dy = cy - obstacle.y; + d = Math.hypot(dx, dy); + if (d >= hitRadius) return; + if (d < 0.001) { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } + else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; } + } + } + if (d < 0.001) { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } + else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; } + } + const nx = dx / d; + const ny = dy / d; + const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; + this.x = obstacle.x + nx * (hitRadius + 0.5); + this.y = obstacle.y + ny * (hitRadius + 0.5); + if (toward < 0) { + this.vx = (this.vx || 0) - (1 + restitution) * toward * nx; + this.vy = (this.vy || 0) - (1 + restitution) * toward * ny; + } else { + this.vx = (this.vx || 0) + nx * 24; + this.vy = (this.vy || 0) + ny * 24; + } + this.vx *= 0.96; + this.vy *= 0.96; + this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38); + this.emitBallBounce(worldRef, obstacle); + }, + +resolveBallRectBounce(rect, worldRef, source = null) { + if (!rect) return; + const hitRadius = (this.r || 18) + 2; + const cx = clamp(this.x, rect.left, rect.right); + const cy = clamp(this.y, rect.top, rect.bottom); + let dx = this.x - cx; + let dy = this.y - cy; + let d = Math.hypot(dx, dy); + if (d >= hitRadius) { + const px = this.prevX; + const py = this.prevY; + const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius }; + if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return; + let nx = 0; + let ny = 0; + const vx = this.vx || 0; + const vy = this.vy || 0; + const candidates = []; + const sx = this.x - px; + const sy = this.y - py; + if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) }); + if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) }); + if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) }); + if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) }); + if (candidates.length) { + candidates.sort((a, b) => a.t - b.t); + nx = candidates[0].nx; + ny = candidates[0].ny; + } else if (Math.abs(vx) >= Math.abs(vy)) { + nx = vx >= 0 ? -1 : 1; + } else { + ny = vy >= 0 ? -1 : 1; + } + const toward = vx * nx + vy * ny; + if (nx < 0) this.x = expanded.left - 0.5; + else if (nx > 0) this.x = expanded.right + 0.5; + if (ny < 0) this.y = expanded.top - 0.5; + else if (ny > 0) this.y = expanded.bottom + 0.5; + if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom); + if (ny) this.x = clamp(this.x, expanded.left, expanded.right); + if (toward < 0) { + this.vx = vx - 1.78 * toward * nx; + this.vy = vy - 1.78 * toward * ny; + } else { + this.vx = vx + nx * 18; + this.vy = vy + ny * 18; + } + this.vx *= 0.94; + this.vy *= 0.94; + this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); + this.emitBallBounce(worldRef, source || rect.item || rect); + return; + } + if (d < 0.001) { + const left = Math.abs(this.x - rect.left); + const right = Math.abs(rect.right - this.x); + const top = Math.abs(this.y - rect.top); + const bottom = Math.abs(rect.bottom - this.y); + const m = Math.min(left, right, top, bottom); + if (m === left) { dx = -1; dy = 0; } + else if (m === right) { dx = 1; dy = 0; } + else if (m === top) { dx = 0; dy = -1; } + else { dx = 0; dy = 1; } + d = 1; + } + const nx = dx / d; + const ny = dy / d; + const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; + this.x = cx + nx * (hitRadius + 0.5); + this.y = cy + ny * (hitRadius + 0.5); + if (toward < 0) { + this.vx = (this.vx || 0) - 1.78 * toward * nx; + this.vy = (this.vy || 0) - 1.78 * toward * ny; + } else { + this.vx = (this.vx || 0) + nx * 18; + this.vy = (this.vy || 0) + ny * 18; + } + this.vx *= 0.94; + this.vy *= 0.94; + this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); + this.emitBallBounce(worldRef, source || rect.item || rect); + }, + +emitBallBounce(worldRef, obstacle) { + const now = worldRef?.time || 0; + if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return; + this.lastObstacleBounceAt = now; + worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" })); + }, + +updatePushpin(dt, worldRef) { + if (!worldRef) return; + if (this.pinState === "lodged") { + this.updateLodgedPushpin(dt, worldRef); + return; + } + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); + } else { + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + } + this.tryStickPushpin(worldRef); + }, + +tryStickPushpin(worldRef) { + if (!worldRef?.tarinai?.length) return false; + if (this.pinState === "lodged") return false; + const now = worldRef.time || 0; + if ((this.noStickUntil || 0) > now) return false; + let best = null; + let bestD = Infinity; + const hitRange = Math.max(10, (this.r || 8) * 1.25); + const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai; + for (const t of candidates) { + if (!t || t.dead) continue; + if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue; + const d = distXY(this.x, this.y, t.x, t.y); + const hit = (t.radius || 16) * 0.86 + hitRange; + if (d <= hit && d < bestD) { best = t; bestD = d; } + } + if (!best) return false; + return this.attachPushpin(best, worldRef, bestD); + }, + +attachPushpin(t, worldRef, d = null) { + if (!t || t.dead) return false; + if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + const dx = this.x - t.x; + const dy = this.y - t.y; + const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); + this.pinState = "lodged"; + this.deletable = false; + this.pinTargetId = t.id; + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = isOshibyo ? -faceDir : faceDir; + this.pinVisualSide = visualSide; + this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide); + this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + this.pinDamageTick = 0; + this.pinFallCheckTimer = 0; + this.vx = 0; + this.vy = 0; + this.spinVelocity = 0; + this.spin = this.pinAttachAngle; + t.stuckPushpinId = this.id; + t.sleeping = false; + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); + if (isOshibyo) { + t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; + worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); + worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + return true; + } + if (t.enterPanic) { + t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); + } else { + t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); + t.fearTimer = Math.max(t.fearTimer || 0, 1.3); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); + } + if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); + if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { + this.pinLogAt = worldRef.time || 0; + worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); + } + worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); + return true; + }, + +updateLodgedPushpin(dt, worldRef) { + const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null; + if (!t) { + this.detachPushpin(worldRef, "owner-lost"); + return; + } + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); + t.stuckPushpinId = this.id; + this.deletable = false; + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = isOshibyo ? -faceDir : faceDir; + this.pinVisualSide = visualSide; + this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36; + this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; + this.prevX = this.x; + this.prevY = this.y; + this.spin = visualSide * (isOshibyo ? 0.52 : 0.28); + this.vx = t.vx || 0; + this.vy = t.vy || 0; + if (isOshibyo) { + if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044"; + return; + } + this.pinDamageTick = (this.pinDamageTick || 0) + dt; + while (this.pinDamageTick >= 0.55) { + this.pinDamageTick -= 0.55; + if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); + t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); + if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); + if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); + } + t.sleeping = false; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); + else { + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); + t.fearTimer = Math.max(t.fearTimer || 0, 0.65); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); + } + this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; + while (this.pinFallCheckTimer >= 1.0) { + this.pinFallCheckTimer -= 1.0; + if (Math.random() < 0.10) { + this.detachPushpin(worldRef, "fall"); + return; + } + } + }, + +detachPushpin(worldRef, reason = "released") { + const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const wasOshibyo = Boolean(behavior?.blocksZunchi); + const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; + if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; + if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; + if (reason === "pinch") { + const now = worldRef?.time || 0; + this.noStickUntil = now + 1.0; + this.noStickTargetId = t?.id || ""; + this.noStickTargetUntil = now + 1.0; + } else if (t) { + const now = worldRef?.time || 0; + this.noStickTargetId = t.id || ""; + this.noStickTargetUntil = now + 0.55; + } + this.pinState = "loose"; + this.pinTargetId = ""; + this.pinDamageTick = 0; + this.pinFallCheckTimer = 0; + this.deletable = true; + if (reason === "pinch") { + this.vx = 0; + this.vy = 0; + this.spinVelocity = 0; + } else { + this.vx = rand(-24, 24); + this.vy = rand(-10, 18); + this.spinVelocity = rand(-1.6, 1.6); + this.spin += rand(-0.25, 0.25); + if (t) { + this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30)); + this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30)); + } + worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); + if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); + } + + if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { + worldRef.spawnBurstZunchi(t || this, storedZunchi, this); + } + }, + +updateZunchi(dt, worldRef) { + const rainy = worldRef.weather === "light_rain"; + const rainBoost = rainy ? 1.28 : 1; + const decayBoost = rainy ? 1.35 : 1; + let waterBoost = 0; + const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []); + for (const it of nearby) { + if (it.type !== "water") continue; + waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1); + } + this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65); + const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt; + if (this.stage === "fresh") { + this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); + this.amount -= decayDt * 0.018 * decayBoost; + if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; } + } else if (this.stage === "dry") { + this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55); + this.amount -= decayDt * 0.032 * decayBoost; + if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; } + } else if (this.stage === "decomposing") { + this.fertility = clamp(this.fertility + dt * 0.015, 0, 1); + this.amount -= decayDt * 0.046 * decayBoost; + if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); } + } else if (this.stage === "fertile_soil") { + this.fertility = clamp(1 - this.stageTimer / 360, 0, 1); + this.amount -= decayDt * 0.090 * decayBoost; + } + } +}); diff --git a/js/item_registry.js b/js/item_registry.js new file mode 100644 index 0000000..f7cd9c6 --- /dev/null +++ b/js/item_registry.js @@ -0,0 +1,682 @@ +"use strict"; + +// Unified item definition source: tools, item traits, visuals, food metadata, and item effects. + +const TOOL_DEFINITIONS = Object.freeze({ + 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\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\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.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, + sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, + + laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" }, + protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" }, + niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" }, + ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" }, + mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" }, + mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" }, + giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" }, + dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" }, + zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" }, + grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, + stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, + bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, + nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, + ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, + ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, + signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" }, + firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, + pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, + oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" }, + fence_v: { id: "fence_v", itemType: "fence_v", label: "\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: 4, amount: 24, simulationOnly: true }, +}); + + +const ITEM_TRAITS = Object.freeze({ + obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]), + food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + pin: new Set(["pushpin", "oshibyo"]), + kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), + sleepFurniture: new Set(["bed", "nest_box"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]), +}); + +function itemHasTrait(type = "", trait = "") { + return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); +} + +function isPinType(type = "") { return itemHasTrait(type, "pin"); } +function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } +function lodgedPinFor(tarinai = null, worldRef = null) { + if (!tarinai?.stuckPushpinId) return null; + const items = worldRef?.items || tarinai.world?.items || []; + return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; +} +function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } +function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } + +const PIN_BEHAVIORS = Object.freeze({ + pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, + oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, +}); + +function pinBehaviorFor(type = "") { + return PIN_BEHAVIORS[String(type || "")] || null; +} + + +let ITEM_VISUAL_DEFINITIONS = null; +let FOOD_REGISTRY = null; +let FOOD_DEFINITIONS = null; +let EFFECT_REGISTRY = null; +let EFFECT_DEFINITIONS = null; + +const TOOL_CATEGORIES = Object.freeze([ + { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, + { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] }, + { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, + { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] }, + { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] }, +]); + +function toolCategories() { + return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0)); +} + +function toolDefinition(id = "") { + return TOOL_DEFINITIONS[id] || null; +} + +function toolLabel(id = "") { + const def = toolDefinition(id); + return def?.label || id || ""; +} + +function toolItemType(id = "") { + const def = toolDefinition(id); + return def?.placeable ? (def.itemType || def.id) : null; +} + +function itemDefinition(type = "") { + return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; +} + +function itemRadiusFor(type = "", fallback = 12) { + return itemDefinition(type)?.radius ?? fallback; +} + +function itemAmountFor(type = "", fallback = 80) { + return itemDefinition(type)?.amount ?? fallback; +} + +const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); +const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }); +const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); +function normalizedToolSizeFor(worldRef = null, type = "") { + const def = itemDefinition(type); + if (!def?.scalable) return "medium"; + const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; + return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; +} +function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } +function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } + +function scalableToolIds() { + return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); +} + +function toolTipsFromDefinitions() { + return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); +} + +(function (global) { + const raw = String.raw`{ + "water": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"] + }, + "zunda_juice": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"] + }, + "mercury": { + "renderer": "oval_droplet", + "shape": "horizontal_oval_liquid", + "palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] + }, + "water_bowl": { + "renderer": "water_bowl", + "shape": "bowl_with_horizontal_oval_water", + "palette": { + "bowlFill": "rgba(171,120,76,0.72)", + "bowlStroke": "rgba(94,69,48,0.55)", + "waterFill": "rgba(86,157,224,0.58)", + "waterStroke": "rgba(62,113,178,0.36)", + "highlight": "rgba(255,255,255,0.54)" + }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"] + }, + "sleep_drug": { + "renderer": "sleep_tablet", + "shape": "moon_tablet", + "palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "moon", "purple"] + }, + "laxative": { + "renderer": "laxative_tablet", + "shape": "rounded_tablet_zunchi_motif", + "palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "zunchi motif", "tan"] + }, + "mystery_drug": { + "renderer": "mystery_tablet", + "shape": "question_mark_tablet", + "palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["tablet", "question mark", "purple"] + }, + "ammo": { + "renderer": "bullet", + "shape": "bullet", + "palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bullet", "gold"] + }, + "protein": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "orange"] + }, + "niteropu": { + "renderer": "powder_pile", + "shape": "powder_pile", + "palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["powder", "purple"] + }, + "giant_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" }, + "scale": 1.08, + "uiPreviewScale": 1.08, + "fieldScale": 1.08, + "accessibleShapeTraits": ["pill", "large", "orange"] + }, + "dwarf_drug": { + "renderer": "split_pill", + "shape": "split_pill", + "palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" }, + "scale": 0.92, + "uiPreviewScale": 0.92, + "fieldScale": 0.92, + "accessibleShapeTraits": ["pill", "small", "purple"] + }, + "ball": { + "renderer": "emoji", + "shape": "soccer_ball", + "palette": {}, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["round", "soccer ball"], + "emojiFallback": "\u26bd" + } + }`; + + const defs = Object.freeze(JSON.parse(raw)); + + function itemVisualDefinition(type = "") { + return defs[String(type || "")] || null; + } + + function itemVisualPalette(type = "") { + return itemVisualDefinition(type)?.palette || null; + } + + ITEM_VISUAL_DEFINITIONS = defs; + global.itemVisualDefinition = itemVisualDefinition; + global.itemVisualPalette = itemVisualPalette; +})(typeof window !== "undefined" ? window : globalThis); + +(function (global) { + const rawDefinitions = { + food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, + sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, + love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, + fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, + sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, + laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, + protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, + niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, + ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, + mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, + mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, + giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, + dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, + zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, + grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } + }; + + class FoodRegistry extends global.TarinaiRegistryBase { + servingTypes() { + return [...this.defs.entries()] + .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) + .map(([id]) => id); + } + typesByInterest(...interests) { + const wanted = new Set(interests.flat().filter(Boolean)); + return [...this.defs.entries()] + .filter(([, def]) => wanted.has(def.interest)) + .map(([id]) => id); + } + paramEffectTypes() { + return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); + } + nutrition(type = "", fallback = 0) { + const value = this.get(type)?.nutrition; + return Number.isFinite(value) ? value : fallback; + } + hungerRelief(type = "", fallback = 0) { + const value = this.get(type)?.hungerRelief; + return Number.isFinite(value) ? value : fallback; + } + effectId(type = "") { return this.get(type)?.effectId || ""; } + passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } + hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } + } + + const registry = new FoodRegistry(rawDefinitions); + FOOD_REGISTRY = registry; + FOOD_DEFINITIONS = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); + +(function (global) { + const rawDefinitions = { + laxative: { + label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60, + tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`, + }, + ammo: { + label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18, + tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`, + }, + protein: { + label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d", + }, + niteropu: { + label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power", + tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d", + }, + mercury: { + label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply", + tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`, + }, + giant_drug: { + label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + dwarf_drug: { + label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size", + tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8", + }, + love_mochi: { + label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + fight_mochi: { + label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer", + tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`, + }, + sleep_drug: { + label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"], + }, + mystery_drug: { + label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"], + }, + zunda_juice: { + label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"], + }, + disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] }, + disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] }, + disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] }, + }; + + Object.assign(rawDefinitions.laxative, { + modifiers: { hungerReliefMultiplier: 0 }, + tickInterval: 3, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.ammo, { + modifiers: { speedMultiplier: 10 }, + visualEffectId: "fall", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.protein, { + modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 }, + visualEffectId: "fall_up_red", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.niteropu, { + modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 }, + visualEffectId: "fall_down_purple", + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.mercury, { + modifiers: { lifeMultiplierPerUse: 1.3 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.giant_drug, { + modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + Object.assign(rawDefinitions.dwarf_drug, { + modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 }, + bubblePolicy: "none", + mysteryEligible: true, + zundaJuiceRemovable: true, + }); + for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) { + Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" }); + } + Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false }); + + Object.assign(rawDefinitions.love_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); + if (context.source === "eating") { + if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); + } + return true; + }, + }); + Object.assign(rawDefinitions.fight_mochi, { + onApply(tarinai, context = {}) { + const scale = context.scale ?? 1; + tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); + if (context.source === "eating") { + tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); + if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); + } + return true; + }, + }); + Object.assign(rawDefinitions.sleep_drug, { + onApply(tarinai, context = {}) { + if (context.source === "eating") { + const nutrition = Number(context.nutrition) || 0; + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); + tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); + tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); + tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); + const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); + const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; + if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); + if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; + return true; + } + return tarinai.infectSleepDisease?.(context.item || null) || true; + }, + }); + Object.assign(rawDefinitions.laxative, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1))); + }, + onTick(tarinai) { + tarinai.forceLaxativePoop?.(); + return true; + }, + }); + Object.assign(rawDefinitions.ammo, { + onApply(tarinai, context = {}) { + return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1))); + }, + }); + Object.assign(rawDefinitions.protein, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); }, + }); + Object.assign(rawDefinitions.niteropu, { + onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); }, + }); + Object.assign(rawDefinitions.mercury, { + onApply(tarinai) { return tarinai.setMercuryEffect?.(); }, + }); + Object.assign(rawDefinitions.giant_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.dwarf_drug, { + onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); }, + onTick(tarinai) { + tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label); + if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25); + return true; + }, + }); + Object.assign(rawDefinitions.zunda_juice, { + onApply(tarinai) { return tarinai.clearAllItemEffects?.(); }, + }); + Object.assign(rawDefinitions.disease_zunchi, { + onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; }, + }); + Object.assign(rawDefinitions.disease_sleep, { + onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_explosion, { + onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; }, + }); + Object.assign(rawDefinitions.disease_fight, { + onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, + }); + + class EffectRegistry extends global.TarinaiRegistryBase { + label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } + display(id = "", timer = 0, tarinai = null) { + const def = this.get(id); + if (!def) return ""; + return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || ""); + } + apply(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onApply !== "function") return false; + return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + tick(id = "", tarinai = null, dt = 0, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onTick !== "function") return false; + return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this })); + } + remove(id = "", tarinai = null, context = {}) { + const def = this.get(id); + if (!def || !tarinai || typeof def.onRemove !== "function") return false; + return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this })); + } + modifier(id = "", key = "", fallback = 1) { + const value = this.get(id)?.modifiers?.[key]; + return Number.isFinite(value) ? value : fallback; + } + timerValue(tarinai = null, id = "") { + const def = this.get(id); + if (!def || !tarinai) return 0; + if (def.timerProp) return Number(tarinai[def.timerProp] || 0); + if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0); + return 0; + } + isActive(tarinai = null, id = "") { + if (!tarinai) return false; + const def = this.get(id); + if (!def) return false; + if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id; + if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id; + if (id === "mercury") return tarinai.lifeItemMode === "mercury"; + return this.timerValue(tarinai, id) > 0.04; + } + activeSummary(tarinai = null) { + const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"]; + return order + .filter(id => this.isActive(tarinai, id)) + .map(id => ({ + id, + label: this.label(id), + detail: this.display(id, this.timerValue(tarinai, id), tarinai), + })); + } + labels() { return super.labels(); } + colors() { return super.colors(); } + randomPool() { + const itemEffects = [...this.defs.entries()] + .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) + .map(([id]) => id) + .filter(id => id !== "mystery_drug" && id !== "zunda_juice"); + const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || []; + return [...new Set([...itemEffects, ...diseases])]; + } + permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); } + timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); } + debugSnapshot() { + return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" })); + } + } + + const registry = new EffectRegistry(rawDefinitions); + EFFECT_REGISTRY = registry; + EFFECT_DEFINITIONS = Object.freeze(rawDefinitions); +})(typeof window !== "undefined" ? window : globalThis); + + +(function (global) { + function itemTypeDefinition(type = "") { + const id = String(type || ""); + const tool = typeof itemDefinition === "function" ? itemDefinition(id) : null; + const food = FOOD_DEFINITIONS?.[id] || null; + const effectId = food?.effectId || (EFFECT_DEFINITIONS?.[id] ? id : ""); + const effect = effectId ? (EFFECT_DEFINITIONS?.[effectId] || null) : null; + const visual = ITEM_VISUAL_DEFINITIONS?.[id] || null; + return { + id, + tool, + food, + effect, + effectId, + visual, + label: tool?.label || food?.label || effect?.label || id, + radius: tool?.radius, + amount: tool?.amount, + placeable: Boolean(tool?.placeable), + scalable: Boolean(tool?.scalable), + traits: Object.fromEntries(Object.keys(ITEM_TRAITS).map(trait => [trait, itemHasTrait(id, trait)])), + }; + } + + function itemTypeLabel(type = "") { + return itemTypeDefinition(type).label; + } + + function itemTypeDebugSnapshot() { + const ids = new Set([ + ...Object.values(TOOL_DEFINITIONS).map(def => def.itemType || def.id), + ...Object.keys(FOOD_DEFINITIONS || {}), + ...Object.keys(EFFECT_DEFINITIONS || {}), + ...Object.keys(ITEM_VISUAL_DEFINITIONS || {}), + ]); + return [...ids].sort().map(id => itemTypeDefinition(id)); + } + + global.TarinaiItemRegistry = Object.freeze({ + definition: itemTypeDefinition, + label: itemTypeLabel, + debugSnapshot: itemTypeDebugSnapshot, + tool: toolDefinition, + item: itemDefinition, + food: FOOD_REGISTRY, + effect: EFFECT_REGISTRY, + visual: itemVisualDefinition, + hasTrait: itemHasTrait, + radius: itemRadiusFor, + amount: itemAmountFor, + categories: toolCategories, + scalableToolIds, + pinBehavior: pinBehaviorFor, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js new file mode 100644 index 0000000..1db06f4 --- /dev/null +++ b/js/item_render_runtime.js @@ -0,0 +1,533 @@ +"use strict"; + +// Item field rendering. Kept separate from construction and lifecycle logic. + +Object.assign(Item.prototype, { +draw(ctx, t, lighting = null) { + const lightState = lighting || getLightingState(world); + const night = clamp(lightState.nightStrength * 1.35, 0, 1); + const warm = lightState.warmth; + const styleNight = lightState.light < 0.42; + const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); + if (isPinType(this.type) && this.pinState === "lodged") { + const owner = world?.liveTarinaiById?.(this.pinTargetId) || null; + if (owner?.insideNestBoxId) return; + } + const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; + const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); + const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; + const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170; + const dropY = dropT > 0 ? -dropFallHeight * dropT : 0; + const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); + if (this.type !== "trace" && this.type !== "splat") { + const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null; + const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null; + if (zunchiImg) { + const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi"); + const ratio = metrics?.ratio || 178 / 236; + const zunchiH = this.r * 1.66 * ratio; + drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, { + alpha: shadow.alpha * visibleAlpha * 1.18, + widthScale: 0.92, + heightScale: 0.92, + }); + } else if (this.type === "grass") { + ctx.save(); + ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13); + ctx.fillStyle = shadow.color; + ctx.beginPath(); + ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } else { + drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 }); + } + } + drawItemGlow(ctx, this, lightState, visibleAlpha); + if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) { + ctx.save(); + ctx.globalAlpha = 0.32; + ctx.strokeStyle = "rgba(255, 250, 220, 0.82)"; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + ctx.save(); + ctx.translate(this.x, this.y + dropY); + ctx.globalAlpha = visibleAlpha; + if (isConsumableSpriteType(this.type)) { + drawConsumableFieldSprite(ctx, this.type, this.r, this.seed); + } else if (this.type === "genkotsu") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); + const impact = clamp(this.impactFlash || 0, 0, 1); + ctx.save(); + ctx.shadowColor = "rgba(54,42,31,0.26)"; + ctx.shadowBlur = 5 + impact * 8; + ctx.shadowOffsetY = 4; + const squashY = 1 - impact * 0.08; + const stretchX = 1 + impact * 0.05; + ctx.scale(stretchX, squashY); + if (img) { + const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null; + const w = this.r * 2.55; + const h = w * (metrics?.ratio || 1.27); + ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h); + } else { + ctx.fillStyle = "rgba(255,255,255,0.94)"; + ctx.strokeStyle = "rgba(64,64,64,0.78)"; + ctx.lineWidth = 5; + roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(42,46,52,0.92)"; + ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("\u6b63", 0, -this.r * 0.74); + ctx.fillText("\u7fa9", 0, -this.r * 0.27); + } + if (impact > 0.01) { + ctx.globalAlpha = impact * 0.20; + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } else if (isPinType(this.type)) { + const stuck = this.pinState === "lodged"; + const isOshibyo = this.type === "oshibyo"; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); + const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); + ctx.save(); + if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1); + if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); + ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)"; + ctx.shadowBlur = 3; + ctx.shadowOffsetY = 2; + if (img) { + const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; + const ratio = metrics?.ratio || (stuck ? 1 : 2.0); + const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78); + const h = w * ratio; + ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); + } else if (stuck) { + ctx.fillStyle = "#d92736"; + ctx.strokeStyle = "rgba(125,18,30,0.78)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2); + ctx.fillStyle = "rgba(176,20,30,0.82)"; + ctx.fill(); + } else { + ctx.fillStyle = "#d92736"; + ctx.strokeStyle = "rgba(125,18,30,0.78)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.strokeStyle = "rgba(210,210,215,0.92)"; + ctx.lineWidth = Math.max(1.5, this.r * 0.12); + ctx.beginPath(); + ctx.moveTo(0, this.r * 0.42); + ctx.lineTo(0, this.r * 1.65); + ctx.stroke(); + } + ctx.restore(); + } else if (this.type === "water_bowl") { + const palette = visualPaletteFor("water_bowl"); + ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; + ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; + ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; + ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "water") { + drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); + } else if (this.type === "grass") { + const numericStage = Number.isFinite(this.grassStage) ? clampGrassStage(this.grassStage) : grassStageFromGrowth(this.growth, this.amount); + const growth = grassGrowthForStage(numericStage); + const eaten = (this.amount || 0) <= 0; + const stageLabel = numericStage <= 0 ? "sprout" : (numericStage <= 2 ? "young" : "mature"); + const wither = clamp(this.wither || 0, 0, 1); + ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"))); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.lineCap = "round"; + ctx.lineWidth = styleNight ? 1.5 : 1.8; + const bladeCount = Math.max(2, numericStage + 2); + for (let i = 0; i < bladeCount; i++) { + const spread = stageLabel === "mature" ? 1.05 : 0.68; + const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread); + const len = this.r * randSeed(this.seed + i + 10, 0.42, stageLabel === "mature" ? 1.18 : 0.92) * clamp(growth, 0.18, 0.92); + const rootX = stageLabel === "mature" ? randSeed(this.seed + i + 31, -this.r * 0.28, this.r * 0.28) : 0; + ctx.beginPath(); + ctx.moveTo(rootX, 8); + ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78); + ctx.stroke(); + } + if (numericStage >= 3 && !eaten) { + ctx.save(); + ctx.globalAlpha = 0.14; + ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)"; + ctx.beginPath(); + ctx.ellipse(0, 1, this.r * 0.62, this.r * 0.20, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } else if (this.type === "stone") { + ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8"); + ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63"; + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.lineWidth = 2; + roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.34)"; + ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "bed") { + ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" ); + ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" ); + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = "rgba(116, 89, 42, 0.45)"; + ctx.lineWidth = 1.5; + for (let i = 0; i < 13; i++) { + const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08); + const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46); + const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70); + const a = randSeed(this.seed + i + 60, -0.75, 0.75); + ctx.beginPath(); + ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5); + ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); + ctx.stroke(); + } + } else if (this.type === "nest_box") { + ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237"); + ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24"; + ctx.lineWidth = 2.6; + roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b"; + roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6); + ctx.fill(); + ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)"; + ctx.lineWidth = 2.0; + ctx.beginPath(); + ctx.moveTo(-this.r * 0.95, -this.r * 0.50); + ctx.lineTo(this.r * 0.95, -this.r * 0.50); + ctx.moveTo(-this.r * 0.95, this.r * 0.48); + ctx.lineTo(this.r * 0.95, this.r * 0.48); + ctx.stroke(); + ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f"; + ctx.beginPath(); + ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); + ctx.fill(); + } else if (this.type === "duplicator") { + ctx.save(); + const loaded = !!this.storedFoodType; + const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9); + bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6"); + bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7"); + bodyGrad.addColorStop(1, styleNight ? "#2c343c" : "#9fafb7"); + ctx.fillStyle = bodyGrad; + ctx.strokeStyle = styleNight ? "#1b252c" : "#52636b"; + ctx.lineWidth = Math.max(2, this.r * 0.075); + roundedRect(ctx, -this.r * 1.10, -this.r * 0.86, this.r * 1.52, this.r * 1.52, this.r * 0.20); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = styleNight ? "rgba(20,28,34,0.52)" : "rgba(255,255,255,0.72)"; + roundedRect(ctx, -this.r * 0.92, -this.r * 0.64, this.r * 1.12, this.r * 0.42, this.r * 0.10); + ctx.fill(); + ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)"; + ctx.stroke(); + + ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)"; + ctx.beginPath(); + ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(35,47,54,0.52)"; + ctx.stroke(); + ctx.fillStyle = loaded ? "rgba(126,245,141,0.82)" : "rgba(255,255,255,0.45)"; + ctx.beginPath(); + ctx.arc(-this.r * 0.74, -this.r * 0.47, this.r * 0.045, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = styleNight ? "#232b30" : "#657079"; + ctx.lineWidth = Math.max(1.6, this.r * 0.045); + ctx.beginPath(); + ctx.moveTo(this.r * 0.18, -this.r * 0.34); + ctx.quadraticCurveTo(this.r * 0.48, -this.r * 0.52, this.r * 0.74, -this.r * 0.28); + ctx.stroke(); + ctx.fillStyle = styleNight ? "#283139" : "#e6edf0"; + ctx.beginPath(); + ctx.arc(this.r * 0.78, -this.r * 0.28, this.r * 0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + const dishGrad = ctx.createLinearGradient(this.r * 0.08, -this.r * 0.06, this.r * 1.28, this.r * 0.62); + dishGrad.addColorStop(0, styleNight ? "#3f454b" : "#eef4f4"); + dishGrad.addColorStop(0.58, styleNight ? "#737c82" : "#ffffff"); + dishGrad.addColorStop(1, styleNight ? "#242a30" : "#adb8bd"); + ctx.fillStyle = dishGrad; + ctx.strokeStyle = styleNight ? "#12181d" : "#67767e"; + ctx.lineWidth = Math.max(2, this.r * 0.06); + ctx.beginPath(); + ctx.ellipse(this.r * 0.62, this.r * 0.42, this.r * 0.83, this.r * 0.39, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = loaded ? "rgba(255,245,209,0.96)" : "rgba(230,235,238,0.48)"; + ctx.beginPath(); + ctx.ellipse(this.r * 0.62, this.r * 0.32, this.r * 0.50, this.r * 0.23, -0.08, 0, Math.PI * 2); + ctx.fill(); + + if (loaded) { + ctx.save(); + ctx.translate(this.r * 0.62, this.r * 0.24); + ctx.scale(0.82, 0.82); + drawStoredConsumableSprite(ctx, this.storedFoodType || "food", Math.max(7, this.r * 0.35), this.seed + 241); + ctx.restore(); + } else { + ctx.fillStyle = "rgba(72,80,86,0.62)"; + ctx.font = `bold ${Math.max(10, Math.round(this.r * 0.34))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("?", this.r * 0.62, this.r * 0.28); + } + + ctx.fillStyle = styleNight ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.45)"; + ctx.beginPath(); + ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } else if (this.type === "signboard") { + ctx.save(); + const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12); + const boardHalfW = this.r * 1.20; + boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8"); + boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6"); + boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397"); + ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87"; + ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444"; + ctx.lineWidth = 2.2; + roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = boardGrad; + ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48"; + roundedRect(ctx, -boardHalfW, -this.r * 1.28, boardHalfW * 2, this.r * 1.38, this.r * 0.18); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)"; + ctx.lineWidth = 1.1; + for (let i = 0; i < 3; i++) { + const y = -this.r * 1.02 + i * this.r * 0.36; + ctx.beginPath(); + ctx.moveTo(-boardHalfW * 0.82, y); + ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, boardHalfW * 0.82, y + Math.cos(this.seed + i) * 2); + ctx.stroke(); + } + ctx.fillStyle = "rgba(255,255,255,0.28)"; + roundedRect(ctx, -boardHalfW * 0.82, -this.r * 1.10, boardHalfW * 1.64, this.r * 0.20, this.r * 0.08); + ctx.fill(); + const raw = String(this.text || "").replace(/\r/g, "").trim(); + if (raw) { + const charsPerLine = 6; + const lines = []; + for (const rawLine of raw.split("\n")) { + let rest = rawLine.trim(); + if (!rest && lines.length < 3) { lines.push(""); continue; } + while (rest && lines.length < 3) { + const chars = Array.from(rest); + lines.push(chars.slice(0, charsPerLine).join("")); + rest = chars.slice(charsPerLine).join(""); + } + if (lines.length >= 3) break; + } + ctx.fillStyle = styleNight ? "rgba(37,31,24,0.95)" : "rgba(55,37,22,0.96)"; + ctx.font = `bold ${Math.max(11, Math.round(this.r * 0.39))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const startY = -this.r * 0.66 - (lines.length - 1) * this.r * 0.16; + lines.forEach((line, index) => ctx.fillText(line, 0, startY + index * this.r * 0.34)); + } else { + ctx.fillStyle = styleNight ? "rgba(55,45,33,0.58)" : "rgba(91,64,35,0.50)"; + ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("\u2026", 0, -this.r * 0.58); + } + ctx.restore(); + } else if (this.type === "ant_nest") { + const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); + ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); + ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = styleNight ? "#211a17" : "#30231d"; + ctx.beginPath(); + ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(38,37,35,0.88)"; + for (let i = 0; i < Math.min(6, count); i++) { + const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0); + const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55); + ctx.beginPath(); + ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(255,248,220,0.82)"; + ctx.font = "bold 10px ui-rounded, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(`${count}`, 0, this.r * 1.25); + } else if (this.type === "ant_corpse") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker"); + ctx.save(); + ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26); + ctx.scale(1, -1); + ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1); + if (img) { + const w = this.r * 3.9; + const metrics = getImageMetrics("ant_worker"); + const h = w * (metrics?.ratio || 0.52); + ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h); + } else { + ctx.fillStyle = "rgba(42,39,35,0.85)"; + ctx.beginPath(); + ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } else if (this.type === "ball") { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + const moving = clamp(speed / 260, 0, 1); + ctx.save(); + ctx.rotate(this.spin || 0); + ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)"; + ctx.shadowBlur = 2 + moving * 3; + ctx.shadowOffsetY = 2; + ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("\u26bd", 0, 1); + if (moving > 0.55) { + ctx.globalAlpha = 0.14 + moving * 0.10; + ctx.shadowColor = "transparent"; + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } else if (this.type === "firecracker") { + const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1); + const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0; + ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12); + ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43"; + ctx.strokeStyle = "rgba(96,48,34,0.78)"; + ctx.lineWidth = 2; + roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#f4d15b"; + ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20); + ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20); + ctx.strokeStyle = "rgba(74,55,39,0.82)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.moveTo(0, -this.r * 0.88); + ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42); + ctx.stroke(); + ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)"; + ctx.beginPath(); + ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(42,36,29,0.66)"; + ctx.font = "bold 10px ui-rounded, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42); + } else if (this.type === "fence_v" || this.type === "fence_h") { + const vertical = this.type === "fence_v"; + const len = Math.max(112, this.r * 3.55); + const thick = Math.max(10, this.r * 0.31); + ctx.save(); + ctx.rotate(vertical ? 0 : Math.PI / 2); + const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"); + const edge = styleNight ? "#4d3d2f" : "#5f3f25"; + const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)"; + ctx.fillStyle = wood; + ctx.strokeStyle = edge; + ctx.lineWidth = 2.4; + ctx.beginPath(); + roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = hi; + ctx.beginPath(); + roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3); + ctx.fill(); + ctx.restore(); + } else if (this.type === "zunchi") { + const zunchiId = this.zunchiVariant || "zunchi"; + const img = images.get(zunchiId) || images.get("zunchi"); + if (img) { + const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi"); + const ratio = metrics?.ratio || 178 / 236; + const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46); + ctx.globalAlpha *= stageAlpha; + ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio); + } + } else if (this.type === "trace") { + ctx.fillStyle = "rgba(122, 100, 76, 0.12)"; + ctx.strokeStyle = "rgba(122, 100, 76, 0.16)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } else if (this.type === "splat") { + const fade = clamp(this.amount / 260, 0, 1); + ctx.globalAlpha = visibleAlpha * fade; + ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`; + ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`; + ctx.lineWidth = 1.8; + const blobs = [ + [-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5] + ]; + for (const [bx, by, bw, bh] of blobs) { + ctx.beginPath(); + ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + for (let i = 0; i < 7; i++) { + ctx.beginPath(); + ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + } +}); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js new file mode 100644 index 0000000..c856f0e --- /dev/null +++ b/js/item_type_initializers.js @@ -0,0 +1,115 @@ +"use strict"; + +// Type-specific Item constructor state. Kept out of items.js so construction stays focused on shared fields. + +function initializeItemTypeState(item, type, x, y) { + if (!item) return item; + if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true; + if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true; + if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; + if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true; + if (["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(type)) item.roles.danger = true; + if (type === "grass") item.roles.grassMaterial = true; + if (type === "grass") { + item.grassStage = Math.floor(rand(0, 2.999)); + setGrassStage(item, item.grassStage, { force: true }); + item.seedTimer = rand(18, 38); + item.eatenAmount = 0; + item.fertilityBoost = 0; + item.lifeSpan = rand(420, 860); + item.wither = 0; + } + if (type === "bed") { + item.comfort = rand(0.86, 1.14); + item.wear = 0; + } + if (type === "signboard") { + item.text = ""; + item.textEditedAt = -999; + } + if (type === "duplicator") { + item.storedFoodType = ""; + item.storedFoodLabel = ""; + item.roles.food = false; + item.roles.duplicator = true; + item.needEffects.food = 55; + item.amount = 999; + } + if (type === "ball") { + item.vx = rand(-5, 5); + item.vy = rand(-5, 5); + item.prevX = x; + item.prevY = y; + item.spin = rand(0, Math.PI * 2); + item.spinVelocity = rand(-1.6, 1.6); + item.lastKickedAt = -999; + item.lastKickerId = ""; + item.lastPokedAt = -999; + item.pokeCombo = 0; + item.lastPokeAngle = rand(0, Math.PI * 2); + } + if (type === "zunchi") { + item.amount = Math.max(Number(item.amount || 0) || 0, 240); + item.stage = "fresh"; + item.stageTimer = 0; + item.fertility = 0; + item.freshness = 1; + item.spawnGrace = 10.0; + item.zunchiVariant = stableUnit(item.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; + } + if (type === "ant_nest") { + item.antCount = ANT_NEST_START_COUNT || 6; + item.antSpawnTimer = rand(0.6, 2.2); + item.antSpawnCooldown = rand(0.8, 2.4); + item.antWorkers = typeof buildAntWorkerPool === "function" + ? buildAntWorkerPool(item.antCount) + : Array.from({ length: item.antCount }, () => ANT_WORKER_HP || 32); + item.queenSpawnAt = -999; + } + if (type === "ant_corpse") { + item.amount = 24; + item.workerSprite = "ant_worker"; + item.decayTimer = 0; + } + if (type === "firecracker") { + item.fuseTimer = 5; + item.fuseMax = 5; + } + if (type === "genkotsu") { + item.amount = 100; + item.dropMax = 0; + item.dropImpactDone = false; + item.impactFlash = 0; + item.lingerTimer = 0; + item.lingerDuration = 2.0; + } + if (isPinType(type)) { + item.amount = 100; + item.vx = rand(-120, 120); + item.vy = rand(-90, 90); + item.prevX = x; + item.prevY = y; + item.spin = rand(-0.35, 0.35); + item.spinVelocity = rand(-5.4, 5.4); + item.pinState = "loose"; + item.pinTargetId = ""; + item.pinAttachAngle = 0; + item.pinAttachDistance = 0; + item.pinOffsetY = 0; + item.pinDamageTick = 0; + item.pinFallCheckTimer = 0; + item.pinLogAt = -999; + item.deletable = true; + } + if (isServingFoodType(type)) { + item.toolSize = "medium"; + item.foodServingScale = 1; + item.foodServingsMax = foodServingsForSize(item.toolSize); + item.foodServingsRemaining = item.foodServingsMax; + item.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval()); + item.amount = item.foodServingsRemaining; + } + return item; +} + +if (typeof window !== "undefined") window.initializeItemTypeState = initializeItemTypeState; diff --git a/js/item_update_scheduler.js b/js/item_update_scheduler.js new file mode 100644 index 0000000..cf1f210 --- /dev/null +++ b/js/item_update_scheduler.js @@ -0,0 +1,200 @@ +"use strict"; + +(function (global) { + const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]); + const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); + const SCHEDULED_ITEM_TYPES = [ + "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", + "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", + "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", + "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "water_bowl", + ]; + + function itemUpdateInterval(item) { + if (!item || item.dead) return Infinity; + if (REALTIME_ITEM_TYPES.has(item.type)) return 0; + if (PIN_ITEM_TYPES.has(item.type)) return 0; + if (item.isStructure && item.type === "plushie") return 0; + if (item.isStructure && item.type === "grass_bed") return 3.0; + if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; + if (item.type === "zunchi") return 2.4; + if (item.type === "grass") return Infinity; + if (item.type === "duplicator") return 2.0; + if (item.type === "water") return 1.8; + if (item.type === "trace" || item.type === "splat") return 1.2; + if (item.type === "ant_corpse") return 3.5; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; + return Infinity; + } + + function heapPush(heap, entry) { + heap.push(entry); + let i = heap.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (heap[p].time <= entry.time) break; + heap[i] = heap[p]; + i = p; + } + heap[i] = entry; + } + + function heapPop(heap) { + if (!heap.length) return null; + const root = heap[0]; + const last = heap.pop(); + if (heap.length && last) { + let i = 0; + while (true) { + let c = i * 2 + 1; + if (c >= heap.length) break; + if (c + 1 < heap.length && heap[c + 1].time < heap[c].time) c++; + if (heap[c].time >= last.time) break; + heap[i] = heap[c]; + i = c; + } + heap[i] = last; + } + return root; + } + + function schedulerSignature(worldRef) { + return [ + (worldRef.items || []).length, + worldRef.itemBucketRebuildsTotal || 0, + worldRef.itemBucketsDirty ? 1 : 0, + ].join(":"); + } + + function ensureBuckets(worldRef) { + if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler"); + } + + function candidateItems(worldRef) { + if (!worldRef?.itemsOfType) return (worldRef.items || []).filter(it => it && !it.dead && it.type !== "grass"); + const out = []; + for (const type of SCHEDULED_ITEM_TYPES) { + const bucket = worldRef.itemsOfType(type) || []; + for (const it of bucket) if (it && !it.dead) out.push(it); + } + return out; + } + + function scheduleItem(state, item, now, interval = itemUpdateInterval(item)) { + if (!item || item.dead || !Number.isFinite(interval)) return false; + if (interval <= 0) { + state.realtime.push(item); + item._schedulerRealtime = true; + return true; + } + const token = (item._schedulerToken || 0) + 1; + item._schedulerToken = token; + item._schedulerRealtime = false; + if (!Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now; + heapPush(state.heap, { time: now + interval, item, token }); + return true; + } + + function rebuild(worldRef, reason = "manual") { + ensureBuckets(worldRef); + const now = worldRef.time || 0; + const state = { + heap: [], + realtime: [], + signature: schedulerSignature(worldRef), + rebuilds: ((worldRef._itemUpdateScheduler?.rebuilds || 0) + 1), + reason, + }; + for (const item of candidateItems(worldRef)) { + item._schedulerLastAt = Number.isFinite(item._schedulerLastAt) ? item._schedulerLastAt : now; + scheduleItem(state, item, now, itemUpdateInterval(item)); + } + worldRef._itemUpdateScheduler = state; + worldRef._itemUpdateRealtime = state.realtime; + return state; + } + + function ensure(worldRef) { + const sig = schedulerSignature(worldRef); + if (!worldRef._itemUpdateScheduler || worldRef._itemUpdateScheduler.signature !== sig) return rebuild(worldRef, "signature"); + return worldRef._itemUpdateScheduler; + } + + function runRealtime(worldRef, state, dt) { + let ran = 0; + const now = worldRef.time || 0; + const nextRealtime = []; + for (const item of state.realtime || []) { + if (!item || item.dead) continue; + const interval = itemUpdateInterval(item); + if (!Number.isFinite(interval)) { + item._schedulerRealtime = false; + continue; + } + if (interval <= 0) { + item.update(dt, worldRef); + item._schedulerLastAt = now; + ran += 1; + if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item); + else scheduleItem(state, item, now, itemUpdateInterval(item)); + } else { + scheduleItem(state, item, now, interval); + } + } + state.realtime = nextRealtime; + worldRef._itemUpdateRealtime = state.realtime; + return ran; + } + + function runDue(worldRef, state) { + let ran = 0; + const now = worldRef.time || 0; + const maxDue = Math.max(24, Math.ceil((state.heap.length || 0) * 0.08)); + let dueRuns = 0; + while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) { + const entry = heapPop(state.heap); + const item = entry?.item; + if (!item || item.dead || item._schedulerToken !== entry.token) continue; + const interval = itemUpdateInterval(item); + if (!Number.isFinite(interval)) continue; + if (interval <= 0) { + state.realtime.push(item); + item._schedulerRealtime = true; + continue; + } + const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval))); + item.update(elapsed, worldRef); + item._schedulerLastAt = now; + ran += 1; + dueRuns += 1; + scheduleItem(state, item, now, itemUpdateInterval(item)); + } + return ran; + } + + function run(worldRef, dt) { + const end = global.TarinaiPerf?.begin?.("update.items") || null; + try { + const state = ensure(worldRef); + let ran = runRealtime(worldRef, state, dt); + ran += runDue(worldRef, state); + state.signature = schedulerSignature(worldRef); + worldRef._itemUpdateSchedulerStats = { + heap: state.heap.length, + realtime: state.realtime.length, + rebuilds: state.rebuilds, + lastReason: state.reason, + lastRan: ran, + }; + return ran; + } finally { + if (end) end(); + } + } + + function trackedDynamicItems(worldRef) { + return worldRef?._itemUpdateRealtime || []; + } + + global.TarinaiItemUpdateScheduler = Object.freeze({ run, rebuild, ensure, itemUpdateInterval, trackedDynamicItems }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_visual_registry.js b/js/item_visual_registry.js deleted file mode 100644 index 5c29984..0000000 --- a/js/item_visual_registry.js +++ /dev/null @@ -1,144 +0,0 @@ -"use strict"; - -(function (global) { - const raw = String.raw`{ - "water": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"] - }, - "zunda_juice": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"] - }, - "mercury": { - "renderer": "oval_droplet", - "shape": "horizontal_oval_liquid", - "palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] - }, - "water_bowl": { - "renderer": "water_bowl", - "shape": "bowl_with_horizontal_oval_water", - "palette": { - "bowlFill": "rgba(171,120,76,0.72)", - "bowlStroke": "rgba(94,69,48,0.55)", - "waterFill": "rgba(86,157,224,0.58)", - "waterStroke": "rgba(62,113,178,0.36)", - "highlight": "rgba(255,255,255,0.54)" - }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"] - }, - "sleep_drug": { - "renderer": "sleep_tablet", - "shape": "moon_tablet", - "palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "moon", "purple"] - }, - "laxative": { - "renderer": "laxative_tablet", - "shape": "rounded_tablet_zunchi_motif", - "palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "zunchi motif", "tan"] - }, - "mystery_drug": { - "renderer": "mystery_tablet", - "shape": "question_mark_tablet", - "palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["tablet", "question mark", "purple"] - }, - "ammo": { - "renderer": "bullet", - "shape": "bullet", - "palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["bullet", "gold"] - }, - "protein": { - "renderer": "powder_pile", - "shape": "powder_pile", - "palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["powder", "orange"] - }, - "niteropu": { - "renderer": "powder_pile", - "shape": "powder_pile", - "palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["powder", "purple"] - }, - "giant_drug": { - "renderer": "split_pill", - "shape": "split_pill", - "palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" }, - "scale": 1.08, - "uiPreviewScale": 1.08, - "fieldScale": 1.08, - "accessibleShapeTraits": ["pill", "large", "orange"] - }, - "dwarf_drug": { - "renderer": "split_pill", - "shape": "split_pill", - "palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" }, - "scale": 0.92, - "uiPreviewScale": 0.92, - "fieldScale": 0.92, - "accessibleShapeTraits": ["pill", "small", "purple"] - }, - "ball": { - "renderer": "emoji", - "shape": "soccer_ball", - "palette": {}, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["round", "soccer ball"], - "emojiFallback": "\u26bd" - } - }`; - - const defs = Object.freeze(JSON.parse(raw)); - - function itemVisualDefinition(type = "") { - return defs[String(type || "")] || null; - } - - function itemVisualPalette(type = "") { - return itemVisualDefinition(type)?.palette || null; - } - - global.TARINAI_ITEM_VISUALS = defs; - global.itemVisualDefinition = itemVisualDefinition; - global.itemVisualPalette = itemVisualPalette; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/items.js b/js/items.js index dc5ea42..a217b91 100644 --- a/js/items.js +++ b/js/items.js @@ -1,11 +1,12 @@ -// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data +// Item construction and shared item helpers. Type-specific initialization is in item_type_initializers.js. "use strict"; -const FOOD_SERVING_TYPES = new Set(window.TarinaiFoodRegistry?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); -const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiFoodRegistry?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); +const FOOD_SERVING_TYPES = new Set(window.TarinaiItemRegistry?.food?.servingTypes?.() || ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); +const PARAM_EFFECT_ITEM_TYPES = new Set(window.TarinaiItemRegistry?.food?.paramEffectTypes?.() || ["laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]); function isParamEffectItemType(type = "") { return PARAM_EFFECT_ITEM_TYPES.has(type); } + const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 }; function foodServingsForSize(size = "medium") { return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium; @@ -15,6 +16,78 @@ function isServingFoodType(type = "") { } const PASSIVE_FOOD_DECAY_PER_SECOND = 0.004; const PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING = 0.055; + +const GRASS_STAGE_COUNT = 5; +const GRASS_STAGE_AMOUNTS = [24, 48, 72, 96, 120]; +const GRASS_STAGE_GROWTH = [0.18, 0.36, 0.54, 0.72, 0.88]; +function clampGrassStage(stage = 0) { + return clamp(Math.round(Number(stage) || 0), 0, GRASS_STAGE_COUNT - 1); +} +function grassStageFromGrowth(growth = 0.18, amount = null) { + if (Number.isFinite(amount) && amount <= 0) return 0; + if (Number.isFinite(amount) && amount > 0) { + for (let i = GRASS_STAGE_AMOUNTS.length - 1; i >= 0; i--) { + if (amount >= GRASS_STAGE_AMOUNTS[i] - 4) return i; + } + } + const g = clamp(Number(growth) || 0, 0, GRASS_STAGE_GROWTH[GRASS_STAGE_GROWTH.length - 1]); + let best = 0, bestD = Infinity; + for (let i = 0; i < GRASS_STAGE_GROWTH.length; i++) { + const d = Math.abs(GRASS_STAGE_GROWTH[i] - g); + if (d < bestD) { best = i; bestD = d; } + } + return best; +} +function grassAmountForStage(stage = 0) { + return GRASS_STAGE_AMOUNTS[clampGrassStage(stage)] || GRASS_STAGE_AMOUNTS[0]; +} +function grassGrowthForStage(stage = 0) { + return GRASS_STAGE_GROWTH[clampGrassStage(stage)] || GRASS_STAGE_GROWTH[0]; +} +function setGrassStage(item, stage = 0, opts = {}) { + if (!item || item.type !== "grass") return false; + const oldStage = Number.isFinite(item.grassStage) ? clampGrassStage(item.grassStage) : grassStageFromGrowth(item.growth, item.amount); + if (stage < 0) { + item.grassStage = 0; + item.growth = 0; + item.amount = 0; + return oldStage !== -1 || opts.force; + } + const nextStage = clampGrassStage(stage); + item.grassStage = nextStage; + item.growth = grassGrowthForStage(nextStage); + item.health = 1; + item.wither = 0; + item.fertilityBoost = 0; + item.seedTimer = Math.max(Number(item.seedTimer || 0), 12); + item.amount = grassAmountForStage(nextStage); + return opts.force || nextStage !== oldStage; +} +function normalizeGrassStage(item) { + if (!item || item.type !== "grass") return 0; + const stage = Number.isFinite(item.grassStage) ? clampGrassStage(item.grassStage) : grassStageFromGrowth(item.growth, item.amount); + setGrassStage(item, stage, { force: true }); + return stage; +} +function advanceGrassStage(item, steps = 1) { + if (!item || item.type !== "grass" || item.dead || (item.amount || 0) <= 0) return false; + const stage = normalizeGrassStage(item); + if (stage >= GRASS_STAGE_COUNT - 1) return false; + return setGrassStage(item, stage + Math.max(1, Math.floor(steps || 1))); +} +function regressGrassStage(item, steps = 1) { + if (!item || item.type !== "grass" || item.dead || (item.amount || 0) <= 0) return 0; + const stage = normalizeGrassStage(item); + const before = grassAmountForStage(stage); + const next = stage - Math.max(1, Math.floor(steps || 1)); + if (next < 0) { + setGrassStage(item, -1); + return before; + } + setGrassStage(item, next); + return Math.max(1, before - grassAmountForStage(next)); +} + 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; @@ -23,7 +96,7 @@ function passiveFoodDecayInterval(worldRef = null) { function passiveFoodDecayRate(item) { const size = item?.toolSize || "medium"; const sizeMul = size === "small" ? 0.74 : (size === "large" ? 1.32 : 1); - const typeMul = window.TarinaiFoodRegistry?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1)); + const typeMul = window.TarinaiItemRegistry?.food?.passiveDecayMultiplier?.(item?.type) ?? (item?.type === "sweet" ? 0.82 : (item?.type === "zunda_juice" ? 1.12 : 1)); return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; } @@ -413,6 +486,16 @@ if (typeof window !== "undefined") { window.drawConsumableFieldSprite = drawConsumableFieldSprite; window.drawStoredConsumableSprite = drawStoredConsumableSprite; window.drawToolItemPreview = drawToolItemPreview; + window.TarinaiGrass = { + stageCount: GRASS_STAGE_COUNT, + normalize: normalizeGrassStage, + setStage: setGrassStage, + advance: advanceGrassStage, + regress: regressGrassStage, + amountForStage: grassAmountForStage, + growthForStage: grassGrowthForStage, + stageFromGrowth: grassStageFromGrowth, + }; } @@ -428,1318 +511,18 @@ class Item { this.seed = Math.random() * 1000; this.roles = {}; this.needEffects = { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; - if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) this.roles.food = true; - if (type === "water" || type === "water_bowl" || type === "zunda_juice") this.roles.drink = true; - if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) this.roles.medicine = true; - if (type === "bed" || type === "nest_box") this.roles.sleepPlace = true; - if (["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(type)) this.roles.danger = true; - if (type === "grass") this.roles.grassMaterial = true; this.dropTimer = 0; this.dropMax = 0; this.dropImpactDone = false; this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55); this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0); - if (type === "grass") { - this.growth = rand(0.18, 0.42); - this.health = 1; - this.seedTimer = rand(18, 38); - this.eatenAmount = 0; - this.fertilityBoost = 0; - this.lifeSpan = rand(420, 860); - this.wither = 0; - } - if (type === "bed") { - this.comfort = rand(0.86, 1.14); - this.wear = 0; - } - if (type === "signboard") { - this.text = ""; - this.textEditedAt = -999; - } - if (type === "duplicator") { - this.storedFoodType = ""; - this.storedFoodLabel = ""; - this.roles.food = false; - this.roles.duplicator = true; - this.needEffects.food = 55; - this.amount = 999; - } - if (type === "ball") { - this.vx = rand(-5, 5); - this.vy = rand(-5, 5); - this.prevX = x; - this.prevY = y; - this.spin = rand(0, Math.PI * 2); - this.spinVelocity = rand(-1.6, 1.6); - this.lastKickedAt = -999; - this.lastKickerId = ""; - this.lastPokedAt = -999; - this.pokeCombo = 0; - this.lastPokeAngle = rand(0, Math.PI * 2); - } - if (type === "zunchi") { - this.amount = Math.max(Number(this.amount || 0) || 0, 240); - this.stage = "fresh"; - this.stageTimer = 0; - this.fertility = 0; - this.freshness = 1; - this.spawnGrace = 10.0; - this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; - } - if (type === "ant_nest") { - this.antCount = ANT_NEST_START_COUNT || 6; - this.antSpawnTimer = rand(0.6, 2.2); - this.antSpawnCooldown = rand(0.8, 2.4); - this.antWorkers = typeof buildAntWorkerPool === "function" - ? buildAntWorkerPool(this.antCount) - : Array.from({ length: this.antCount }, () => ANT_WORKER_HP || 32); - this.queenSpawnAt = -999; - } - if (type === "ant_corpse") { - this.amount = 24; - this.workerSprite = "ant_worker"; - this.decayTimer = 0; - } - if (type === "firecracker") { - this.fuseTimer = 5; - this.fuseMax = 5; - } - if (type === "genkotsu") { - this.amount = 100; - this.dropMax = 0; - this.dropImpactDone = false; - this.impactFlash = 0; - this.lingerTimer = 0; - this.lingerDuration = 2.0; - } - if (isPinType(type)) { - this.amount = 100; - this.vx = rand(-120, 120); - this.vy = rand(-90, 90); - this.prevX = x; - this.prevY = y; - this.spin = rand(-0.35, 0.35); - this.spinVelocity = rand(-5.4, 5.4); - this.pinState = "loose"; - this.pinTargetId = ""; - this.pinAttachAngle = 0; - this.pinAttachDistance = 0; - this.pinOffsetY = 0; - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.pinLogAt = -999; - this.deletable = true; - } - if (isServingFoodType(type)) { - this.toolSize = "medium"; - this.foodServingScale = 1; - this.foodServingsMax = foodServingsForSize(this.toolSize); - this.foodServingsRemaining = this.foodServingsMax; - this.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval()); - this.amount = this.foodServingsRemaining; - } - } - update(dt, worldRef = world) { - this.age += dt; - if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt); - if (this.dropTimer > 0) { - const beforeDrop = this.dropTimer; - this.dropTimer = Math.max(0, this.dropTimer - dt); - if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) { - this.dropImpactDone = true; - if (worldRef.itemDropImpact) worldRef.itemDropImpact(this); - } - } - if (this.type === "water") this.amount -= dt * 0.9; - 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; - const penalty = window.TarinaiFoodRegistry?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING; - worldRef?.registerFoodSpoilage?.(lost * penalty, this); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); - if (this.foodServingsRemaining <= 0.015) this.amount = 0; - } - } else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) { - const before = this.amount || 0; - const lost = Math.min(before, interval * 0.12); - this.amount -= lost; - if (lost > 0) { - const penalty = window.TarinaiFoodRegistry?.hygienePenalty?.(this.type) ?? 0.002; - worldRef?.registerFoodSpoilage?.(lost * penalty, this); - worldRef?.markTerrainDirty?.("food-passive-decay"); - worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true }); - } - } - } - } - if (this.type === "trace") this.amount -= dt * 1.35; - if (this.type === "splat") this.amount -= dt * 1.05; - if (this.type === "ant_corpse") this.amount -= dt * 0.018; - if (this.type === "firecracker") { - this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt); - if (this.fuseTimer <= 0) { - if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this); - else this.amount = 0; - } - } - if (this.type === "genkotsu") { - this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt); - 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 === "duplicator") this.updateDuplicator(dt, worldRef); - if (isPinType(this.type)) this.updatePushpin(dt, worldRef); - if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); - if (this.type === "grass") { - this.lifecycleTimer += dt; - const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1; - const interval = (this.lifecycleInterval || 1.9) * factor; - if (this.lifecycleTimer >= interval) { - const lifecycleDt = Math.min(this.lifecycleTimer, 4.8); - this.lifecycleTimer = 0; - this.updateGrass(lifecycleDt, worldRef); - } - } else if (this.type === "zunchi") { - this.lifecycleTimer += dt; - const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45); - if (this.lifecycleTimer >= interval) { - const lifecycleDt = Math.min(this.lifecycleTimer, 2.4); - this.lifecycleTimer = 0; - this.updateZunchi(lifecycleDt, worldRef); - } - } - } + if (typeof initializeItemTypeState === "function") initializeItemTypeState(this, type, x, y); - - updateDuplicator(dt, worldRef) { - this.amount = 999; - this.roles.food = Boolean(this.storedFoodType); - if (!worldRef?.nearbyItems) return; - const candidates = worldRef.nearbyItems(this.x, this.y, Math.max(74, (this.r || 34) * 2.4)); - let best = null, bestD = Infinity; - for (const it of candidates) { - if (!it || it === this || it.dead) continue; - if (typeof isServingFoodType !== "function" || !isServingFoodType(it.type)) continue; - if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; - const d = distXY(this.x, this.y, it.x, it.y); - if (d < bestD && d <= Math.max(58, (this.r || 34) + (it.r || 14) + 18)) { best = it; bestD = d; } - } - if (!best) return; - const newType = best.type; - const def = typeof itemDefinition === "function" ? itemDefinition(newType) : null; - const newLabel = def?.label || newType; - const changed = this.storedFoodType !== newType; - if (!changed && this.storedFoodType) return; - this.storedFoodType = newType; - this.storedFoodLabel = newLabel; - this.roles.food = true; - this.loadedAt = worldRef.time || 0; - best.amount = 0; - best.foodServingsRemaining = 0; - // Item.dead is a getter based on amount. Do not assign to it. - worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed }); - worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food"); - } - updateZunchiMotion(dt, worldRef) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } - if (window.TarinaiPhysics?.applyKinematicItemMotion) { - window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); - } else { - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } - const slow = Math.pow(0.68, dt * 2.4); - this.vx *= slow; - this.vy *= slow; - worldRef.drawListDirty = true; - } - this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed); - this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); - } - - resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { - if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return; - const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); - for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) { - if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; - const d = distXY(this.x, this.y, t.x, t.y); - if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue; - const now = worldRef.time || 0; - if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue; - this.lastHitTarinaiAt = this.lastHitTarinaiAt || {}; - this.lastHitTarinaiAt[t.id] = now; - const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); - const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); - const impulse = clamp(speed * 1.15, 150, 620); - t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); - t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); - t.fallTimer = Math.max(t.fallTimer || 0, 0.85); - t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); - t.fallDir = nx < 0 ? -1 : 1; - if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); - else { - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true }); - if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); - } - worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); - worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); - this.vx *= -0.18; - this.vy *= -0.18; - break; - } - } - - updateBall(dt, worldRef) { - this.prevX = this.x; - this.prevY = this.y; - const moving = Math.hypot(this.vx || 0, this.vy || 0); - if (moving > 0.04) { - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; } - this.resolveBallObstacleCollisions(dt, worldRef); - this.x = clamp(this.x, p, worldRef.w - p); - this.y = clamp(this.y, p, worldRef.h - p); - const groundFriction = Math.pow(0.72, dt); - this.vx *= groundFriction; - this.vy *= groundFriction; - } - this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18); - this.spinVelocity *= Math.pow(0.65, dt); - if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; } - } - - resolveBallObstacleCollisions(dt, worldRef) { - if (!worldRef?.nearbyItems) return; - const speed = Math.hypot(this.vx || 0, this.vy || 0); - const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); - const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || []; - for (const it of items) { - if (!it || it === this || it.dead) continue; - if (it.type === "bed") { - this.applyBallHayDrag(it, dt, worldRef); - } else if (it.type === "stone") { - this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef); - } - const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; - if (!rects.length) continue; - for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it); - } - } - - applyBallHayDrag(bed, dt, worldRef) { - const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40); - const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34); - const dx = this.x - bed.x; - const dy = this.y - bed.y; - const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; - if (!inside) return; - const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016)); - this.vx *= slow; - this.vy *= slow; - this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); - if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) { - this.lastHaySlowLogAt = worldRef.time || 0; - worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" })); - } - } - - resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) { - let dx = this.x - obstacle.x; - let dy = this.y - obstacle.y; - let d = Math.hypot(dx, dy); - if (d >= hitRadius) { - const px = this.prevX; - const py = this.prevY; - if (!Number.isFinite(px) || !Number.isFinite(py)) return; - const sx = this.x - px; - const sy = this.y - py; - const len2 = sx * sx + sy * sy; - if (len2 <= 0.0001) return; - const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1); - const cx = px + sx * t; - const cy = py + sy * t; - dx = cx - obstacle.x; - dy = cy - obstacle.y; - d = Math.hypot(dx, dy); - if (d >= hitRadius) return; - if (d < 0.001) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } - else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; } - } - } - if (d < 0.001) { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; } - else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; } - } - const nx = dx / d; - const ny = dy / d; - const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; - this.x = obstacle.x + nx * (hitRadius + 0.5); - this.y = obstacle.y + ny * (hitRadius + 0.5); - if (toward < 0) { - this.vx = (this.vx || 0) - (1 + restitution) * toward * nx; - this.vy = (this.vy || 0) - (1 + restitution) * toward * ny; - } else { - this.vx = (this.vx || 0) + nx * 24; - this.vy = (this.vy || 0) + ny * 24; - } - this.vx *= 0.96; - this.vy *= 0.96; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38); - this.emitBallBounce(worldRef, obstacle); - } - - resolveBallRectBounce(rect, worldRef, source = null) { - if (!rect) return; - const hitRadius = (this.r || 18) + 2; - const cx = clamp(this.x, rect.left, rect.right); - const cy = clamp(this.y, rect.top, rect.bottom); - let dx = this.x - cx; - let dy = this.y - cy; - let d = Math.hypot(dx, dy); - if (d >= hitRadius) { - const px = this.prevX; - const py = this.prevY; - const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius }; - if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return; - let nx = 0; - let ny = 0; - const vx = this.vx || 0; - const vy = this.vy || 0; - const candidates = []; - const sx = this.x - px; - const sy = this.y - py; - if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) }); - if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) }); - if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) }); - if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) }); - if (candidates.length) { - candidates.sort((a, b) => a.t - b.t); - nx = candidates[0].nx; - ny = candidates[0].ny; - } else if (Math.abs(vx) >= Math.abs(vy)) { - nx = vx >= 0 ? -1 : 1; - } else { - ny = vy >= 0 ? -1 : 1; - } - const toward = vx * nx + vy * ny; - if (nx < 0) this.x = expanded.left - 0.5; - else if (nx > 0) this.x = expanded.right + 0.5; - if (ny < 0) this.y = expanded.top - 0.5; - else if (ny > 0) this.y = expanded.bottom + 0.5; - if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom); - if (ny) this.x = clamp(this.x, expanded.left, expanded.right); - if (toward < 0) { - this.vx = vx - 1.78 * toward * nx; - this.vy = vy - 1.78 * toward * ny; - } else { - this.vx = vx + nx * 18; - this.vy = vy + ny * 18; - } - this.vx *= 0.94; - this.vy *= 0.94; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); - this.emitBallBounce(worldRef, source || rect.item || rect); - return; - } - if (d < 0.001) { - const left = Math.abs(this.x - rect.left); - const right = Math.abs(rect.right - this.x); - const top = Math.abs(this.y - rect.top); - const bottom = Math.abs(rect.bottom - this.y); - const m = Math.min(left, right, top, bottom); - if (m === left) { dx = -1; dy = 0; } - else if (m === right) { dx = 1; dy = 0; } - else if (m === top) { dx = 0; dy = -1; } - else { dx = 0; dy = 1; } - d = 1; - } - const nx = dx / d; - const ny = dy / d; - const toward = (this.vx || 0) * nx + (this.vy || 0) * ny; - this.x = cx + nx * (hitRadius + 0.5); - this.y = cy + ny * (hitRadius + 0.5); - if (toward < 0) { - this.vx = (this.vx || 0) - 1.78 * toward * nx; - this.vy = (this.vy || 0) - 1.78 * toward * ny; - } else { - this.vx = (this.vx || 0) + nx * 18; - this.vy = (this.vy || 0) + ny * 18; - } - this.vx *= 0.94; - this.vy *= 0.94; - this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38); - this.emitBallBounce(worldRef, source || rect.item || rect); - } - - resolveBallFenceBounce(fence, worldRef) { - const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : []; - for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, fence); - } - - emitBallBounce(worldRef, obstacle) { - const now = worldRef?.time || 0; - if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return; - this.lastObstacleBounceAt = now; - worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" })); - } - - updatePushpin(dt, worldRef) { - if (!worldRef) return; - if (this.pinState === "lodged") { - this.updateLodgedPushpin(dt, worldRef); - return; - } - if (window.TarinaiPhysics?.applyKinematicItemMotion) { - window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); - } else { - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - } - this.tryStickPushpin(worldRef); - } - - tryStickPushpin(worldRef) { - if (!worldRef?.tarinai?.length) return false; - if (this.pinState === "lodged") return false; - const now = worldRef.time || 0; - if ((this.noStickUntil || 0) > now) return false; - let best = null; - let bestD = Infinity; - const hitRange = Math.max(10, (this.r || 8) * 1.25); - const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai; - for (const t of candidates) { - if (!t || t.dead) continue; - if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue; - const d = distXY(this.x, this.y, t.x, t.y); - const hit = (t.radius || 16) * 0.86 + hitRange; - if (d <= hit && d < bestD) { best = t; bestD = d; } - } - if (!best) return false; - return this.attachPushpin(best, worldRef, bestD); - } - - attachPushpin(t, worldRef, d = null) { - if (!t || t.dead) return false; - if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - const dx = this.x - t.x; - const dy = this.y - t.y; - const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); - this.pinState = "lodged"; - this.deletable = false; - this.pinTargetId = t.id; - const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -faceDir : faceDir; - this.pinVisualSide = visualSide; - this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide); - this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.vx = 0; - this.vy = 0; - this.spinVelocity = 0; - this.spin = this.pinAttachAngle; - t.stuckPushpinId = this.id; - t.sleeping = false; - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); - if (isOshibyo) { - t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f"; - worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); - return true; - } - if (t.enterPanic) { - t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); - } else { - t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); - t.fearTimer = Math.max(t.fearTimer || 0, 1.3); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); - } - if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); - if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { - this.pinLogAt = worldRef.time || 0; - worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); - } - worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); - return true; - } - - updateLodgedPushpin(dt, worldRef) { - const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null; - if (!t) { - this.detachPushpin(worldRef, "owner-lost"); - return; - } - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - t.stuckPushpinId = this.id; - this.deletable = false; - const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); - const visualSide = isOshibyo ? -faceDir : faceDir; - this.pinVisualSide = visualSide; - this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36; - this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; - this.prevX = this.x; - this.prevY = this.y; - this.spin = visualSide * (isOshibyo ? 0.52 : 0.28); - this.vx = t.vx || 0; - this.vy = t.vy || 0; - if (isOshibyo) { - if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044"; - return; - } - this.pinDamageTick = (this.pinDamageTick || 0) + dt; - while (this.pinDamageTick >= 0.55) { - this.pinDamageTick -= 0.55; - if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); - if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); - } - t.sleeping = false; - if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); - else { - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - t.fearTimer = Math.max(t.fearTimer || 0, 0.65); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); - } - this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; - while (this.pinFallCheckTimer >= 1.0) { - this.pinFallCheckTimer -= 1.0; - if (Math.random() < 0.10) { - this.detachPushpin(worldRef, "fall"); - return; - } - } - } - - detachPushpin(worldRef, reason = "released") { - const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const wasOshibyo = Boolean(behavior?.blocksZunchi); - const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; - if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; - if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; - if (reason === "pinch") { - const now = worldRef?.time || 0; - this.noStickUntil = now + 1.0; - this.noStickTargetId = t?.id || ""; - this.noStickTargetUntil = now + 1.0; - } else if (t) { - const now = worldRef?.time || 0; - this.noStickTargetId = t.id || ""; - this.noStickTargetUntil = now + 0.55; - } - this.pinState = "loose"; - this.pinTargetId = ""; - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.deletable = true; - if (reason === "pinch") { - this.vx = 0; - this.vy = 0; - this.spinVelocity = 0; - } else { - this.vx = rand(-24, 24); - this.vy = rand(-10, 18); - this.spinVelocity = rand(-1.6, 1.6); - this.spin += rand(-0.25, 0.25); - if (t) { - this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30)); - this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30)); - } - worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); - if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); - } - - if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { - worldRef.spawnBurstZunchi(t || this, storedZunchi, this); - } - } - - updateGrass(dt, worldRef) { - const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7; - const weather = worldRef.weather || "sunny"; - const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0; - if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) { - this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28); - this.amount -= dt * 34; - return; - } - let fertility = 0; - let fertileSource = null; - let bestFertility = 0; - let waterNear = weather === "light_rain" ? 0.35 : 0; - const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360; - const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []); - let scanned = 0; - const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999; - for (const it of nearby) { - if (++scanned > scanLimit) break; - if (it === this) continue; - const d = distXY(this.x, this.y, it.x, it.y); - if ((it.type === "trace" || it.type === "splat") && d < 170) { - const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55); - fertility += influence; - if (influence > bestFertility) { bestFertility = influence; fertileSource = it; } - } - if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) { - const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72); - fertility += influence; - if (influence > bestFertility) { - bestFertility = influence; - fertileSource = it; - } - } - if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25; - } - this.fertilityBoost = clamp(fertility, 0, 1.4); - const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08; - const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0; - const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * (2 / 3); - this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88); - this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1); - const grassCanSpread = worldRef.canAddGrass ? worldRef.canAddGrass(1) : ((worldRef.itemCounts?.grass || 0) < (CONFIG.grassLimit ?? 360)); - if (this.growth >= 0.86 && grassCanSpread) { - const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2); - if (spawned > 0) this.growth = rand(0.52, 0.68); - this.seedTimer = rand(9, 17); - } - if (this.age > (this.lifeSpan || 640) * 0.82) { - const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1); - this.wither = fade; - this.health = clamp((this.health ?? 1) - dt * (0.004 + fade * 0.020), 0, 1); - this.growth = clamp((this.growth ?? 0.25) - dt * fade * 0.005, 0, 0.88); - } else { - this.wither = Math.max(0, (this.wither || 0) - dt * 0.08); - } - this.amount = clamp(this.growth * 120 * this.health, 0, 130); - if (this.age > (this.lifeSpan || 640) * 1.05) this.amount -= dt * (1.2 + (this.wither || 0) * 24); - this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * (2 / 3); - if (this.seedTimer <= 0 && this.growth > 0.64) { - this.seedTimer = rand(12, 27); - if ((worldRef.canAddGrass ? worldRef.canAddGrass(1) : ((worldRef.itemCounts?.grass || 0) < (CONFIG.grassLimit ?? 360))) - && Math.random() < 0.62 + this.fertilityBoost * 0.22) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1); - } - } - seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) { - let made = 0; - for (let n = 0; n < maxNew && (worldRef.canAddGrass ? worldRef.canAddGrass(1) : ((worldRef.itemCounts?.grass || 0) < (CONFIG.grassLimit ?? 360))); n++) { - const source = fertile && fertileSource ? fertileSource : this; - const spot = worldRef.findGrassSproutSpot - ? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource)) - : { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) }; - if (!spot) break; - const sprout = new Item("grass", spot.x, spot.y); - sprout.growth = rand(0.08, 0.18); - sprout.amount = sprout.growth * 120; - const added = worldRef.addItem?.(sprout, "grass-seed") || (!worldRef.addItem && worldRef.items.push(sprout) && sprout); - if (!added) break; - made += 1; - } - return made; - } - - updateZunchi(dt, worldRef) { - const rainy = worldRef.weather === "light_rain"; - const rainBoost = rainy ? 1.28 : 1; - const decayBoost = rainy ? 1.35 : 1; - let waterBoost = 0; - const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []); - for (const it of nearby) { - if (it.type !== "water") continue; - waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1); - } - this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65); - const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt; - if (this.stage === "fresh") { - this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); - this.amount -= decayDt * 0.018 * decayBoost; - if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; } - } else if (this.stage === "dry") { - this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55); - this.amount -= decayDt * 0.032 * decayBoost; - if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; } - } else if (this.stage === "decomposing") { - this.fertility = clamp(this.fertility + dt * 0.015, 0, 1); - this.amount -= decayDt * 0.046 * decayBoost; - if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); } - } else if (this.stage === "fertile_soil") { - this.fertility = clamp(1 - this.stageTimer / 360, 0, 1); - this.amount -= decayDt * 0.090 * decayBoost; - } } get dead() { return this.amount <= 0; } - draw(ctx, t, lighting = null) { - const lightState = lighting || getLightingState(world); - const night = clamp(lightState.nightStrength * 1.35, 0, 1); - const warm = lightState.warmth; - const styleNight = lightState.light < 0.42; - const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); - if (isPinType(this.type) && this.pinState === "lodged") { - const owner = world?.liveTarinaiById?.(this.pinTargetId) || null; - if (owner?.insideNestBoxId) return; - } - const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; - const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); - const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; - const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170; - const dropY = dropT > 0 ? -dropFallHeight * dropT : 0; - const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); - if (this.type !== "trace" && this.type !== "splat") { - const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null; - const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null; - if (zunchiImg) { - const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi"); - const ratio = metrics?.ratio || 178 / 236; - const zunchiH = this.r * 1.66 * ratio; - drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, { - alpha: shadow.alpha * visibleAlpha * 1.18, - widthScale: 0.92, - heightScale: 0.92, - }); - } else if (this.type === "grass") { - ctx.save(); - ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13); - ctx.fillStyle = shadow.color; - ctx.beginPath(); - ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } else { - drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 }); - } - } - drawItemGlow(ctx, this, lightState, visibleAlpha); - if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) { - ctx.save(); - ctx.globalAlpha = 0.32; - ctx.strokeStyle = "rgba(255, 250, 220, 0.82)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.restore(); - } - ctx.save(); - ctx.translate(this.x, this.y + dropY); - ctx.globalAlpha = visibleAlpha; - if (isConsumableSpriteType(this.type)) { - drawConsumableFieldSprite(ctx, this.type, this.r, this.seed); - } else if (this.type === "genkotsu") { - const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); - const impact = clamp(this.impactFlash || 0, 0, 1); - ctx.save(); - ctx.shadowColor = "rgba(54,42,31,0.26)"; - ctx.shadowBlur = 5 + impact * 8; - ctx.shadowOffsetY = 4; - const squashY = 1 - impact * 0.08; - const stretchX = 1 + impact * 0.05; - ctx.scale(stretchX, squashY); - if (img) { - const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null; - const w = this.r * 2.55; - const h = w * (metrics?.ratio || 1.27); - ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h); - } else { - ctx.fillStyle = "rgba(255,255,255,0.94)"; - ctx.strokeStyle = "rgba(64,64,64,0.78)"; - ctx.lineWidth = 5; - roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(42,46,52,0.92)"; - ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u6b63", 0, -this.r * 0.74); - ctx.fillText("\u7fa9", 0, -this.r * 0.27); - } - if (impact > 0.01) { - ctx.globalAlpha = impact * 0.20; - ctx.fillStyle = "#ffffff"; - ctx.beginPath(); - ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } else if (isPinType(this.type)) { - const stuck = this.pinState === "lodged"; - const isOshibyo = this.type === "oshibyo"; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); - const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); - ctx.save(); - if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1); - if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); - ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)"; - ctx.shadowBlur = 3; - ctx.shadowOffsetY = 2; - if (img) { - const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; - const ratio = metrics?.ratio || (stuck ? 1 : 2.0); - const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78); - const h = w * ratio; - ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); - } else if (stuck) { - ctx.fillStyle = "#d92736"; - ctx.strokeStyle = "rgba(125,18,30,0.78)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.beginPath(); - ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2); - ctx.fillStyle = "rgba(176,20,30,0.82)"; - ctx.fill(); - } else { - ctx.fillStyle = "#d92736"; - ctx.strokeStyle = "rgba(125,18,30,0.78)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.beginPath(); - ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.strokeStyle = "rgba(210,210,215,0.92)"; - ctx.lineWidth = Math.max(1.5, this.r * 0.12); - ctx.beginPath(); - ctx.moveTo(0, this.r * 0.42); - ctx.lineTo(0, this.r * 1.65); - ctx.stroke(); - } - ctx.restore(); - } else if (this.type === "water_bowl") { - const palette = visualPaletteFor("water_bowl"); - ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; - ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; - ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; - ctx.lineWidth = 1.6; - ctx.beginPath(); - ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; - ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); - } else if (this.type === "water") { - drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); - } else if (this.type === "grass") { - const growth = this.growth ?? clamp(this.amount / 120, 0, 1); - const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; - const eaten = this.eatenAmount > 14 || growth < 0.16; - const grassLoad = 1; - const wither = clamp(this.wither || 0, 0, 1); - ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"))); - ctx.shadowColor = "transparent"; - ctx.shadowBlur = 0; - ctx.lineCap = "round"; - ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2); - const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7; - const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades); - for (let i = 0; i < bladeCount; i++) { - const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70; - const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread); - const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1); - const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0; - ctx.beginPath(); - ctx.moveTo(rootX, 8); - ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78); - ctx.stroke(); - } - if (stage === "mature" && !eaten && grassLoad <= 1) { - ctx.save(); - ctx.globalAlpha = 0.16; - ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)"; - ctx.beginPath(); - ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - } else if (this.type === "stone") { - ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8"); - ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63"; - ctx.shadowColor = "transparent"; - ctx.shadowBlur = 0; - ctx.lineWidth = 2; - roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.34)"; - ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill(); - } else if (this.type === "bed") { - ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" ); - ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" ); - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.strokeStyle = "rgba(116, 89, 42, 0.45)"; - ctx.lineWidth = 1.5; - for (let i = 0; i < 13; i++) { - const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08); - const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46); - const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70); - const a = randSeed(this.seed + i + 60, -0.75, 0.75); - ctx.beginPath(); - ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5); - ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5); - ctx.stroke(); - } - } else if (this.type === "nest_box") { - ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237"); - ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24"; - ctx.lineWidth = 2.6; - roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b"; - roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6); - ctx.fill(); - ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)"; - ctx.lineWidth = 2.0; - ctx.beginPath(); - ctx.moveTo(-this.r * 0.95, -this.r * 0.50); - ctx.lineTo(this.r * 0.95, -this.r * 0.50); - ctx.moveTo(-this.r * 0.95, this.r * 0.48); - ctx.lineTo(this.r * 0.95, this.r * 0.48); - ctx.stroke(); - ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f"; - ctx.beginPath(); - ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); - ctx.fill(); - } else if (this.type === "duplicator") { - ctx.save(); - const loaded = !!this.storedFoodType; - const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9); - bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6"); - bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7"); - bodyGrad.addColorStop(1, styleNight ? "#2c343c" : "#9fafb7"); - ctx.fillStyle = bodyGrad; - ctx.strokeStyle = styleNight ? "#1b252c" : "#52636b"; - ctx.lineWidth = Math.max(2, this.r * 0.075); - roundedRect(ctx, -this.r * 1.10, -this.r * 0.86, this.r * 1.52, this.r * 1.52, this.r * 0.20); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = styleNight ? "rgba(20,28,34,0.52)" : "rgba(255,255,255,0.72)"; - roundedRect(ctx, -this.r * 0.92, -this.r * 0.64, this.r * 1.12, this.r * 0.42, this.r * 0.10); - ctx.fill(); - ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)"; - ctx.stroke(); - - ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)"; - ctx.beginPath(); - ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = "rgba(35,47,54,0.52)"; - ctx.stroke(); - ctx.fillStyle = loaded ? "rgba(126,245,141,0.82)" : "rgba(255,255,255,0.45)"; - ctx.beginPath(); - ctx.arc(-this.r * 0.74, -this.r * 0.47, this.r * 0.045, 0, Math.PI * 2); - ctx.fill(); - - ctx.strokeStyle = styleNight ? "#232b30" : "#657079"; - ctx.lineWidth = Math.max(1.6, this.r * 0.045); - ctx.beginPath(); - ctx.moveTo(this.r * 0.18, -this.r * 0.34); - ctx.quadraticCurveTo(this.r * 0.48, -this.r * 0.52, this.r * 0.74, -this.r * 0.28); - ctx.stroke(); - ctx.fillStyle = styleNight ? "#283139" : "#e6edf0"; - ctx.beginPath(); - ctx.arc(this.r * 0.78, -this.r * 0.28, this.r * 0.10, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - - const dishGrad = ctx.createLinearGradient(this.r * 0.08, -this.r * 0.06, this.r * 1.28, this.r * 0.62); - dishGrad.addColorStop(0, styleNight ? "#3f454b" : "#eef4f4"); - dishGrad.addColorStop(0.58, styleNight ? "#737c82" : "#ffffff"); - dishGrad.addColorStop(1, styleNight ? "#242a30" : "#adb8bd"); - ctx.fillStyle = dishGrad; - ctx.strokeStyle = styleNight ? "#12181d" : "#67767e"; - ctx.lineWidth = Math.max(2, this.r * 0.06); - ctx.beginPath(); - ctx.ellipse(this.r * 0.62, this.r * 0.42, this.r * 0.83, this.r * 0.39, -0.08, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = loaded ? "rgba(255,245,209,0.96)" : "rgba(230,235,238,0.48)"; - ctx.beginPath(); - ctx.ellipse(this.r * 0.62, this.r * 0.32, this.r * 0.50, this.r * 0.23, -0.08, 0, Math.PI * 2); - ctx.fill(); - - if (loaded) { - ctx.save(); - ctx.translate(this.r * 0.62, this.r * 0.24); - ctx.scale(0.82, 0.82); - drawStoredConsumableSprite(ctx, this.storedFoodType || "food", Math.max(7, this.r * 0.35), this.seed + 241); - ctx.restore(); - } else { - ctx.fillStyle = "rgba(72,80,86,0.62)"; - ctx.font = `bold ${Math.max(10, Math.round(this.r * 0.34))}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("?", this.r * 0.62, this.r * 0.28); - } - - ctx.fillStyle = styleNight ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.45)"; - ctx.beginPath(); - ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } else if (this.type === "signboard") { - ctx.save(); - const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12); - const boardHalfW = this.r * 1.20; - boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8"); - boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6"); - boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397"); - ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87"; - ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444"; - ctx.lineWidth = 2.2; - roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = boardGrad; - ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48"; - roundedRect(ctx, -boardHalfW, -this.r * 1.28, boardHalfW * 2, this.r * 1.38, this.r * 0.18); - ctx.fill(); - ctx.stroke(); - ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)"; - ctx.lineWidth = 1.1; - for (let i = 0; i < 3; i++) { - const y = -this.r * 1.02 + i * this.r * 0.36; - ctx.beginPath(); - ctx.moveTo(-boardHalfW * 0.82, y); - ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, boardHalfW * 0.82, y + Math.cos(this.seed + i) * 2); - ctx.stroke(); - } - ctx.fillStyle = "rgba(255,255,255,0.28)"; - roundedRect(ctx, -boardHalfW * 0.82, -this.r * 1.10, boardHalfW * 1.64, this.r * 0.20, this.r * 0.08); - ctx.fill(); - const raw = String(this.text || "").replace(/\r/g, "").trim(); - if (raw) { - const charsPerLine = 6; - const lines = []; - for (const rawLine of raw.split("\n")) { - let rest = rawLine.trim(); - if (!rest && lines.length < 3) { lines.push(""); continue; } - while (rest && lines.length < 3) { - const chars = Array.from(rest); - lines.push(chars.slice(0, charsPerLine).join("")); - rest = chars.slice(charsPerLine).join(""); - } - if (lines.length >= 3) break; - } - ctx.fillStyle = styleNight ? "rgba(37,31,24,0.95)" : "rgba(55,37,22,0.96)"; - ctx.font = `bold ${Math.max(11, Math.round(this.r * 0.39))}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - const startY = -this.r * 0.66 - (lines.length - 1) * this.r * 0.16; - lines.forEach((line, index) => ctx.fillText(line, 0, startY + index * this.r * 0.34)); - } else { - ctx.fillStyle = styleNight ? "rgba(55,45,33,0.58)" : "rgba(91,64,35,0.50)"; - ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u2026", 0, -this.r * 0.58); - } - ctx.restore(); - } else if (this.type === "ant_nest") { - const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); - ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); - ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = styleNight ? "#211a17" : "#30231d"; - ctx.beginPath(); - ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "rgba(38,37,35,0.88)"; - for (let i = 0; i < Math.min(6, count); i++) { - const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0); - const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55); - ctx.beginPath(); - ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2); - ctx.fill(); - } - ctx.fillStyle = "rgba(255,248,220,0.82)"; - ctx.font = "bold 10px ui-rounded, sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(`${count}`, 0, this.r * 1.25); - } else if (this.type === "ant_corpse") { - const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker"); - ctx.save(); - ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26); - ctx.scale(1, -1); - ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1); - if (img) { - const w = this.r * 3.9; - const metrics = getImageMetrics("ant_worker"); - const h = w * (metrics?.ratio || 0.52); - ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h); - } else { - ctx.fillStyle = "rgba(42,39,35,0.85)"; - ctx.beginPath(); - ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } else if (this.type === "ball") { - const speed = Math.hypot(this.vx || 0, this.vy || 0); - const moving = clamp(speed / 260, 0, 1); - ctx.save(); - ctx.rotate(this.spin || 0); - ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)"; - ctx.shadowBlur = 2 + moving * 3; - ctx.shadowOffsetY = 2; - ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u26bd", 0, 1); - if (moving > 0.55) { - ctx.globalAlpha = 0.14 + moving * 0.10; - ctx.shadowColor = "transparent"; - ctx.fillStyle = "#ffffff"; - ctx.beginPath(); - ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } else if (this.type === "firecracker") { - const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1); - const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0; - ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12); - ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43"; - ctx.strokeStyle = "rgba(96,48,34,0.78)"; - ctx.lineWidth = 2; - roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "#f4d15b"; - ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20); - ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20); - ctx.strokeStyle = "rgba(74,55,39,0.82)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.moveTo(0, -this.r * 0.88); - ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42); - ctx.stroke(); - ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)"; - ctx.beginPath(); - ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "rgba(42,36,29,0.66)"; - ctx.font = "bold 10px ui-rounded, sans-serif"; - ctx.textAlign = "center"; - ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42); - } else if (this.type === "fence_v" || this.type === "fence_h") { - const vertical = this.type === "fence_v"; - const len = Math.max(112, this.r * 3.55); - const thick = Math.max(10, this.r * 0.31); - ctx.save(); - ctx.rotate(vertical ? 0 : Math.PI / 2); - const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31"); - const edge = styleNight ? "#4d3d2f" : "#5f3f25"; - const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)"; - ctx.fillStyle = wood; - ctx.strokeStyle = edge; - ctx.lineWidth = 2.4; - ctx.beginPath(); - roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = hi; - ctx.beginPath(); - roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3); - ctx.fill(); - ctx.restore(); - } else if (this.type === "zunchi") { - const zunchiId = this.zunchiVariant || "zunchi"; - const img = images.get(zunchiId) || images.get("zunchi"); - if (img) { - const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi"); - const ratio = metrics?.ratio || 178 / 236; - const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46); - ctx.globalAlpha *= stageAlpha; - ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio); - } - } else if (this.type === "trace") { - ctx.fillStyle = "rgba(122, 100, 76, 0.12)"; - ctx.strokeStyle = "rgba(122, 100, 76, 0.16)"; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - } else if (this.type === "splat") { - const fade = clamp(this.amount / 260, 0, 1); - ctx.globalAlpha = visibleAlpha * fade; - ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`; - ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`; - ctx.lineWidth = 1.8; - const blobs = [ - [-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5] - ]; - for (const [bx, by, bw, bh] of blobs) { - ctx.beginPath(); - ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } - for (let i = 0; i < 7; i++) { - ctx.beginPath(); - ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.restore(); - } + } + +if (typeof window !== "undefined") window.Item = Item; diff --git a/js/main.js b/js/main.js index d9ae4cd..1660a01 100644 --- a/js/main.js +++ b/js/main.js @@ -78,16 +78,22 @@ function loop() { const rawDt = t - last; const dt = clamp(rawDt, 0, 0.05); last = t; + window.TarinaiPerf?.beginFrame?.(); fpsTimer += rawDt; fpsFrames += 1; if (fpsTimer >= 0.5) { window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001)); - window.TarinaiPerformance?.updateFps?.(window.__tarinaiFps); fpsTimer = 0; fpsFrames = 0; } + const endUpdate = window.TarinaiPerf?.begin?.("update.total") || null; world.update(dt); + if (endUpdate) endUpdate(); + const endRender = window.TarinaiPerf?.begin?.("render.total") || null; render(); + if (endRender) endRender(); + window.TarinaiPerf?.endFrame?.(rawDt, window.__tarinaiFps || 0); + if (window.TarinaiPerf?.consumeResizeRequest?.() && typeof resizeCanvas === "function") resizeCanvas(); statsTimer += dt; const statsInterval = 2.5; if (statsTimer > statsInterval) { diff --git a/js/perf_profiler.js b/js/perf_profiler.js new file mode 100644 index 0000000..23beb02 --- /dev/null +++ b/js/perf_profiler.js @@ -0,0 +1,115 @@ +"use strict"; + +(function (global) { + const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now()); + const buckets = new Map(); + let frameOpen = null; + let frameCount = 0; + let lastQualityCheckAt = 0; + let tier = "high"; + let dprScale = 1; + let resizeRequested = false; + + function bucketFor(label) { + let b = buckets.get(label); + if (!b) { + b = { label, ms: 0, avg: 0, max: 0, calls: 0, last: 0 }; + buckets.set(label, b); + } + return b; + } + + function begin(label) { + const start = nowMs(); + return () => { + const elapsed = Math.max(0, nowMs() - start); + const b = bucketFor(label); + b.ms += elapsed; + b.calls += 1; + b.last = elapsed; + if (elapsed > b.max) b.max = elapsed; + return elapsed; + }; + } + + function beginFrame() { + frameOpen = nowMs(); + return frameOpen; + } + + function smoothBuckets() { + for (const b of buckets.values()) { + b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms; + b.ms = 0; + b.calls = 0; + } + } + + function qualityFromFps(fps) { + const f = Number(fps) || 0; + if (f > 55) return { tier: "high", dpr: 1 }; + if (f < 32) return { tier: "low", dpr: 0.72 }; + if (f < 45) return { tier: "mid", dpr: 0.86 }; + return { tier: tier === "low" ? "mid" : tier, dpr: tier === "low" ? 0.86 : dprScale }; + } + + function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) { + frameCount += 1; + if (frameOpen != null) { + const b = bucketFor("frame.total"); + const elapsed = Math.max(0, nowMs() - frameOpen); + b.ms += elapsed; + b.calls += 1; + b.last = elapsed; + if (elapsed > b.max) b.max = elapsed; + frameOpen = null; + } + const t = nowMs(); + if (t - lastQualityCheckAt >= 750) { + lastQualityCheckAt = t; + smoothBuckets(); + const next = qualityFromFps(fps); + const nextDpr = next.dpr; + if (next.tier !== tier || Math.abs(nextDpr - dprScale) > 0.04) { + tier = next.tier; + dprScale = nextDpr; + resizeRequested = true; + } + } + } + + function renderQualityTier() { + return tier; + } + + function dprScaleValue() { + return dprScale; + } + + function consumeResizeRequest() { + const v = resizeRequested; + resizeRequested = false; + return v; + } + + function snapshot() { + const entries = [...buckets.values()].map(b => ({ + label: b.label, + avg: Number((b.avg || 0).toFixed(2)), + max: Number((b.max || 0).toFixed(2)), + last: Number((b.last || 0).toFixed(2)), + calls: b.calls || 0, + })).sort((a, b) => b.avg - a.avg); + return { tier, dprScale, frameCount, entries }; + } + + global.TarinaiPerf = Object.freeze({ + begin, + beginFrame, + endFrame, + renderQualityTier, + dprScale: dprScaleValue, + consumeResizeRequest, + snapshot, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/performance_manager.js b/js/performance_manager.js deleted file mode 100644 index 5b54975..0000000 --- a/js/performance_manager.js +++ /dev/null @@ -1,55 +0,0 @@ -"use strict"; - -// Build 15.6.2-lag-pass: -// Dynamic load-based quality switching was removed. The old implementation -// counted tarinai/ants/items/effects from many hot paths, so the load check -// itself became a source of frame spikes. Keep a tiny compatibility object for -// legacy optional calls, but never scan the world and never change quality. -class TarinaiPerformanceManager { - constructor() { - this.fps = 60; - this.level = 0; - this.lastLevel = 0; - } - - updateFps(fps) { - const n = Number(fps); - if (Number.isFinite(n) && n > 0) this.fps = Math.round(n); - return 0; - } - - quality() { - return TarinaiPerformanceManager.STATIC_QUALITY; - } - - debugSnapshot() { - return { version: window.TARINAI_VERSION || "", fps: this.fps, level: 0, quality: TarinaiPerformanceManager.STATIC_QUALITY }; - } -} - -TarinaiPerformanceManager.STATIC_QUALITY = Object.freeze({ - fps: 60, - pressure: 0, - level: 0, - effectDensity: 1, - tarinaiCount: 0, - antStride: 2, - itemCount: 0, - antCount: 0, - effectCount: 0, - workUnits: 0, - maxDpr: 2, - effectLimit: 96, - aiIntervalScale: 1, - envIntervalScale: 1, - socialScanLimit: 8, - itemScanLimit: 16, - collisionInterval: 1.8, - bedConflictInterval: 3.2, - statsInterval: 1.2, - terrainRedrawInterval: 3.0, - backgroundLightStep: 180, - uiUpdateInterval: 1.2, -}); - -window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager(); diff --git a/js/relation_event_system.js b/js/relation_event_system.js deleted file mode 100644 index 7310956..0000000 --- a/js/relation_event_system.js +++ /dev/null @@ -1,90 +0,0 @@ -"use strict"; - -(function (global) { - const MAX_PENDING_LOSSES = 16; - - - function relationWithDead(observer, dead, worldRef) { - if (!observer || !dead || observer === dead) return ""; - const deadFamily = worldRef?.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); - const observerFamily = observer.familyKey || observer.archiveKey || observer.id || ""; - const deadId = dead.id || dead.releasedLiveId || ""; - const observerId = observer.id || observer.liveId || ""; - if (deadFamily && (observer.parents || []).includes(deadFamily)) return "\u89aa"; - if (deadFamily && (observer.children || []).includes(deadFamily)) return "\u5b50"; - if (observerFamily && (dead.parents || []).includes(observerFamily)) return "\u5b50"; - if (observerFamily && (dead.children || []).includes(observerFamily)) return "\u89aa"; - const rel = deadId ? observer.relationships?.[deadId] : null; - const reverse = observerId ? dead.relationships?.[observerId] : null; - if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "\u53cb\u9054"; - return ""; - } - - function applyLiveDeathShock(observer, dead, relation, worldRef) { - const strong = relation === "\u89aa" || relation === "\u5b50"; - if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u3044\u305f"); - const stress = strong ? 18 : 11; - const fear = strong ? 2.1 : 1.45; - if (observer.enterPanic) { - const relLabel = relation || "\u8eab\u8fd1\u306a\u500b\u4f53"; - observer.lastPanicDetail = `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u305f`; - observer.enterPanic({ threat: dead, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); - } - else { - observer.sleeping = false; - observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relation || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, wake: true }); - observer.fearTimer = Math.max(observer.fearTimer || 0, fear); - if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 }); - observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); - } - observer.recordChangeCause?.(`${relation}\u306e\u6b7b\u4ea1`, "\u30b9\u30c8\u30ec\u30b9", { value: stress }); - observer.addRecord?.(`${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\u306e\u6b7b\u306b\u6c17\u3065\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death"); - } - - function notifyDeathToRelations(dead, reason = "") { - const worldRef = this || global.world; - if (!worldRef || !dead) return; - const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); - const deadId = dead.id || dead.releasedLiveId || ""; - let liveHits = 0; - for (const other of worldRef.tarinai || []) { - if (!other || other === dead || other.dead) continue; - const relation = relationWithDead(other, dead, worldRef); - if (!relation) continue; - applyLiveDeathShock(other, dead, relation, worldRef); - liveHits += 1; - } - for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) { - const data = entry.data || entry; - const relation = relationWithDead(data, dead, worldRef); - if (!relation) continue; - if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = []; - const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId)); - if (!duplicate) { - entry.pendingLosses.unshift({ - relation, - name: dead.name || "\u305f\u308a\u306a\u3044", - familyKey: deadFamily, - liveId: deadId, - reason: reason || dead.deathReason || "\u6b7b\u4ea1", - time: worldRef.time || 0, - }); - if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES; - } - } - if (liveHits) worldRef.log?.(`${dead.name || "\u305f\u308a\u306a\u3044"}\u306e\u6b7b\u306b\u3001\u8eab\u8fd1\u306a\u500b\u4f53\u304c\u52d5\u63fa\u3057\u305f\u3002`, "death", { participants: [dead] }); - global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef); - global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); - } - - function patchWorldPrototype() { - const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; }; - if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return; - const World = global.World; - if (!World) return; - apply(World.prototype); - } - - global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype }; - patchWorldPrototype(); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index d25b745..f28c9c7 100644 --- a/js/render.js +++ b/js/render.js @@ -147,7 +147,7 @@ function placementPreviewFor(world) { return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) }; } if (type === "grass") { - blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55)); + blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55)); } if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp); return { type, x, y, r, blocked }; @@ -227,18 +227,20 @@ const backgroundCache = { function backgroundCacheKey(world, lighting) { const weather = world.weather || "sunny"; const field = world.fieldType || "garden"; - const perf = world.performanceLevel ? world.performanceLevel() : 0; - const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180; - const progressBucket = Math.round((lighting.progress || 0) * step); - const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2)); - return `${field}:${weather}:${progressBucket}:${weatherBucket}`; + const ground = world.groundType || "soil"; + // Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays. + const lightBucket = Math.round((lighting.light || 0) * 8); + const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5); + const weatherBucket = Math.floor((world.time || 0) / 8); + return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${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. +// static visual noise. Rendering them into visible chunks avoids repeating +// thousands of small strokes every frame and avoids redrawing the entire field +// when a small terrain area changes. const terrainCache = { canvas: null, ctx: null, @@ -247,6 +249,12 @@ const terrainCache = { key: "", }; +const terrainChunkCache = { + size: 256, + key: "", + chunks: new Map(), +}; + function isCachedTerrainItem(it) { if (!it || it.dead) return false; if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true; @@ -273,16 +281,104 @@ function renderLayerKindRank(entity) { } 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}`; + const lightBucket = Math.round((lighting.light || 0) * 6); + return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${worldRef.w}x${worldRef.h}:${lightBucket}`; +} + +function terrainChunkKey(cx, cy) { + return `${cx}:${cy}`; +} + +function terrainChunkFor(cx, cy, worldRef) { + const key = terrainChunkKey(cx, cy); + let chunk = terrainChunkCache.chunks.get(key); + const size = terrainChunkCache.size; + const x = cx * size; + const y = cy * size; + const w = Math.max(1, Math.min(size, Math.ceil((worldRef.w || 1) - x))); + const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y))); + if (!chunk) { + const canvas = document.createElement("canvas"); + chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" }; + terrainChunkCache.chunks.set(key, chunk); + } + if (chunk.w !== w || chunk.h !== h) { + chunk.w = w; + chunk.h = h; + chunk.dirty = true; + } + return chunk; +} + +function redrawTerrainChunk(chunk, worldRef, lighting) { + const c = chunk.ctx; + chunk.canvas.width = Math.max(1, chunk.w); + chunk.canvas.height = Math.max(1, chunk.h); + c.clearRect(0, 0, chunk.w, chunk.h); + c.save(); + c.translate(-chunk.x, -chunk.y); + const oldPointer = worldRef.pointer; + try { + if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false }; + const cx = chunk.x + chunk.w / 2; + const cy = chunk.y + chunk.h / 2; + const radius = Math.hypot(chunk.w, chunk.h) / 2 + 90; + const candidates = worldRef.nearbyItems ? worldRef.nearbyItems(cx, cy, radius, false) : (worldRef.items || []); + const rect = { left: chunk.x - 80, top: chunk.y - 80, right: chunk.x + chunk.w + 80, bottom: chunk.y + chunk.h + 80 }; + for (const it of candidates || []) { + if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue; + it.draw(c, worldRef.time || 0, lighting); + } + } catch (err) { + console.error("terrain chunk redraw failed", err); + } finally { + if (oldPointer) worldRef.pointer = oldPointer; + c.restore(); + } + chunk.dirty = false; + chunk.key = terrainChunkCache.key; +} + +function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) { + const end = window.TarinaiPerf?.begin?.("render.terrain") || null; + try { + const key = terrainCacheKey(worldRef, lighting); + if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) { + terrainChunkCache.key = key; + terrainChunkCache.chunks.clear(); + worldRef.terrainDirty = false; + worldRef.terrainDirtyGlobal = false; + worldRef.terrainDirtyChunks?.clear?.(); + } else if (worldRef.terrainDirty && worldRef.terrainDirtyChunks?.size) { + for (const chunkKey of worldRef.terrainDirtyChunks) { + const chunk = terrainChunkCache.chunks.get(chunkKey); + if (chunk) chunk.dirty = true; + } + worldRef.terrainDirty = false; + worldRef.terrainDirtyChunks.clear(); + } + const size = terrainChunkCache.size; + const minX = Math.max(0, Math.floor((visibleRect.left || 0) / size)); + const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size)); + const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size)); + const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size)); + for (let cy = minY; cy <= maxY; cy++) { + for (let cx = minX; cx <= maxX; cx++) { + const chunk = terrainChunkFor(cx, cy, worldRef); + if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting); + ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h); + } + } + } finally { + if (end) end(); + } } function ensureTerrainCache(worldRef, lighting) { + // Compatibility fallback for older callers. The main renderer uses chunks. 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); + const key = `${terrainCacheKey(worldRef, lighting)}:fallback:v${worldRef.terrainVersion || 0}`; if (!terrainCache.canvas) { terrainCache.canvas = document.createElement("canvas"); terrainCache.ctx = terrainCache.canvas.getContext("2d"); @@ -297,11 +393,8 @@ function ensureTerrainCache(worldRef, lighting) { 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); - } + for (const it of worldRef.items || []) if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting); worldRef.terrainDirty = false; return terrainCache.canvas; } catch (err) { @@ -397,6 +490,64 @@ function ensureWorldRenderStack(worldRef) { return stack; } + +function collectVisibleRenderStack(worldRef, visibleRect) { + const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null; + try { + if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] }; + const stack = worldRef._visibleRenderStack; + stack.backItems.length = 0; + stack.layered.length = 0; + stack.carriedPlushies.length = 0; + stack.lodgedPins.length = 0; + const seen = new Set(); + const addItem = (it) => { + if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return; + seen.add(it); + if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") { + stack.lodgedPins.push(it); + return; + } + if (it.isStructure && it.type === "plushie" && it.carriedById) { + stack.carriedPlushies.push(it); + return; + } + if (isBehindSpriteLayerItem(it)) stack.backItems.push(it); + else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) }); + }; + const addLayered = (entity) => { + if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return; + seen.add(entity); + stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) }); + }; + worldRef.ensureSpatial?.("render-visible"); + if (worldRef.spatial?.nearbyRectInto) { + const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []); + itemScratch.length = 0; + worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch); + if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch); + for (const it of itemScratch) addItem(it); + const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []); + tarinaiScratch.length = 0; + worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch); + for (const t of tarinaiScratch) addLayered(t); + const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []); + antScratch.length = 0; + worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch); + for (const a of antScratch) addLayered(a); + } else { + for (const it of worldRef.items || []) addItem(it); + for (const t of worldRef.tarinai || []) addLayered(t); + for (const a of worldRef.ants || []) addLayered(a); + } + stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0)); + stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0))); + return stack; + } finally { + if (end) end(); + } +} + function ensureBackgroundCache(w, h, lighting) { const key = backgroundCacheKey(world, lighting); if (!backgroundCache.canvas) { @@ -484,10 +635,11 @@ function drawGardenBackdrop(w, h, lighting) { function drawGardenBed(w, h, lighting) { const light = lighting.light; const fieldType = world.fieldType || "garden"; + const groundType = world.groundType || "soil"; const x = 18, y = 22, bw = w - 36, bh = h - 44; const border = activeCtx.createLinearGradient(0, y, 0, y + bh); - const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); - const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b"); + const borderTop = groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#b78a62" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66"); + const borderBottom = groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#815d43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b"); border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72"); border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68"); activeCtx.fillStyle = border; @@ -503,11 +655,17 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14); - const palette = fieldType === "park" - ? ["#cfe5a8", "#b8d990", "#91bd74"] - : fieldType === "cage" - ? ["#d7d2c5", "#c7c2b6", "#aaa79e"] - : [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"]; + const palette = groundType === "concrete" + ? ["#d7d2c5", "#c7c2b6", "#aaa79e"] + : groundType === "blanket" + ? ["#ffe1ec", "#f6c6dc", "#e3a2c6"] + : groundType === "foot_massage" + ? ["#e6c59c", "#cfa677", "#a77a52"] + : fieldType === "park" + ? ["#cfe5a8", "#b8d990", "#91bd74"] + : fieldType === "cage" + ? ["#d7d2c5", "#c7c2b6", "#aaa79e"] + : [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"]; soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6"); soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd"); soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af"); @@ -520,19 +678,34 @@ function drawGardenBed(w, h, lighting) { roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28); activeCtx.clip(); - const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46; + const speckCount = groundType === "blanket" ? 28 : groundType === "foot_massage" ? 86 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46; for (let i = 0; i < speckCount; i++) { const px = randSeed(900 + i, x + 24, x + bw - 24); const py = randSeed(1200 + i, y + 22, y + bh - 24); const r = randSeed(1600 + i, 1.2, 3.8); const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70); - activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`; - activeCtx.beginPath(); - activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2); - activeCtx.fill(); + if (groundType === "foot_massage") { + activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 118, 166))}, ${Math.round(randSeed(2300 + i, 75, 112))}, ${Math.round(randSeed(2600 + i, 48, 78))}, ${a * 1.7})`; + activeCtx.beginPath(); + activeCtx.arc(px, py, r * 1.25, 0, Math.PI * 2); + activeCtx.fill(); + } else { + activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`; + activeCtx.beginPath(); + activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2); + activeCtx.fill(); + } } - if (fieldType === "cage") { + if (groundType === "blanket") { + activeCtx.save(); + activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)"; + activeCtx.lineWidth = 1.25; + for (let gy = y + 38; gy < y + bh - 26; gy += 32) { + activeCtx.beginPath(); activeCtx.moveTo(x + 28, gy); activeCtx.lineTo(x + bw - 28, gy + Math.sin(gy * 0.05) * 3); activeCtx.stroke(); + } + activeCtx.restore(); + } else if (fieldType === "cage") { activeCtx.save(); activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)"; activeCtx.lineWidth = 1.1; @@ -555,7 +728,7 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); } - const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8; + const patchCount = groundType === "concrete" ? 3 : groundType === "blanket" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8; for (let i = 0; i < patchCount; i++) { const px = randSeed(3300 + i, x + 40, x + bw - 40); const py = randSeed(3600 + i, y + 44, y + bh - 46); @@ -568,7 +741,7 @@ function drawGardenBed(w, h, lighting) { } // edge grasses and tiny flowers - const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14; + const edgeGrassCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14; for (let i = 0; i < edgeGrassCount; i++) { const side = i % 2; const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30); @@ -588,7 +761,7 @@ function drawGardenBed(w, h, lighting) { activeCtx.restore(); } - const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9; + const flowerCount = groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9; for (let i = 0; i < flowerCount; i++) { const px = randSeed(7000 + i, x + 44, x + bw - 44); const py = randSeed(7400 + i, y + 46, y + bh - 44); @@ -607,7 +780,7 @@ function drawGardenBed(w, h, lighting) { } } - const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10; + const leafCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10; for (let i = 0; i < leafCount; i++) { const px = randSeed(7800 + i, x + 36, x + bw - 36); const py = randSeed(8200 + i, y + 34, y + bh - 34); @@ -797,15 +970,13 @@ function render() { drawLightRays(ctx, screenW, screenH, lighting); const visibleRect = visibleWorldRect(world, 180); - const terrainLayer = ensureTerrainCache(world, lighting); beginFieldTransform(); drawPlacementPreview(ctx, world); - if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH); + drawTerrainLayer(ctx, world, lighting, visibleRect); - // Dynamic render stacks are sorted on a short interval rather than every paint. - // Visibility is still checked per frame, so camera movement remains responsive. - const renderStack = ensureWorldRenderStack(world); + // Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap. + const renderStack = collectVisibleRenderStack(world, visibleRect); for (const it of renderStack.backItems) { if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } @@ -890,30 +1061,51 @@ function render() { } function drawPointerItemTooltip(ctx, world) { - const nest = world.pointerNestBoxInfo?.(); - const bedInfo = !nest ? world.pointerOwnedBedInfo?.() : null; - let target = null; - let lines = []; - if (nest?.box) { - target = nest.box; - const names = (nest.occupants || []).map(t => t?.name).filter(Boolean).slice(0, 5); - lines = ["\u5de3\u7bb1"]; - if (names.length) lines.push(`${"\u5229\u7528\u4e2d"}: ${names.join("\u3001")}`); - else lines.push("\u5229\u7528\u4e2d: \u306a\u3057"); - } else if (bedInfo?.bed) { - target = bedInfo.bed; - lines = ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `${"\u6301\u3061\u4e3b"}: ${bedInfo.owner?.name || "\u4e0d\u660e"}`]; - } - if (!target || !lines.length) return; + const info = world.pointerItemTooltipInfo?.(); + const target = info?.target || null; + const rawLines = (info?.lines || []).filter(Boolean); + if (!target || !rawLines.length) return; ctx.save(); ctx.font = "12px Yomogi, sans-serif"; + const maxTextW = 238; + const ellipsize = (text, maxW) => { + let s = String(text || ""); + if (ctx.measureText(s).width <= maxW) return s; + while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1); + return `${s}…`; + }; + const wrapLine = (text, maxW) => { + const s = String(text || ""); + if (ctx.measureText(s).width <= maxW) return [s]; + if (s.includes("、")) { + const parts = s.split("、"); + const out = []; + let line = ""; + for (const part of parts) { + const candidate = line ? `${line}、${part}` : part; + if (ctx.measureText(candidate).width <= maxW) line = candidate; + else { + if (line) out.push(line); + line = part; + } + } + if (line) out.push(line); + return out.slice(0, 3).map(line => ellipsize(line, maxW)); + } + return [ellipsize(s, maxW)]; + }; + let lines = []; + for (const line of rawLines) lines.push(...wrapLine(line, maxTextW)); + if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("、"), maxTextW)]; const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); const lineH = 17; const w = Math.min(280, Math.max(92, textW + 22)); const h = Math.max(38, 16 + lines.length * lineH); const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y }; - const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8); - const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, ctx.canvas.height - h - 8); + const canvasW = world.viewportW || world.w || ctx.canvas.width || 1; + const canvasH = world.viewportH || world.h || ctx.canvas.height || 1; + const x = clamp(pos.x - w / 2, 8, canvasW - w - 8); + const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8); ctx.fillStyle = "rgba(46,38,30,0.66)"; ctx.strokeStyle = "rgba(255,248,220,0.74)"; ctx.lineWidth = 1.2; diff --git a/js/restore_coordinator.js b/js/restore_coordinator.js new file mode 100644 index 0000000..4948989 --- /dev/null +++ b/js/restore_coordinator.js @@ -0,0 +1,35 @@ +"use strict"; + +(function (global) { + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for restore_coordinator.js"); + + function renderWorldAfterRestore(worldRef = global.world) { + if (!worldRef) return; + if (typeof renderLog === "function") renderLog(worldRef.logs || []); + if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + } + + function syncRestoreDependents(worldRef = global.world, context = {}) { + global.TarinaiFreezeSystem?.afterWorldRestored?.(worldRef, context); + worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" }); + renderWorldAfterRestore(worldRef); + } + + function restoreSnapshot(snapshot, worldRef = global.world, options = {}) { + const result = Snapshot.restoreSnapshot(snapshot, worldRef); + if (options.syncUi !== false) syncRestoreDependents(worldRef, { source: options.source || "snapshot", snapshotVersion: result?.snapshotVersion || Snapshot.version }); + return result; + } + + global.TarinaiRestoreCoordinator = { + restoreSnapshot, + syncRestoreDependents, + renderWorldAfterRestore, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/save_codec.js b/js/save_codec.js new file mode 100644 index 0000000..4b49016 --- /dev/null +++ b/js/save_codec.js @@ -0,0 +1,139 @@ +"use strict"; + +(function (global) { + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js"); + const EXPORT_PREFIX = "TN3!"; + const SAVE_TEXT_ALPHABET = (() => { + let out = ""; + for (let i = 33; i <= 126; i++) { + const ch = String.fromCharCode(i); + if (ch === "*" || ch === "'" || ch === "_") continue; + out += ch; + } + return out; + })(); + const SAVE_TEXT_REVERSE = (() => { + const map = new Map(); + for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i); + return map; + })(); + if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars"); + + function base91Encode(bytes) { + let b = 0; + let n = 0; + let out = ""; + for (const byte of bytes || []) { + b |= (byte & 255) << n; + n += 8; + if (n > 13) { + let v = b & 8191; + if (v > 88) { + b >>= 13; + n -= 13; + } else { + v = b & 16383; + b >>= 14; + n -= 14; + } + out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)]; + } + } + if (n) { + out += SAVE_TEXT_ALPHABET[b % 91]; + if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)]; + } + return out; + } + + function base91Decode(text) { + const clean = String(text || "").trim(); + let v = -1; + let b = 0; + let n = 0; + const out = []; + for (const ch of clean) { + const c = SAVE_TEXT_REVERSE.get(ch); + if (c == null) continue; + if (v < 0) { + v = c; + } else { + v += c * 91; + b |= v << n; + n += (v & 8191) > 88 ? 13 : 14; + do { + out.push(b & 255); + b >>= 8; + n -= 8; + } while (n > 7); + v = -1; + } + } + if (v >= 0) out.push((b | (v << n)) & 255); + return new Uint8Array(out); + } + + async function compressBytes(bytes) { + if (typeof CompressionStream === "function") { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + return bytes; + } + + async function decompressBytes(bytes, codec = "R") { + if (codec === "D") { + if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + return bytes; + } + + async function encodeSnapshot(snapshot) { + if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("snapshot version mismatch"); + const json = JSON.stringify(snapshot); + const bytes = new TextEncoder().encode(json); + let codec = "R"; + let packed = bytes; + try { + const compressed = await compressBytes(bytes); + if (compressed.length < bytes.length) { + codec = "D"; + packed = compressed; + } + } catch (error) { + console.warn("save compression fallback", error); + } + return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`; + } + + async function decodeSnapshot(text) { + const raw = String(text || "").trim(); + if (!raw) throw new Error("empty import text"); + if (raw.startsWith("{") || raw.startsWith("[")) { + const data = JSON.parse(raw); + if (data?.v !== Snapshot.version) throw new Error("unsupported save version"); + return data; + } + if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text"); + const codec = raw.charAt(EXPORT_PREFIX.length) || "R"; + const payload = raw.slice(EXPORT_PREFIX.length + 1); + const packed = base91Decode(payload); + const bytes = await decompressBytes(packed, codec); + const json = new TextDecoder().decode(bytes); + const data = JSON.parse(json); + if (data?.v !== Snapshot.version) throw new Error("unsupported save version"); + return data; + } + + global.TarinaiSaveCodec = { + EXPORT_PREFIX, + SAVE_TEXT_ALPHABET, + base91Encode, + base91Decode, + encodeSnapshot, + decodeSnapshot, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/save_storage.js b/js/save_storage.js new file mode 100644 index 0000000..e812cc5 --- /dev/null +++ b/js/save_storage.js @@ -0,0 +1,47 @@ +"use strict"; + +(function (global) { + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_storage.js"); + const STORAGE_PREFIX = "tarinai_save_slot_v3_"; + const SLOT_COUNT = 9; + + function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } + + function writeSlot(slot, snapshot) { + if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("unsupported slot version"); + global.localStorage?.setItem(slotKey(slot), JSON.stringify(snapshot)); + return snapshot; + } + + function readSlot(slot) { + const raw = global.localStorage?.getItem(slotKey(slot)); + if (!raw) return null; + try { + const data = JSON.parse(raw); + return data?.v === Snapshot.version ? data : null; + } catch (_) { + return null; + } + } + + function requireSlot(slot) { + const snapshot = readSlot(slot); + if (!snapshot) throw new Error("empty slot"); + return snapshot; + } + + function deleteSlot(slot) { + global.localStorage?.removeItem(slotKey(slot)); + } + + global.TarinaiSaveStorage = { + STORAGE_PREFIX, + SLOT_COUNT, + slotKey, + writeSlot, + readSlot, + requireSlot, + deleteSlot, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/save_system.js b/js/save_system.js index 9738749..5c47ee0 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -1,164 +1,42 @@ "use strict"; (function (global) { - const STORAGE_PREFIX = "tarinai_save_slot_v2_"; - const EXPORT_PREFIX = "TN2!"; - const SLOT_COUNT = 9; const Snapshot = global.TarinaiSnapshot; - if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); - const createSnapshot = (...args) => Snapshot.createSnapshot(...args); - const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); + const Codec = global.TarinaiSaveCodec; + const Storage = global.TarinaiSaveStorage; + const Restore = global.TarinaiRestoreCoordinator; + if (!Snapshot || !Codec || !Storage || !Restore) throw new Error("save_system.js dependencies are not available"); + const SLOT_COUNT = Storage.SLOT_COUNT; + const htmlEscape = global.TarinaiUIHelpers.htmlEscape; - const SAVE_TEXT_ALPHABET = (() => { - let out = ""; - for (let i = 33; i <= 126; i++) { - const ch = String.fromCharCode(i); - if (ch === "*" || ch === "'" || ch === "_") continue; - out += ch; - } - return out; - })(); - const SAVE_TEXT_REVERSE = (() => { - const map = new Map(); - for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i); - return map; - })(); - if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars"); - - function base91Encode(bytes) { - let b = 0; - let n = 0; - let out = ""; - for (const byte of bytes || []) { - b |= (byte & 255) << n; - n += 8; - if (n > 13) { - let v = b & 8191; - if (v > 88) { - b >>= 13; - n -= 13; - } else { - v = b & 16383; - b >>= 14; - n -= 14; - } - out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)]; - } - } - if (n) { - out += SAVE_TEXT_ALPHABET[b % 91]; - if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)]; - } - return out; + function createSnapshot(worldRef = global.world) { + return Snapshot.createSnapshot(worldRef); } - function base91Decode(text) { - const clean = String(text || "").trim(); - let v = -1; - let b = 0; - let n = 0; - const out = []; - for (const ch of clean) { - const c = SAVE_TEXT_REVERSE.get(ch); - if (c == null) continue; - if (v < 0) { - v = c; - } else { - v += c * 91; - b |= v << n; - n += (v & 8191) > 88 ? 13 : 14; - do { - out.push(b & 255); - b >>= 8; - n -= 8; - } while (n > 7); - v = -1; - } - } - if (v >= 0) out.push((b | (v << n)) & 255); - return new Uint8Array(out); + function restoreSnapshot(snapshot, worldRef = global.world, options = {}) { + return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi }); } - async function compressBytes(bytes) { - if (typeof CompressionStream === "function") { - const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw")); - return new Uint8Array(await new Response(stream).arrayBuffer()); - } - return bytes; - } - - async function decompressBytes(bytes, codec = "R") { - if (codec === "D") { - if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser"); - const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); - return new Uint8Array(await new Response(stream).arrayBuffer()); - } - return bytes; - } - - async function encodeSnapshot(snapshot) { - const compact = snapshot && snapshot.v === 2 ? snapshot : createSnapshot(global.world); - const json = JSON.stringify(compact); - const bytes = new TextEncoder().encode(json); - let codec = "R"; - let packed = bytes; - try { - const compressed = await compressBytes(bytes); - if (compressed.length < bytes.length) { - codec = "D"; - packed = compressed; - } - } catch (error) { - console.warn("save compression fallback", error); - } - return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`; + async function encodeSnapshot(snapshot = createSnapshot(global.world)) { + return Codec.encodeSnapshot(snapshot); } async function decodeSnapshot(text) { - const raw = String(text || "").trim(); - if (!raw) throw new Error("empty import text"); - if (raw.startsWith("{") || raw.startsWith("[")) { - const data = JSON.parse(raw); - if (data?.v !== 2) throw new Error("unsupported save version"); - return data; - } - if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text"); - const codec = raw.charAt(EXPORT_PREFIX.length) || "R"; - const payload = raw.slice(EXPORT_PREFIX.length + 1); - const packed = base91Decode(payload); - const bytes = await decompressBytes(packed, codec); - const json = new TextDecoder().decode(bytes); - const data = JSON.parse(json); - if (data?.v !== 2) throw new Error("unsupported save version"); - return data; + return Codec.decodeSnapshot(text); } - function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } - async function saveSlot(slot) { const snapshot = createSnapshot(global.world); - localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); - return snapshot; + return Storage.writeSlot(slot, snapshot); } async function loadSlot(slot) { - const raw = localStorage.getItem(slotKey(slot)); - if (!raw) throw new Error("empty slot"); - const snapshot = JSON.parse(raw); - if (snapshot?.v !== 2) throw new Error("unsupported slot version"); - return restoreSnapshot(snapshot, global.world); + const snapshot = Storage.requireSlot(slot); + return restoreSnapshot(snapshot, global.world, { source: "slot" }); } - function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } - - function readSlot(slot) { - const raw = localStorage.getItem(slotKey(slot)); - if (!raw) return null; - try { return JSON.parse(raw); } catch (_) { return null; } - } - - const htmlEscape = global.TarinaiUIHelpers.htmlEscape; - + function deleteSlot(slot) { Storage.deleteSlot(slot); } + function readSlot(slot) { return Storage.readSlot(slot); } function formatSlotSummary(snapshot) { if (!snapshot) return "\u7a7a\u304d"; @@ -240,6 +118,8 @@ openDialog(); }); const dialog = ensureDialog(); + if (dialog.dataset.saveDialogBound === "1") return; + dialog.dataset.saveDialogBound = "1"; dialog.addEventListener("click", async (e) => { if (e.target === dialog) { closeDialog(); return; } const saveBtn = e.target.closest("[data-save-slot]"); @@ -297,7 +177,7 @@ if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; } if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return; try { - restoreSnapshot(await decodeSnapshot(text), global.world); + restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" }); closeDialog(); global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); } catch (error) { diff --git a/js/sim_core.js b/js/sim_core.js index 2da7fdf..8bae314 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -122,11 +122,6 @@ function scaleForGrowth(adultScale = 0.28, growth = 1) { const PERSONALITIES = Object.freeze({}); const PERSONALITY_IDS = []; - -function personalityForSeed(seed) { - return ""; -} - const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"]; const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 }; const FRIEND_AFFINITY_THRESHOLD = 14; @@ -371,13 +366,6 @@ function relationDefaults() { return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 }; } -function isParentChild(a, b) { - if (!a || !b) return false; - const aKey = a.familyKey || a.id; - const bKey = b.familyKey || b.id; - return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey)); -} - function relationDisplayName(worldRef, id) { if (!id) return "\u306a\u3057"; const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id); @@ -539,20 +527,64 @@ class SpatialGrid { constructor(cellSize = 96) { this.cellSize = cellSize; this.itemCells = new Map(); + this.staticItemCells = new Map(); + this.dynamicItemCells = new Map(); this.tarinaiCells = new Map(); this.antCells = new Map(); this.obstacleCells = new Map(); this.foodCells = new Map(); this.hazardCells = new Map(); + this.staticObstacleCells = new Map(); + this.dynamicObstacleCells = new Map(); + this.staticFoodCells = new Map(); + this.dynamicFoodCells = new Map(); + this.staticHazardCells = new Map(); + this.dynamicHazardCells = new Map(); } clear() { + this.clearItems(); + this.clearTarinai(); + this.clearAnts(); + } + + clearItems() { this.itemCells.clear(); - this.tarinaiCells.clear(); - this.antCells.clear(); + this.staticItemCells.clear(); + this.dynamicItemCells.clear(); this.obstacleCells.clear(); this.foodCells.clear(); this.hazardCells.clear(); + this.staticObstacleCells.clear(); + this.dynamicObstacleCells.clear(); + this.staticFoodCells.clear(); + this.dynamicFoodCells.clear(); + this.staticHazardCells.clear(); + this.dynamicHazardCells.clear(); + } + + clearStaticItems(opts = {}) { + this.staticItemCells.clear(); + this.staticObstacleCells.clear(); + this.staticFoodCells.clear(); + this.staticHazardCells.clear(); + if (opts.rebuildCombined) this.rebuildCombinedItemCells(); + } + + clearDynamicItems(opts = {}) { + this.dynamicItemCells.clear(); + this.dynamicObstacleCells.clear(); + this.dynamicFoodCells.clear(); + this.dynamicHazardCells.clear(); + if (opts.rebuildCombined) this.rebuildCombinedItemCells(); + } + + clearTarinai() { + this.tarinaiCells.clear(); + } + + clearAnts() { + this.antCells.clear(); } keyFor(x, y) { @@ -572,24 +604,115 @@ class SpatialGrid { bucket.push(entity); } + isDynamicItem(it) { + if (!it || it.dead) return false; + const type = it.type || ""; + if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; + if ((it.dropTimer || 0) > 0) return true; + if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true; + if (it.isStructure && it.type === "plushie" && it.carriedById) return true; + return false; + } + + addItemToTraitCells(it, dynamic = this.isDynamicItem(it)) { + const type = it.type || ""; + const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells; + const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells; + const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells; + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(obstacleMap, it); + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(foodMap, it); + if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it); + } + classifyItem(it) { if (!it || it.dead) return; + const dynamic = this.isDynamicItem(it); + const target = dynamic ? this.dynamicItemCells : this.staticItemCells; + this.add(target, it); this.add(this.itemCells, it); - const type = it.type || ""; - if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(this.obstacleCells, it); - if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(this.foodCells, it); - if (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it); + this.addItemToTraitCells(it, dynamic); + } + + rebuildItems(items) { + this.clearItems(); + for (const it of items || []) this.classifyItem(it); + this.rebuildCombinedItemCells(); + } + + rebuildStaticItems(items, opts = {}) { + this.staticItemCells.clear(); + this.staticObstacleCells.clear(); + this.staticFoodCells.clear(); + this.staticHazardCells.clear(); + for (const it of items || []) { + if (!it || it.dead || this.isDynamicItem(it)) continue; + this.add(this.staticItemCells, it); + this.addItemToTraitCells(it, false); + } + if (opts.rebuildCombined) this.rebuildCombinedItemCells(); + } + + rebuildDynamicItems(items, opts = {}) { + this.dynamicItemCells.clear(); + this.dynamicObstacleCells.clear(); + this.dynamicFoodCells.clear(); + this.dynamicHazardCells.clear(); + for (const it of items || []) { + if (!it || it.dead || !this.isDynamicItem(it)) continue; + this.add(this.dynamicItemCells, it); + this.addItemToTraitCells(it, true); + } + if (opts.rebuildCombined) this.rebuildCombinedItemCells(); + } + + copyCells(src, dst) { + for (const [key, bucket] of src.entries()) { + let out = dst.get(key); + if (!out) { + out = []; + dst.set(key, out); + } + for (const entity of bucket) out.push(entity); + } + } + + rebuildCombinedItemCells() { + this.itemCells.clear(); + this.obstacleCells.clear(); + this.foodCells.clear(); + this.hazardCells.clear(); + this.copyCells(this.staticItemCells, this.itemCells); + this.copyCells(this.dynamicItemCells, this.itemCells); + this.copyCells(this.staticObstacleCells, this.obstacleCells); + this.copyCells(this.dynamicObstacleCells, this.obstacleCells); + this.copyCells(this.staticFoodCells, this.foodCells); + this.copyCells(this.dynamicFoodCells, this.foodCells); + this.copyCells(this.staticHazardCells, this.hazardCells); + this.copyCells(this.dynamicHazardCells, this.hazardCells); + } + + rebuildTarinai(tarinai) { + this.tarinaiCells.clear(); + for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t); + } + + rebuildAnts(ants = []) { + this.antCells.clear(); + for (const a of ants || []) if (!a.dead) this.add(this.antCells, a); } 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); + this.rebuildItems(items); + this.rebuildTarinai(tarinai); + this.rebuildAnts(ants); } nearby(map, x, y, radius, out = [], filterDistance = false) { out.length = 0; + return this.nearbyInto(map, x, y, radius, out, filterDistance); + } + + nearbyInto(map, x, y, radius, out = [], filterDistance = false) { const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); const minX = Math.floor((x - r) / this.cellSize); const maxX = Math.floor((x + r) / this.cellSize); @@ -612,6 +735,29 @@ class SpatialGrid { } return out; } + + nearbySplitInto(staticMap, dynamicMap, x, y, radius, out = [], filterDistance = false) { + out.length = 0; + if (staticMap) this.nearbyInto(staticMap, x, y, radius, out, filterDistance); + if (dynamicMap) this.nearbyInto(dynamicMap, x, y, radius, out, filterDistance); + return out; + } + + nearbyRectInto(map, rect, out = []) { + if (!rect) return out; + const minX = Math.floor((rect.left || 0) / this.cellSize); + const maxX = Math.floor((rect.right || 0) / this.cellSize); + const minY = Math.floor((rect.top || 0) / this.cellSize); + const maxY = Math.floor((rect.bottom || 0) / this.cellSize); + for (let cy = minY; cy <= maxY; cy++) { + 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); + } + } + return out; + } } function drawHeartShape(ctx, x, y, size, opts = {}) { diff --git a/js/snapshot_system.js b/js/snapshot_system.js index f66b5b9..2261f01 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -1,7 +1,7 @@ "use strict"; (function (global) { - const SNAPSHOT_VERSION = 2; + const SNAPSHOT_VERSION = 3; const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); @@ -67,17 +67,6 @@ return Number.isFinite(n) ? n / scale : fallback; } - function compactNumberArray(obj, keys, scale = 1) { - return keys.map(k => q(obj?.[k], scale)); - } - - function expandNumberArray(arr, keys, scale = 1) { - const out = {}; - if (!Array.isArray(arr)) return out; - keys.forEach((key, i) => { out[key] = u(arr[i], scale); }); - return out; - } - function needsToArray(needs = {}) { return NEED_KEYS.map(k => q(needs[k], 1)); } @@ -135,6 +124,7 @@ function worldData(worldRef) { return { fieldType: worldRef?.fieldType || "garden", + groundType: worldRef?.groundType || "soil", time: Number(worldRef?.time) || 0, day: Number(worldRef?.day) || 1, cameraX: Number(worldRef?.cameraX) || 0, @@ -166,6 +156,7 @@ q(worldRef?.lastBirthAt, 10, -9990), q(worldRef?.maxGeneration, 1, 1), mood, + worldRef?.groundType || "soil", ]; } @@ -184,26 +175,50 @@ worldRef.maxGeneration = Math.max(1, u(arr[11], 1, 1)); const moodId = arr[12] || "relaxed"; worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || worldRef.colonyMoodDefinition?.("relaxed") || { id: moodId, label: moodId, effects: { personality: {} } }; + worldRef.setGroundType?.(arr[13] || "soil", { silent: true, force: true }); + } + + function compactBehaviorTarget(ref = null) { + if (!ref || typeof ref !== "object") return null; + const id = ref.id != null ? String(ref.id) : ""; + if ((ref.kind === "tarinai" || ref.familyKey || ref.name) && id) return ["t", id]; + if ((ref.kind === "item" || ref.type) && id) return ["i", id]; + if (Number.isFinite(ref.x) && Number.isFinite(ref.y)) return ["p", q(ref.x, 1), q(ref.y, 1)]; + return null; + } + + function expandBehaviorTarget(row = null) { + if (!Array.isArray(row) || !row[0]) return null; + if (row[0] === "t") return { kind: "tarinai", id: String(row[1] || "") }; + if (row[0] === "i") return { kind: "item", id: String(row[1] || "") }; + if (row[0] === "p") return { kind: "position", x: u(row[1], 1), y: u(row[2], 1) }; + return null; } function compactBehavior(b = null, tarinaiIndex = new Map(), itemIndex = new Map()) { if (!b || typeof b !== "object") return null; - const ref = b.targetRef || b.target || null; - let target = null; - if (ref?.kind === "tarinai") target = ["t", tarinaiIndex.get(ref.id) ?? -1]; - else if (ref?.kind === "item") target = ["i", itemIndex.get(ref.id) ?? -1]; - else if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) target = ["p", q(ref.x, 1), q(ref.y, 1)]; - return [b.id || "", b.need || "", b.subNeed || "", b.phase || "", b.forced ? 1 : 0, target].filter((v, i) => i < 5 || v); + const target = compactBehaviorTarget(b.targetRef || b.target || null); + const forced = typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.source === "forced" || b.forcedId || b.forcedRequest); + const source = forced ? "forced" : String(b.source || "need"); + return [ + b.actionId || "", b.need || "", b.subNeed || "", b.phase || "", source, + b.reason || "", b.text || "", b.label || "", b.forcedId || "", target, + ].filter((v, i) => i < 5 || v); } function expandBehavior(arr = null) { if (!Array.isArray(arr) || !arr[0]) return null; return { - id: arr[0] || "", + actionId: arr[0] || "", need: arr[1] || "", subNeed: arr[2] || "", phase: arr[3] || "", - forced: !!arr[4], + source: arr[4] || "need", + reason: arr[5] || "", + text: arr[6] || arr[5] || "", + label: arr[7] || "", + forcedId: arr[8] || "", + target: expandBehaviorTarget(arr[9]), }; } @@ -306,7 +321,8 @@ return; } if (type === "grass") { - item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); item.grassLimitExempt = !!extra[7]; item.manualGrass = !!extra[7]; + item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); item.grassLimitExempt = false; item.manualGrass = false; + global.TarinaiGrass?.normalize?.(item); } else if (type === "bed") { item.comfort = u(extra[0], 1000); item.wear = u(extra[1], 1000); } else if (type === "signboard") { @@ -335,18 +351,6 @@ return [ant.kind || "worker", itemIndex.get(ant.homeId) ?? -1, tarinaiIndex.get(ant.targetId) ?? -1, q(ant.x, 1), q(ant.y, 1), q(ant.vx, 10), q(ant.vy, 10), q(ant.hp, 10), q(ant.maxHp, 10), ant.state || "search", q(ant.age, 10), q(ant.seed, 10), q(ant.returnTimer, 10), q(ant.foundingX, 1), q(ant.foundingY, 1), q(ant.foundingDelayUntil, 10), q(ant.foundingAttempts, 1), ant.dead ? 1 : 0]; } - function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { - // Compatibility helper for non-save subsystems such as freeze. The exported save format no longer uses this broad snapshot. - const data = copyOwnData(entity, skip); - const targetRef = refFor(worldRef, entity?.target); - const panicTargetRef = refFor(worldRef, entity?.panicTarget); - const targetRefObject = refFor(worldRef, entity?.targetRef); - if (targetRef) data.__targetRef = targetRef; - if (panicTargetRef) data.__panicTargetRef = panicTargetRef; - if (targetRefObject) data.__targetRefObject = targetRefObject; - return data; - } - function createSnapshot(worldRef = global.world) { if (!worldRef) throw new Error("world is not ready"); const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead); @@ -364,7 +368,7 @@ v: SNAPSHOT_VERSION, a: "tcg", c: now, - m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length], + m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length, worldRef.groundType || "soil"], w: compactWorld(worldRef), t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)), i: itemsLive.map((it, i) => compactItem(it, i, tarinaiIndex)), @@ -373,22 +377,6 @@ }; } - function restoreTarinaiData(worldRef, data, opts = {}) { - const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); - if (!TarinaiClass) throw new Error("Tarinai is not available"); - const t = new TarinaiClass(worldRef, data || {}); - applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"])); - t.world = worldRef; - if (opts.clearTransient !== false) { - t.target = null; - t.panicTarget = null; - t.sleeping = false; - t.insideNestBoxId = ""; - } - if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); - return t; - } - function expandTarinai(row, index, worldRef) { const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); if (!TarinaiClass) throw new Error("Tarinai is not available"); @@ -432,7 +420,7 @@ } function restoreSnapshot(snapshot, worldRef = global.world) { - if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid v2 save data"); + if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data"); if (!worldRef) throw new Error("world is not ready"); const fieldType = snapshot.w?.[0] || "garden"; if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); @@ -474,6 +462,7 @@ item = new Item(type, u(row[1], 1), u(row[2], 1)); } item.id = `si${idx}`; + item.world = worldRef; item.r = u(row[3], 10, item.r || 12); item.amount = u(row[4], 10, item.amount || 0); item.age = u(row[5], 10, 0); @@ -516,19 +505,17 @@ worldRef.resolvedFightIds = {}; worldRef.eventCounters = {}; if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.enforceGrassLimit === "function") { + worldRef.enforceGrassLimit("load-grass-limit"); + worldRef.compactItems?.(); + worldRef.updateItemCounts?.(); + } if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); - if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load-v2"); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); if (typeof worldRef.clampCamera === "function") worldRef.clampCamera(); - if (typeof renderLog === "function") renderLog(worldRef.logs || []); - if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); - if (typeof renderArchive === "function") renderArchive(); - if (typeof renderSelected === "function") renderSelected(); - if (typeof renderStats === "function") renderStats(); - if (typeof render === "function") render(); - if (typeof syncTopButtons === "function") syncTopButtons(); - global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); - return true; + worldRef.emit?.("snapshot:restored", { snapshotVersion: SNAPSHOT_VERSION }); + return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION }; } global.TarinaiSnapshot = { @@ -539,9 +526,7 @@ refFor, resolveRef, worldData, - snapshotEntity, createSnapshot, restoreSnapshot, - restoreTarinaiData, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/structure_lifecycle.js b/js/structure_lifecycle.js new file mode 100644 index 0000000..688a8f0 --- /dev/null +++ b/js/structure_lifecycle.js @@ -0,0 +1,90 @@ +"use strict"; + +(function (global) { + function markWorldAfterStructureChange(worldRef, reason = "structure-change") { + if (!worldRef) return; + worldRef.drawListDirty = true; + worldRef.markItemBucketsDirty?.(reason); + worldRef.markSpatialDirty?.(reason); + worldRef.markTerrainDirty?.(reason); + } + + function markStructureGone(structure, reason = "structure-removed") { + if (!structure || structure.dead) return false; + structure.dead = true; + structure.hp = 0; + structure.amount = 0; + structure.removedReason = String(reason || "structure-removed"); + return true; + } + + function releaseStructureUsers(worldRef, structure, opts = {}) { + if (!worldRef || !structure) return 0; + const reason = opts.reason || "なくなった"; + const wake = opts.wake !== false; + let released = 0; + for (const t of worldRef.tarinai || []) { + if (!t || t.dead) continue; + const targetMatch = t.target === structure; + const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id; + const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id; + if (!targetMatch && !sleepTargetMatch && !nestMatch) continue; + + if (nestMatch) { + t.insideNestBoxId = null; + t.nestBoxSleepTimer = 0; + } + if (sleepTargetMatch && t.sleepSession) { + t.sleepSession.targetId = null; + t.sleepSession.consumesGrassBedOnWake = false; + } + if (targetMatch) t.target = null; + if (t.state === "sleep" || t.sleeping || t.state === "seek_bed" || nestMatch) { + t.setActionState?.("idle", { clearTarget: true, reason, wake, sleeping: false }); + } + released += 1; + } + return released; + } + + function deleteStructure(worldRef, structure, opts = {}) { + if (!structure || structure.dead) return false; + const reason = opts.reason || "structure-deleted"; + releaseStructureUsers(worldRef, structure, { reason: opts.userReason || "置きものがなくなった", wake: opts.wake !== false }); + const changed = markStructureGone(structure, reason); + if (!changed) return false; + markWorldAfterStructureChange(worldRef, reason); + worldRef?.emit?.("structure:deleted", { structure, type: structure.type, reason }); + return true; + } + + function consumeGrassBedOnWake(worldRef, bed, user = null, reason = "wake") { + if (!bed || bed.dead || bed.type !== "grass_bed") return false; + bed.singleUsePending = false; + bed.singleUseWakeReason = String(reason || "wake"); + bed.singleUseConsumedById = user?.id || bed.singleUseConsumedById || null; + if (user?.target === bed) user.target = null; + const changed = markStructureGone(bed, "grass-bed-wake-used"); + if (!changed) return false; + markWorldAfterStructureChange(worldRef, "grass-bed-wake-used"); + worldRef?.emit?.("structure:usedUp", { structure: bed, user, reason: "wake" }); + return true; + } + + function deleteItem(worldRef, item, opts = {}) { + if (!item || item.dead) return false; + if (item.isStructure) return deleteStructure(worldRef, item, { reason: opts.reason || "delete-tool", userReason: opts.userReason, wake: opts.wake }); + item.amount = 0; + if (item.type !== "grass") item.dead = Boolean(opts.killNonStructure); + markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool"); + return true; + } + + global.TarinaiStructureLifecycle = Object.freeze({ + markWorldAfterStructureChange, + releaseStructureUsers, + deleteStructure, + consumeGrassBedOnWake, + deleteItem, + }); +})(window); diff --git a/js/structures.js b/js/structures.js index 7242b7c..805bc65 100644 --- a/js/structures.js +++ b/js/structures.js @@ -72,9 +72,9 @@ this.amount = Math.max(0.001, this.hp); } - use(user, worldRef = this.world) { + use(user, worldRef = this.world, options = {}) { const definition = global.StructureRegistry?.get?.(this.type); - return definition?.onUse?.(user, this, worldRef) || false; + return definition?.onUse?.(user, this, worldRef, options) || false; } damage(amount = 1, worldRef = this.world, breaker = null) { @@ -191,11 +191,11 @@ buildNeed: "fulfill", buildTime: 4, maxHp: 100, - materials: { grassMaterial: 1 }, + materials: { grassMaterial: 4 }, roles: { sleepPlace: true, ownedStructure: true }, needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 }, create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, - onUse(user, structure, worldRef) { + onUse(user, structure, worldRef, options = {}) { if (user?.id && !structure.ownerId) { structure.ownerId = user.id; structure.ownerName = user.name || ""; @@ -207,6 +207,18 @@ const ownerUse = user?.id && user.id === structure.ownerId; if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2); structure.usedCount = (structure.usedCount || 0) + 1; + if (options?.purpose === "sleep" || options?.sleep === true) { + // かんたんベッドは「寝た瞬間」ではなく「起きた時」に消える。 + // 睡眠中は寝場所として残すことで、見た目とホバー情報が自然になる。 + structure.singleUsePending = true; + structure.singleUseConsumedById = user?.id || ""; + structure.singleUseConsumedAt = worldRef?.time || 0; + structure.singleUseWakeReason = "sleep"; + structure.amount = Math.max(0.001, structure.hp || structure.amount || 1); + worldRef?.markItemBucketsDirty?.("grass-bed-sleep-reserved"); + worldRef?.markTerrainDirty?.("grass-bed-sleep-reserved"); + worldRef?.emit?.("structure:useReserved", { structure, user }); + } return true; }, onDestroyed(structure, world, breaker) { diff --git a/js/tarinai.js b/js/tarinai.js index c2eee89..749c5c6 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -16,18 +16,17 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) { 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.familyKey = opts.familyKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); this.id = opts.id || ""; this.liveToken = opts.liveToken || 0; this.name = opts.name || makeName(world); - // Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model. this.personality = ""; this.type = opts.type || baseSpriteId(); const typeMeta = SPRITES.find(s => s.id === this.type); if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); - this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth); - if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.familyKey); - this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality); + this.birthPersonality = normalizePersonality(opts.birthPersonality); + if (!opts.birthPersonality) this.birthPersonality = randomBirthPersonality(this.familyKey); + this.currentPersonality = normalizePersonality(opts.currentPersonality || this.birthPersonality); this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null; ensurePersonality(this); this.genetics = normalizeGenetics(opts.genetics, this.familyKey); @@ -77,7 +76,7 @@ class Tarinai { constructor(world, opts = {}) { this.behaviorSerial = opts.behaviorSerial ?? 0; this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function" ? globalThis.normalizeTarinaiBehaviorState(opts, this) - : (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : (opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null)); + : (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : null); this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : []; this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {}; this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {}; @@ -87,6 +86,7 @@ class Tarinai { constructor(world, opts = {}) { this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); this.dead = !!opts.dead; + this.lifeStatus = opts.lifeStatus || (this.dead ? "dead" : "alive"); this.deathReason = opts.deathReason || ""; this.lastDamageCause = opts.lastDamageCause || ""; this.lastDamageAmount = opts.lastDamageAmount ?? 0; diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js new file mode 100644 index 0000000..cedbb29 --- /dev/null +++ b/js/tarinai_action_definitions.js @@ -0,0 +1,558 @@ +"use strict"; + +// ActionSpec factories and TARINAI_ACTIONS registry. + +function selectRoleTarget(role, range) { + return function selectTarget(tarinai, world) { + return findNearestItemWithRole(world, tarinai, role, range); + }; +} + +function startMoveToTarget(state, fallbackLabel = "行動している") { + return function start(tarinai, world, ctx = {}) { + const target = ctx.target ?? this.selectTarget?.(tarinai, world, ctx) ?? null; + return moveToOrUse(tarinai, target, state, this.label || fallbackLabel); + }; +} + +function selectOwnedStructure(type, range = Infinity) { + return function selectTarget(tarinai, world) { + return findOwnedStructure(world, tarinai, type, range); + }; +} + +function selectMateTarget(tarinai, world, range = 440) { + return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null; +} + +function basicCanStartWithTarget(t, world, ctx = {}) { + return !!(ctx.target ?? this.selectTarget?.(t, world, ctx)); +} + +function createConsumableActionSpec({ id, subNeed = "", state, label, phrase, weight, role, range }) { + return { + id, + need: role === "medicine" ? "health" : "food", + subNeed, + state, + label, + phrase, + weight, + timerBacked: true, + selectTarget: selectRoleTarget(role, range), + canStart: basicCanStartWithTarget, + start: startMoveToTarget(state, label), + tick(t, world, dt) { return updateConsumableBehavior(t, world, dt, role); }, + finish(t) { + if (role === "drink" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: 8, health: 2 }, "drink"); + return true; + }, + fail(t) { + if (t) t.target = null; + return false; + }, + }; +} + +function createSleepInBedActionSpec() { + return { + id: "sleep_in_bed", + need: "sleep", + state: "seek_bed", + label: "寝る場所を探している", + phrase: "寝る場所へ戻った", + weight: 42, + timerBacked: true, + selectTarget(t, world) { return findNearestSleepPlace(world, t, 760); }, + canStart: basicCanStartWithTarget, + start(t, world, ctx = {}) { + const bed = ctx.target ?? this.selectTarget(t, world, ctx); + if (!bed) return false; + if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); + const isEasyBed = bed.type === "grass_bed"; + bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined); + t.startSleeping?.(bed, bed.type === "nest_box" ? "巣箱の中で寝ている" : (isEasyBed ? "かんたんベッドで寝ている" : "ベッドで寝ている")); + return true; + }, + tick(t) { + if (t?.state === "sleep" || t?.sleeping) return true; + return basicCanStartWithTarget.call(this, t, t?.world || null, {}) ? true : false; + }, + }; +} + +function createSleepBuildGrassBedActionSpec() { + return { + id: "sleep_build_grass_bed", + need: "sleep", + subNeed: "bed", + state: "build", + label: "寝る場所がないのでかんたんベッドを作っている", + phrase: "かんたんベッドを作った", + weight: 18, + timerBacked: true, + selectTarget: selectRoleTarget("grassMaterial", 520), + canStart(t, world, ctx = {}) { + return canBuildOwnedStructureByAge(t) + && !findNearestSleepPlace(world, t, 760) + && !findOwnedStructure(world, t, "grass_bed") + && !!(ctx.target ?? this.selectTarget(t, world, ctx)) + && !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state); + }, + start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, + tick(t, world, dt) { + if (findOwnedStructure(world, t, "grass_bed")) return "finished"; + return continueBuildPlan(t, world, dt) ? true : false; + }, + }; +} + +function createSleepAnywhereActionSpec() { + return { + id: "sleep_anywhere", + need: "sleep", + state: "sleep", + label: "なにもない所で寝ている", + phrase: "そのまま寝た", + weight: 8, + timerBacked: true, + canStart(t, world) { + if (findNearestSleepPlace(world, t, 760)) { + if (t) t.noBedNoBuildSince = NaN; + return false; + } + const now = world?.time || 0; + const failedRecently = (t?.lastFailedGrassBedBuildAt || -999) + 14 > now; + const buildTarget = this.selectBuildTarget ? this.selectBuildTarget(t, world, {}) : findNearbyMaterial(world, t, "grassMaterial", 520); + const canTryBuild = canBuildOwnedStructureByAge(t) && !!buildTarget && !(Number(t?.actionIdCooldowns?.sleep_build_grass_bed || 0) > 0.01); + if (canTryBuild && !failedRecently) { + if (t) t.noBedNoBuildSince = NaN; + return false; + } + if (t && !Number.isFinite(t.noBedNoBuildSince)) t.noBedNoBuildSince = now; + const since = Number.isFinite(t?.noBedNoBuildSince) ? t.noBedNoBuildSince : now; + const waited = now - since; + const urgent = (t?.energy || 100) < 18 || (t?.needRaw?.sleep || t?.needs?.sleep || 0) >= 92; + return failedRecently || urgent || waited >= 6.0; + }, + selectBuildTarget: selectRoleTarget("grassMaterial", 520), + start(t) { t.startSleeping?.(null, this.label); return true; }, + tick(t) { return (t?.state === "sleep" || t?.sleeping) ? true : false; }, + }; +} + +function createRestActionSpec() { + return { + id: "rest_to_recover", + need: "health", + state: "idle", + label: "元気になるまで休んでいる", + phrase: "休んだ", + weight: 24, + canStart() { return true; }, + start(t) { + t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); + t.energy = clamp((t.energy || 0) + 1.6, 0, 100); + return true; + }, + tick(t) { + t.energy = clamp((t.energy || 0) + 0.8, 0, 100); + return true; + }, + }; +} + +function createSunbathActionSpec() { + return { + id: "sunbath", + need: "health", + subNeed: "sunbath", + state: "sunbath", + label: "日光浴している", + phrase: "日光浴している", + weight: 32, + timerBacked: true, + canStart(t, world, ctx = {}) { + const healthNeed = Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0; + return (ctx.leisure || healthNeed >= needThreshold("health", "start")) + && !!t?.canStartSunbath?.({ leisure: !!ctx.leisure }); + }, + start(t, world, ctx = {}) { + return !!t?.startSunbath?.({ leisure: !!ctx.leisure }); + }, + tick(t) { + return (t?.sunbathTimer || 0) > 0.04 ? true : "finished"; + }, + finish(t) { + if ((t?.sunbathTimer || 0) <= 0 && t?.state === "sunbath") t.finishSunbath?.(); + return true; + }, + fail(t) { + if (t && t.state === "sunbath") { + t.sunbathTimer = 0; + t.goIdle?.("日光浴をやめた"); + } + return false; + }, + }; +} + +function createWanderActionSpec() { + function targetFor(t, world) { + const radius = rand(80, 180); + return { + x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)), + y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)), + dead: false, + detour: true, + }; + } + return { + id: "wander_lightly", + need: "fulfill", + state: "wander", + label: "少し歩いている", + phrase: "少し歩いた", + weight: 14, + canStart() { return true; }, + selectTarget: targetFor, + start(t, world, ctx = {}) { + t.wanderAngle += rand(-1.2, 1.2); + const target = ctx.target && Number.isFinite(ctx.target.x) ? ctx.target : targetFor(t, world); + t.wanderTarget = target; + t.setActionState?.("wander", { target, reason: this.label, sleeping: false }); + return true; + }, + tick(t, world) { + const target = t?.wanderTarget || currentBehaviorTarget(t) || null; + if (!target || !Number.isFinite(target.x) || dist(t, target) <= Math.max(28, (t.radius || 20) + 10)) return "finished"; + moveToOrUse(t, target, "wander", this.label); + return true; + }, + }; +} + +function selectFightRivalTarget(tarinai, world, range = 360) { + const candidate = conflictTargetFor(tarinai, world, (tarinai?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(tarinai) ? 18 : 34); + const current = currentBehaviorTarget(tarinai); + return isLiveTarinaiEntity(candidate?.target) + ? candidate.target + : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(tarinai, world, isCurrentBehaviorForced(tarinai) ? 760 : range)); +} + + +function createPanicEscapeActionSpec() { + return { + id: "panic_escape", + need: "safety", + subNeed: "panic", + state: "panic", + label: "怖くて逃げている", + phrase: "逃げた", + weight: 62, + timerBacked: true, + selectTarget(t, world) { + return t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.liveTarinaiById?.(t.defeatedById) : null); + }, + canStart(t, world, ctx = {}) { + const threat = ctx.target ?? this.selectTarget(t, world, ctx); + const safety = Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) || 0; + // Do not start panic from ordinary safety pressure alone. Panic needs a + // concrete trigger, lingering fear, defeat, or an extreme safety spike. + return !!threat + || (t?.defeatedTimer || 0) > 0.04 + || (safety >= 92 && !!activePanicBreaker(t, world, 260)); + }, + start(t, world, ctx = {}) { + const threat = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false }); + t.fearTimer = Math.max(t.fearTimer || 0, 1.6); + return true; + }, + tick: updatePanicBehavior, + }; +} + +function createFleeActionSpec() { + return { + id: "flee", + need: "safety", + subNeed: "avoid", + state: "flee", + label: "危ないものから離れている", + phrase: "離れた", + weight: 40, + selectTarget(t, world) { return findNearbyDanger(world, t, 220); }, + canStart: basicCanStartWithTarget, + start(t, world, ctx = {}) { + const danger = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); + return true; + }, + tick: updatePanicBehavior, + }; +} + +function createApproachFriendActionSpec() { + return { + id: "approach_friend", + need: "social", + subNeed: "bond", + state: "seek_friend", + label: "仲間に近づいている", + phrase: "仲間に近づいた", + weight: 46, + selectTarget(t, world) { return t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420) || null; }, + canStart: basicCanStartWithTarget, + start: startMoveToTarget("seek_friend", "仲間に近づいている"), + tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); }, + }; +} + +function createApproachFamilyActionSpec() { + return { + id: "approach_parent_or_child", + need: "social", + subNeed: "family", + state: "follow_parent", + label: "家族に近づいている", + phrase: "家族に近づいた", + weight: 28, + selectTarget(t) { return t.parentToFollow?.() || null; }, + canStart: basicCanStartWithTarget, + start: startMoveToTarget("follow_parent", "家族に近づいている"), + tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); }, + }; +} + +function createApproachMateActionSpec() { + return { + id: "approach_mate", + need: "social", + subNeed: "mate", + state: "seek_friend", + label: "繁殖できる相手に近づいている", + phrase: "繁殖できる相手に近づいている", + weight: 48, + selectTarget(t, world) { return selectMateTarget(t, world, 520); }, + canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start(t, world, ctx = {}) { + const other = ctx.target ?? this.selectTarget(t, world, ctx); + if (!other) return false; + if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; + return moveToOrUse(t, other, "seek_friend", this.label); + }, + tick: updateMateBehavior, + }; +} + +function createFightRivalActionSpec() { + return { + id: "fight_rival", + need: "social", + subNeed: "conflict", + state: "seek_enemy", + label: "気に入らない相手に向かっている", + phrase: "喧嘩を始めた", + weight: 18, + timerBacked: true, + selectTarget(t, world) { return selectFightRivalTarget(t, world); }, + canStart(t, world, ctx = {}) { + if ((t.fightCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; + const forced = (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t); + const candidate = conflictTargetFor(t, world, forced ? 24 : 42); + if (!forced) { + const parts = t?.socialReasonParts || {}; + const conflict = Number(parts.conflict || 0) || 0; + const socialPull = Math.max(Number(parts.bond || 0) || 0, Number(parts.mate || 0) || 0, Number(parts.family || 0) || 0); + if (!candidate?.target || conflict < 30 || conflict < socialPull * 0.82) return false; + } + return !!(ctx.target ?? candidate?.target ?? this.selectTarget(t, world, ctx)); + }, + start(t, world, ctx = {}) { + const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 18 : 34); + const rival = ctx.target ?? this.selectTarget(t, world, ctx); + if (!isLiveTarinaiEntity(rival)) return false; + t.conflictTargetId = rival.id; + t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48); + if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label); + if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); + world.startConflict?.(t, rival); + return true; + }, + tick: updateFightBehavior, + }; +} + +function createIntimidateEnemyActionSpec() { + return { + id: "intimidate_enemy", + need: "social", + subNeed: "conflict", + state: "intimidate", + label: "気になる相手を威嚇している", + phrase: "威嚇した", + weight: 8, + timerBacked: true, + selectTarget(t) { return t.recentFightRival?.(80, 180) || null; }, + canStart(t, world, ctx = {}) { + if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; + const parts = t?.socialReasonParts || {}; + if ((t.fightMochiTimer || 0) <= 0.04 && Number(parts.conflict || 0) < 24) return false; + return basicCanStartWithTarget.call(this, t, world, ctx); + }, + start(t, world, ctx = {}) { + const rival = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); + t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85); + return true; + }, + tick(t, world) { + if ((t.intimidateTimer || 0) > 0.04) return true; + const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180); + if (!rival) return false; + return world?.startIntimidation?.(t, rival) || true; + }, + }; +} + +function createBirthRitualActionSpec() { + return { + id: "birth_ritual", + need: "social", + subNeed: "mate", + label: "繁殖の前ぶれをしている", + phrase: "繁殖の前ぶれをしている", + weight: 0, + state: "birth_ritual", + timerBacked: true, + canStart(t) { return (t.birthRitualTimer || 0) > 0.04; }, + start() { return true; }, + tick(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; }, + }; +} + +const TARINAI_ACTION_DEFINITIONS = [ + createConsumableActionSpec({ + id: "eat_food", + subNeed: "meal", + state: "seek_food", + label: "食べ物を探している", + phrase: "食べ物を食べている", + weight: 62, + role: "food", + range: 760, + }), + createConsumableActionSpec({ + id: "drink_water", + subNeed: "water", + state: "seek_water", + label: "水を探している", + phrase: "水を飲んだ", + weight: 24, + role: "drink", + range: 560, + }), + createSleepInBedActionSpec(), + createSleepBuildGrassBedActionSpec(), + createSleepAnywhereActionSpec(), + createConsumableActionSpec({ + id: "use_medicine", + subNeed: "medicine", + state: "seek_food", + label: "元気になるものを探している", + phrase: "元気になった", + weight: 34, + role: "medicine", + range: 620, + }), + createRestActionSpec(), + createSunbathActionSpec(), + createPanicEscapeActionSpec(), + createFleeActionSpec(), + { + id: "hide_at_owned_structure", + need: "safety", + state: "seek_bed", + label: "自分の場所に隠れている", + phrase: "自分の場所へ戻った", + weight: 30, + selectTarget: selectOwnedStructure("grass_bed", 620), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("seek_bed", "自分の場所に隠れている"), + }, + createApproachFriendActionSpec(), + createApproachFamilyActionSpec(), + createApproachMateActionSpec(), + createFightRivalActionSpec(), + createIntimidateEnemyActionSpec(), + { + id: "play", + need: "fulfill", + state: "play_ball", + label: "遊んでいる", + phrase: "遊んだ", + weight: 24, + selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; }, + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("play_ball", "遊んでいる"), + }, + { + id: "build_grass_bed", + need: "fulfill", + state: "build", + label: "かんたんベッドを作っている", + phrase: "かんたんベッドを作った", + weight: 34, + selectTarget: selectRoleTarget("grassMaterial", 520), + canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, + start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, + }, + { + id: "build_plushie", + need: "fulfill", + state: "build", + label: "ぬいぐるみを作っている", + phrase: "ぬいぐるみを作った", + weight: 28, + selectTarget: selectRoleTarget("grassMaterial", 520), + canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, + start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); }, + }, + { + id: "return_owned_structure", + need: "fulfill", + state: "seek_bed", + label: "自分の場所へ戻っている", + phrase: "自分の場所へ戻った", + weight: 22, + selectTarget: selectOwnedStructure("grass_bed", 620), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("seek_bed", "自分の場所へ戻っている"), + }, + { + id: "use_plushie", + need: "fulfill", + state: "idle", + label: "ぬいぐるみを抱えている", + phrase: "ぬいぐるみを抱えた", + weight: 18, + selectTarget: selectOwnedStructure("plushie", Infinity), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start(t, world, ctx = {}) { + const plushie = ctx.target ?? this.selectTarget(t, world, ctx); + plushie?.use?.(t, world); + t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); + return true; + }, + }, + createBirthRitualActionSpec(), + createWanderActionSpec(), +]; + +const TARINAI_ACTIONS = typeof registerTarinaiActionSpecs === "function" + ? registerTarinaiActionSpecs(TARINAI_ACTION_DEFINITIONS) + : TARINAI_ACTION_DEFINITIONS; + + +if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINITIONS, TARINAI_ACTIONS }); diff --git a/js/tarinai_action_spec.js b/js/tarinai_action_spec.js index 68a3e32..98d604e 100644 --- a/js/tarinai_action_spec.js +++ b/js/tarinai_action_spec.js @@ -41,8 +41,6 @@ if (!id) throw new TypeError("ActionSpec.id is required"); // Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text. - // Legacy hooks (`condition`, `run`, `update`) remain accepted so old code can be - // migrated incrementally, but newly migrated actions should use the lifecycle names. const spec = { ...input, id, @@ -59,12 +57,9 @@ }; const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null; - const originalCondition = hasOwnFn(input, "condition") ? input.condition : null; const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null; const originalStart = hasOwnFn(input, "start") ? input.start : null; - const originalRun = hasOwnFn(input, "run") ? input.run : null; const originalTick = hasOwnFn(input, "tick") ? input.tick : null; - const originalUpdate = hasOwnFn(input, "update") ? input.update : null; const originalFinish = hasOwnFn(input, "finish") ? input.finish : null; const originalFail = hasOwnFn(input, "fail") ? input.fail : null; const originalText = hasOwnFn(input, "text") ? input.text : null; @@ -76,19 +71,16 @@ spec.canStart = function canStart(tarinai, world, ctx = {}) { if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx)); - if (originalCondition) return Boolean(originalCondition.call(spec, tarinai, world, ctx)); return true; }; spec.start = function start(tarinai, world, ctx = {}) { if (originalStart) return originalStart.call(spec, tarinai, world, ctx); - if (originalRun) return originalRun.call(spec, tarinai, world, ctx); return true; }; spec.tick = function tick(tarinai, world, dt = 0, needs = null, ctx = {}) { if (originalTick) return originalTick.call(spec, tarinai, world, dt, needs, ctx); - if (originalUpdate) return originalUpdate.call(spec, tarinai, world, dt, needs, ctx); return undefined; }; diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index 91a0462..11e1294 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -7,11 +7,14 @@ Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ setActionState(state, opts = {}) { if (this.dead) return false; + const wasSleeping = this.state === "sleep" || this.sleeping; const nextState = state || "idle"; this.state = nextState; if ("target" in opts) this.target = opts.target; if (opts.reason != null) this.thought = String(opts.reason || ""); if (opts.clearTarget) this.target = null; + const wakingNow = Boolean(opts.wake) || (wasSleeping && nextState !== "sleep" && opts.sleeping === false); + if (wakingNow) this.consumePendingGrassBedOnWake?.(opts.reason || "wake"); if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping); if (opts.wake) { this.sleeping = false; @@ -20,6 +23,63 @@ this.insideNestBoxId = ""; } if (nextState !== "sleep" && opts.sleeping === false) this.sleepSession = null; + + const reason = String(opts.reason ?? this.thought ?? ""); + if (nextState === "idle") { + if (typeof clearBehavior === "function") clearBehavior(this, reason || "待っている"); + else if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { clearTarget: !!opts.clearTarget }); + return true; + } + + const stateActionIds = { + seek_food: "eat_food", + eat: "eat_food", + seek_water: "drink_water", + seek_bed: "sleep_in_bed", + sleep: "sleep_anywhere", + panic: "panic_escape", + flee: "flee", + fight: "fight_rival", + intimidate: "intimidate_enemy", + birth_ritual: "birth_ritual", + sunbath: "sunbath", + wander: "wander_lightly", + play_ball: "play", + seek_material: "seek_material", + seek_enemy: "fight_rival", + build: "build_structure", + }; + const actionId = String(opts.actionId || opts.behaviorId || stateActionIds[nextState] || nextState); + const target = opts.clearTarget ? null : ("target" in opts ? opts.target : this.target); + const phase = opts.phase || (nextState === "sleep" || nextState === "eat" || nextState === "fight" || nextState === "intimidate" || nextState === "birth_ritual" || nextState === "sunbath" ? "acting" : "approaching"); + if (typeof setBehaviorText === "function") { + setBehaviorText(this, { + need: opts.need || null, + subNeed: opts.subNeed || "", + actionId, + actionLabel: opts.actionLabel || reason || nextState, + reasonText: reason || opts.actionLabel || nextState, + target, + phase, + source: opts.source || "state", + forced: opts.forced || null, + forcedId: opts.forcedId || "", + sourceReasonText: opts.sourceReasonText || opts.forcedReasonText || "", + causeText: opts.causeText || "", + }); + } else if (typeof patchTarinaiBehavior === "function") { + patchTarinaiBehavior(this, { + actionId, + phase, + target, + source: opts.source || "state", + reason, + label: opts.actionLabel || reason || nextState, + text: reason || opts.actionLabel || nextState, + need: opts.need || "fulfill", + subNeed: opts.subNeed || "", + }); + } return true; }, @@ -30,7 +90,7 @@ reason: text, sleeping: false, }); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" }); return ok; }, @@ -48,8 +108,7 @@ }, goIdle(reason = "") { - if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b"); - return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", sleeping: false }); + return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", wake: this.state === "sleep" || this.sleeping, sleeping: false }); }, startSleeping(target = null, reason = "") { @@ -62,13 +121,38 @@ targetEnergy: inFurniture ? rand(84, 94) : rand(74, 86), maxDuration: inFurniture ? rand(42, 68) : rand(28, 48), targetId: target?.id || null, + consumesGrassBedOnWake: target?.type === "grass_bed", }; } + if (target?.type === "grass_bed") { + this.sleepSession.targetId = target.id || this.sleepSession.targetId || null; + this.sleepSession.consumesGrassBedOnWake = true; + this.pendingGrassBedWakeId = target.id || this.pendingGrassBedWakeId || ""; + target.singleUsePending = true; + target.singleUseConsumedById = this.id || target.singleUseConsumedById || ""; + target.singleUseWakeReason = "sleep"; + } const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" }); return this.setActionState("sleep", { target, reason: text, sleeping: true }); }, + consumePendingGrassBedOnWake(reason = "wake") { + const worldRef = this.world || null; + const sessionTargetId = this.sleepSession?.targetId || this.pendingGrassBedWakeId || null; + const bySession = sessionTargetId && worldRef?.itemById ? worldRef.itemById(sessionTargetId) : null; + const byItems = sessionTargetId ? (worldRef?.items || []).find(it => it && it.id === sessionTargetId) : null; + const candidates = [this.target, bySession, byItems]; + let bed = candidates.find(it => it && !it.dead && it.type === "grass_bed" && (it.singleUsePending || it.singleUseConsumedById === this.id || (this.sleepSession?.consumesGrassBedOnWake && it.id === sessionTargetId))); + if (!bed && (this.sleepSession?.consumesGrassBedOnWake || sessionTargetId)) { + bed = (worldRef?.items || []).find(it => it && !it.dead && it.type === "grass_bed" + && (it.id === sessionTargetId || (it.singleUsePending && (it.singleUseConsumedById === this.id || it.ownerId === this.id)))); + } + if (!bed) return false; + this.pendingGrassBedWakeId = ""; + return window.TarinaiStructureLifecycle?.consumeGrassBedOnWake?.(worldRef, bed, this, reason) || false; + }, + seekTarget(state, target = null, reason = "") { return this.setActionState(state, { target, reason, sleeping: false }); }, @@ -79,7 +163,7 @@ }, currentBehaviorText() { - if (typeof activeBehaviorText === "function") return activeBehaviorText(this); + if (typeof behaviorText === "function") return behaviorText(this); return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim(); }, @@ -111,9 +195,9 @@ wake: opts.wake !== false, sleeping: false, }); - if (typeof setLiveActionText === "function") { + if (typeof setBehaviorText === "function") { const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044"); - setLiveActionText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" }); + setBehaviorText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" }); } this.fearTimer = Math.max(this.fearTimer || 0, 2.4); } @@ -127,6 +211,8 @@ if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)"); this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic"; this.lastPanicAt = this.world?.time || 0; + if (this.state === "panic" && !Number.isFinite(this.panicStartedAt)) this.panicStartedAt = this.lastPanicAt; + if (this.state === "panic") this.panicHardStopAt = Math.max(this.panicHardStopAt || 0, this.lastPanicAt + 7.5); return true; }, })); diff --git a/js/tarinai_behavior_state.js b/js/tarinai_behavior_state.js index 9eada82..1729b37 100644 --- a/js/tarinai_behavior_state.js +++ b/js/tarinai_behavior_state.js @@ -4,8 +4,10 @@ const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js"); + const PHASES = new Set(["starting", "seeking", "approaching", "acting", "recovering", "finished", "failed", "idle", "start", "active", "perform"]); + function isObject(value) { - return Boolean(value && typeof value === "object"); + return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function finiteOr(value, fallback) { @@ -13,30 +15,41 @@ return Number.isFinite(n) ? n : fallback; } - function localBehaviorTargetId(target = null) { - if (!target) return null; - if (target.id != null) return target.id; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; - return null; - } - function nowFor(tarinai = null) { return finiteOr(tarinai?.world?.time, 0); } - function hidden(obj, key, value) { - if (!obj) return value; - Object.defineProperty(obj, key, { - value, - writable: true, - configurable: true, - enumerable: false, - }); - return value; + function normalizePhase(value, fallback = "acting") { + const phase = String(value || fallback || "acting"); + return PHASES.has(phase) ? phase : phase; } - function actionIdFrom(input = {}, legacyIntent = null, legacyAction = null, previous = null) { - return String(input.id || input.actionId || legacyIntent?.actionId || legacyAction?.id || previous?.id || ""); + function targetIdFor(target = null) { + if (!target) return null; + if (target.id != null) return String(target.id); + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; + return null; + } + + function normalizeBehaviorTarget(input = undefined, fallback = undefined) { + const target = input !== undefined ? input : fallback; + if (!target) return null; + if (target.kind) { + const out = { kind: String(target.kind) }; + if (target.id != null) out.id = String(target.id); + if (Number.isFinite(Number(target.x))) out.x = Number(target.x); + if (Number.isFinite(Number(target.y))) out.y = Number(target.y); + return out; + } + if (target.id != null) { + const type = String(target.type || ""); + const kind = target.kind === "tarinai" || target.familyKey || type === "tarinai" ? "tarinai" : "item"; + return { kind, id: String(target.id) }; + } + if (Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y))) { + return { kind: "position", x: Number(target.x), y: Number(target.y) }; + } + return null; } function normalizeForcedRequestValue(value = null, fallback = null) { @@ -45,13 +58,13 @@ const uid = String(src.uid || src.forcedId || prev.uid || prev.forcedId || ""); const id = String(src.id || src.actionId || prev.id || prev.actionId || ""); if (!uid && !id) return null; - const request = { + return { uid, id, source: String(src.source || prev.source || "external"), priority: finiteOr(src.priority, finiteOr(prev.priority, 0)), status: String(src.status || prev.status || "active"), - reasonText: String(src.reasonText || src.forcedReasonText || prev.reasonText || prev.forcedReasonText || ""), + reasonText: String(src.reasonText || src.reason || prev.reasonText || prev.reason || ""), causeText: String(src.causeText || prev.causeText || ""), targetId: src.targetId !== undefined ? src.targetId : (prev.targetId !== undefined ? prev.targetId : null), createdAt: finiteOr(src.createdAt, finiteOr(prev.createdAt, 0)), @@ -63,148 +76,85 @@ attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)), failReason: String(src.failReason || prev.failReason || ""), }; - return request; + } + + function isBehaviorForcedValue(behavior = null) { + if (!isObject(behavior)) return false; + return Boolean(behavior.source === "forced" || behavior.forcedId || behavior.forcedRequest || behavior.data?.forcedSource || behavior.forced === true); + } + + function forcedBehaviorSource(behavior = null) { + if (!isObject(behavior)) return ""; + return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || ""); } function normalizeBehaviorValue(input, tarinai = null, previous = null) { if (!isObject(input)) return null; - const legacyIntent = isObject(input.intent) ? input.intent : null; - const legacyAction = isObject(input.currentAction) ? input.currentAction : null; const now = nowFor(tarinai); - const id = actionIdFrom(input, legacyIntent, legacyAction, previous) || String(tarinai?.state || "idle"); - const need = String(input.need || legacyIntent?.need || legacyAction?.need || previous?.need || "fulfill"); - const label = String(input.label || input.actionLabel || legacyIntent?.actionLabel || previous?.label || id || "行動している"); - const reasonText = String(input.reasonText || input.presentText || legacyIntent?.reasonText || previous?.reasonText || label || ""); - const target = input.target !== undefined ? input.target : tarinai?.target; - const startedAt = finiteOr(input.startedAt ?? legacyIntent?.startedAt ?? legacyAction?.startedAt, finiteOr(previous?.startedAt, now)); - const minDuration = finiteOr(input.minDuration ?? legacyAction?.minDuration, finiteOr(previous?.minDuration, 0.45)); - const lockSeconds = finiteOr(input.lockSeconds ?? legacyAction?.lockSeconds, finiteOr(previous?.lockSeconds, 1.0)); - const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length - ? [...input.tiedNeeds] - : (Array.isArray(legacyIntent?.tiedNeeds) && legacyIntent.tiedNeeds.length - ? [...legacyIntent.tiedNeeds] - : (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need])); - const behavior = { - id, - need, - subNeed: String(input.subNeed || legacyAction?.subNeed || previous?.subNeed || ""), - phase: String(input.phase || legacyAction?.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "active")), - state: String(input.state || tarinai?.state || previous?.state || "idle"), - label, - presentText: String(input.presentText || reasonText || ""), - reasonText, - targetId: input.targetId !== undefined ? input.targetId : localBehaviorTargetId(target), - tiedNeeds, - startedAt, - updatedAt: finiteOr(input.updatedAt, now), - minDuration, - lockSeconds, - deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt, - source: String(input.source || previous?.source || "need"), - sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || previous?.sourceReasonText || previous?.forcedReasonText || ""), - forcedReasonText: String(input.forcedReasonText || input.sourceReasonText || previous?.forcedReasonText || previous?.sourceReasonText || ""), - causeText: String(input.causeText || legacyIntent?.causeText || previous?.causeText || ""), - forced: Boolean(input.forced ?? previous?.forced ?? false), - forcedId: String(input.forcedId || previous?.forcedId || ""), - priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)), - specId: String(input.specId || input.actionSpecId || previous?.specId || id), - specKind: String(input.specKind || previous?.specKind || "need_action"), - }; + const previousActionId = previous?.actionId || ""; + const actionId = String(input.actionId || previousActionId || tarinai?.state || "idle"); + const targetInput = input.target !== undefined ? input.target : (input.targetRef !== undefined ? input.targetRef : undefined); + const target = normalizeBehaviorTarget(targetInput, tarinai?.target); const forcedRequest = normalizeForcedRequestValue( input.forcedRequest || input.request || null, - behavior.forced || behavior.forcedId || previous?.forcedRequest - ? { ...(previous?.forcedRequest || {}), uid: behavior.forcedId || previous?.forcedRequest?.uid || "", id: behavior.id, source: behavior.source, priority: behavior.priority, reasonText: behavior.forcedReasonText || behavior.sourceReasonText, causeText: behavior.causeText, targetId: behavior.targetId } + (input.forced || input.forcedId || previous?.forcedRequest) + ? { ...(previous?.forcedRequest || {}), uid: input.forcedId || previous?.forcedId || previous?.forcedRequest?.uid || "", id: actionId, source: input.sourceDetail || input.source || previous?.data?.forcedSource || previous?.forcedRequest?.source || "external", priority: input.priority ?? previous?.priority ?? 0, reasonText: input.sourceReasonText || input.forcedReasonText || input.reason || input.reasonText || previous?.sourceReasonText || previous?.forcedRequest?.reasonText || "", causeText: input.causeText || previous?.causeText || "", targetId: input.targetId ?? targetIdFor(target) } : null ); - behavior.forcedRequest = forcedRequest; - if (forcedRequest) { - behavior.forced = true; - behavior.forcedId = forcedRequest.uid || behavior.forcedId; - behavior.source = forcedRequest.source || behavior.source || "external"; - behavior.priority = finiteOr(forcedRequest.priority, behavior.priority); - behavior.forcedReasonText = behavior.forcedReasonText || forcedRequest.reasonText || ""; - behavior.sourceReasonText = behavior.sourceReasonText || forcedRequest.reasonText || behavior.forcedReasonText || ""; - behavior.causeText = behavior.causeText || forcedRequest.causeText || ""; - } + const forced = Boolean(forcedRequest || input.forced || input.forcedId || previous?.forcedId && input.source === "forced"); + const source = forced ? "forced" : String(input.source || previous?.source || "need"); + const data = isObject(previous?.data) ? { ...previous.data } : {}; + if (isObject(input.data)) Object.assign(data, input.data); + if (forcedRequest) data.forcedSource = forcedRequest.source || data.forcedSource || "external"; + else if (forced && input.source && input.source !== "forced") data.forcedSource = String(input.source); + + const reason = String(input.reason ?? input.reasonText ?? input.presentText ?? previous?.reason ?? previous?.reasonText ?? previous?.presentText ?? input.label ?? previous?.label ?? ""); + const label = String(input.label || input.actionLabel || previous?.label || actionId || "行動している"); + const need = String(input.need || previous?.need || "fulfill"); + const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length + ? [...input.tiedNeeds].filter(Boolean).map(String) + : (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need]); + + const behavior = { + actionId, + phase: normalizePhase(input.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "acting")), + target, + source, + reason, + priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)), + startedAt: finiteOr(input.startedAt, finiteOr(previous?.startedAt, now)), + elapsed: finiteOr(input.elapsed, Math.max(0, now - finiteOr(input.startedAt ?? previous?.startedAt, now))), + timeout: Number.isFinite(Number(input.timeout ?? input.deadlineAt)) ? Number(input.timeout ?? input.deadlineAt) : (previous?.timeout ?? null), + interruptible: input.interruptible !== undefined ? input.interruptible !== false : previous?.interruptible !== false, + data, + need, + subNeed: String(input.subNeed || previous?.subNeed || ""), + tiedNeeds, + label, + text: String(input.text ?? input.presentText ?? input.reasonText ?? previous?.text ?? previous?.presentText ?? reason ?? label ?? ""), + lockSeconds: finiteOr(input.lockSeconds, finiteOr(previous?.lockSeconds, 1.0)), + minDuration: finiteOr(input.minDuration, finiteOr(previous?.minDuration, 0.45)), + deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt, + forcedId: forcedRequest?.uid || String(input.forcedId || previous?.forcedId || ""), + forcedRequest, + sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || forcedRequest?.reasonText || previous?.sourceReasonText || ""), + causeText: String(input.causeText || forcedRequest?.causeText || previous?.causeText || ""), + specId: String(input.specId || input.actionSpecId || previous?.specId || actionId), + specKind: String(input.specKind || previous?.specKind || "need_action"), + }; if (Number.isFinite(Number(input.serial))) behavior.serial = Number(input.serial); - return syncLegacyViews(behavior); - } - - function behaviorFromLegacyIntent(intent, currentAction = null, tarinai = null, previous = null) { - if (!isObject(intent) && !isObject(currentAction)) return null; - const now = nowFor(tarinai); - return normalizeBehaviorValue({ - id: currentAction?.id || intent?.actionId || tarinai?.state || "idle", - need: intent?.need || currentAction?.need || previous?.need || "fulfill", - subNeed: currentAction?.subNeed || previous?.subNeed || "", - phase: currentAction?.phase || previous?.phase || "active", - state: tarinai?.state || previous?.state || "idle", - label: intent?.actionLabel || previous?.label || "行動している", - presentText: intent?.reasonText || previous?.presentText || "", - reasonText: intent?.reasonText || previous?.reasonText || intent?.actionLabel || "", - causeText: intent?.causeText || previous?.causeText || "", - tiedNeeds: Array.isArray(intent?.tiedNeeds) && intent.tiedNeeds.length ? [...intent.tiedNeeds] : undefined, - startedAt: intent?.startedAt ?? currentAction?.startedAt ?? now, - minDuration: currentAction?.minDuration, - lockSeconds: currentAction?.lockSeconds, - priority: previous?.priority, - source: previous?.source || "legacy", - forced: previous?.forced, - forcedId: previous?.forcedId, - sourceReasonText: previous?.sourceReasonText, - forcedReasonText: previous?.forcedReasonText, - }, tarinai, previous); - } - - function syncLegacyViews(behavior) { - if (!isObject(behavior)) return null; - const intent = { - need: behavior.need || "fulfill", - tiedNeeds: Array.isArray(behavior.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [behavior.need || "fulfill"], - actionId: behavior.id || "idle", - actionLabel: behavior.label || behavior.presentText || behavior.reasonText || "行動している", - reasonText: behavior.reasonText || behavior.presentText || behavior.label || "", - causeText: behavior.causeText || "", - startedAt: finiteOr(behavior.startedAt, 0), - }; - const currentAction = { - id: behavior.id || "idle", - need: behavior.need || intent.need, - subNeed: behavior.subNeed || "", - startedAt: finiteOr(behavior.startedAt, 0), - minDuration: finiteOr(behavior.minDuration, 0.45), - lockSeconds: finiteOr(behavior.lockSeconds, 1.0), - phase: behavior.phase || "active", - specId: behavior.specId || behavior.id || "idle", - specKind: behavior.specKind || "need_action", - }; - const forcedRequest = normalizeForcedRequestValue(behavior.forcedRequest || null, - behavior.forced || behavior.forcedId ? { uid: behavior.forcedId || "", id: behavior.id || "", source: behavior.source || "external", priority: finiteOr(behavior.priority, 0), reasonText: behavior.forcedReasonText || behavior.sourceReasonText || "", causeText: behavior.causeText || "", targetId: behavior.targetId } : null); - behavior.forcedRequest = forcedRequest; - const activeForcedBehavior = forcedRequest - ? { uid: forcedRequest.uid || behavior.forcedId || "", id: forcedRequest.id || behavior.id || "", source: forcedRequest.source || behavior.source || "external", priority: finiteOr(forcedRequest.priority, finiteOr(behavior.priority, 0)) } - : null; - hidden(behavior, "_intent", intent); - hidden(behavior, "_currentAction", currentAction); - hidden(behavior, "_activeForcedBehavior", activeForcedBehavior); return behavior; } function normalizeBehaviorState(opts = {}, tarinai = null) { const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null; - let behavior = normalizeBehaviorValue(opts.behavior || opts.activeBehavior || null, tarinai, previous); - if (!behavior) behavior = behaviorFromLegacyIntent(opts.intent || null, opts.currentAction || null, tarinai, previous); - if (!behavior) return null; - if (isObject(opts.intent)) mergeIntent(tarinai, opts.intent, behavior); - if (isObject(opts.currentAction)) mergeCurrentAction(tarinai, opts.currentAction, behavior); - if (isObject(opts.activeForcedBehavior)) mergeActiveForcedBehavior(tarinai, opts.activeForcedBehavior, behavior); - return syncLegacyViews(behavior); + return normalizeBehaviorValue(opts.behavior || null, tarinai, previous); } function ensureBehavior(tarinai) { - if (!tarinai) return null; - if (isObject(tarinai.behavior)) return syncLegacyViews(tarinai.behavior); - return null; + if (!tarinai || !isObject(tarinai.behavior)) return null; + const b = tarinai.behavior; + return b; } function assignBehavior(tarinai, value) { @@ -214,31 +164,6 @@ return next; } - function getTarinaiBehavior(tarinai) { - return ensureBehavior(tarinai); - } - - function getTarinaiBehaviorId(tarinai, fallback = "") { - const b = ensureBehavior(tarinai); - return String(b?.id || fallback || ""); - } - - function getTarinaiBehaviorNeed(tarinai, fallback = "") { - const b = ensureBehavior(tarinai); - return String(b?.need || fallback || ""); - } - - function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") { - const b = ensureBehavior(tarinai); - if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds]; - return [String(b?.need || fallbackNeed || "fulfill")]; - } - - function getTarinaiBehaviorText(tarinai, fallback = "") { - const b = ensureBehavior(tarinai); - return String(b?.presentText || b?.reasonText || b?.label || fallback || "").trim(); - } - function patchTarinaiBehavior(tarinai, fields = {}) { if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai); const previous = ensureBehavior(tarinai) || {}; @@ -251,167 +176,71 @@ if (!tarinai) return null; tarinai.behavior = null; if (opts.clearTarget) tarinai.target = null; - if (opts.reason) tarinai.thought = String(opts.reason || ""); return null; } + function getTarinaiBehavior(tarinai) { return ensureBehavior(tarinai); } + function getTarinaiBehaviorId(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.actionId || fallback || ""); } + function getTarinaiBehaviorNeed(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.need || fallback || ""); } + function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") { + const b = ensureBehavior(tarinai); + if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds]; + return [String(b?.need || fallbackNeed || "fulfill")]; + } + function getTarinaiBehaviorText(tarinai, fallback = "") { + const b = ensureBehavior(tarinai); + return String(b?.text || b?.reason || b?.label || fallback || "").trim(); + } + function setTarinaiForcedBehavior(tarinai, value = null) { - return mergeActiveForcedBehavior(tarinai, value || null); - } - - function mergeIntent(tarinai, value, behavior = null) { - if (!tarinai || !isObject(value)) return null; - const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({ - id: value.actionId || tarinai.state || "idle", - need: value.need || "fulfill", - label: value.actionLabel || value.reasonText || "行動している", - reasonText: value.reasonText || value.actionLabel || "", - tiedNeeds: value.tiedNeeds, - causeText: value.causeText || "", - startedAt: value.startedAt, - source: "intent", - }, tarinai, null); - if (!b) return null; - if (value.actionId != null) b.id = String(value.actionId || b.id || "idle"); - if (value.need != null) b.need = String(value.need || b.need || "fulfill"); - if (value.actionLabel != null) b.label = String(value.actionLabel || b.label || ""); - if (value.reasonText != null) { - b.reasonText = String(value.reasonText || ""); - b.presentText = b.presentText || b.reasonText; - } - if (value.causeText != null) b.causeText = String(value.causeText || ""); - if (Array.isArray(value.tiedNeeds) && value.tiedNeeds.length) b.tiedNeeds = [...value.tiedNeeds]; - if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt); - if (value.specId != null) b.specId = String(value.specId || b.id || "idle"); - if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action"); - tarinai.behavior = syncLegacyViews(b); - return tarinai.behavior._intent; - } - - function mergeCurrentAction(tarinai, value, behavior = null) { - if (!tarinai || !isObject(value)) return null; - const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({ - id: value.id || tarinai.state || "idle", - need: value.need || "fulfill", - subNeed: value.subNeed || "", - startedAt: value.startedAt, - minDuration: value.minDuration, - lockSeconds: value.lockSeconds, - phase: value.phase, - source: "currentAction", - }, tarinai, null); - if (!b) return null; - if (value.id != null) b.id = String(value.id || b.id || "idle"); - if (value.need != null) b.need = String(value.need || b.need || "fulfill"); - if (value.subNeed != null) b.subNeed = String(value.subNeed || ""); - if (value.phase != null) b.phase = String(value.phase || b.phase || "active"); - if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt); - if (Number.isFinite(Number(value.minDuration))) b.minDuration = Number(value.minDuration); - if (Number.isFinite(Number(value.lockSeconds))) b.lockSeconds = Number(value.lockSeconds); - if (value.specId != null) b.specId = String(value.specId || b.id || "idle"); - if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action"); - tarinai.behavior = syncLegacyViews(b); - return tarinai.behavior._currentAction; - } - - function mergeActiveForcedBehavior(tarinai, value, behavior = null) { if (!tarinai) return null; - const b = behavior || ensureBehavior(tarinai); + const b = ensureBehavior(tarinai); if (!b) return null; if (!value) { - b.forced = false; - b.forcedId = ""; - b.forcedRequest = null; - b.sourceReasonText = b.sourceReasonText || ""; - b.forcedReasonText = b.forcedReasonText || ""; - tarinai.behavior = syncLegacyViews(b); - return null; + const data = isObject(b.data) ? { ...b.data } : {}; + delete data.forcedSource; + const next = normalizeBehaviorValue({ ...b, source: b.source === "forced" ? "need" : (b.source || "need"), forcedId: "", forcedRequest: null, sourceReasonText: "", data }, tarinai, null); + tarinai.behavior = next; + return next; } - const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.id || "idle", source: b.source || "external", priority: b.priority || 0, reasonText: b.forcedReasonText || b.sourceReasonText || "", causeText: b.causeText || "", targetId: b.targetId }); - b.forced = true; - b.forcedRequest = request; - b.forcedId = String(request?.uid || value.uid || value.forcedId || b.forcedId || ""); - if (request?.id || value.id != null) b.id = String(request?.id || value.id || b.id || "idle"); - b.source = String(request?.source || value.source || b.source || "external"); - b.priority = finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0))); - b.forcedReasonText = b.forcedReasonText || request?.reasonText || ""; - b.sourceReasonText = b.sourceReasonText || request?.reasonText || b.forcedReasonText || ""; - b.causeText = b.causeText || request?.causeText || ""; - tarinai.behavior = syncLegacyViews(b); - return tarinai.behavior._activeForcedBehavior; + const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.actionId || "idle", source: b.data?.forcedSource || "external", priority: b.priority || 0, reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "", targetId: targetIdFor(b.target) }); + return patchTarinaiBehavior(tarinai, { + ...b, + actionId: request?.id || value.actionId || value.id || b.actionId || "idle", + source: "forced", + priority: finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0))), + forcedId: String(request?.uid || value.uid || value.forcedId || b.forcedId || ""), + forcedRequest: request, + sourceReasonText: request?.reasonText || b.sourceReasonText || "", + causeText: b.causeText || request?.causeText || "", + data: { ...(b.data || {}), forcedSource: request?.source || value.source || "external" }, + }); } function getTarinaiForcedBehavior(tarinai) { const b = ensureBehavior(tarinai); - if (!b || !b.forced) return null; - return b.forcedRequest || b._activeForcedBehavior || null; + if (!isBehaviorForcedValue(b)) return null; + return b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: forcedBehaviorSource(b) || "external", priority: finiteOr(b.priority, 0), reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "" }; } - function isTarinaiBehaviorForced(tarinai) { - const b = ensureBehavior(tarinai); - return Boolean(b?.forced || b?.forcedId || b?.forcedRequest); - } - - Object.defineProperties(Tarinai.prototype, { - activeBehavior: { - configurable: true, - get() { - return ensureBehavior(this); - }, - set(value) { - if (!value) { clearTarinaiBehavior(this); return; } - assignBehavior(this, value); - }, - }, - intent: { - configurable: true, - get() { - const b = ensureBehavior(this); - return b ? b._intent : null; - }, - set(value) { - if (!value) { clearTarinaiBehavior(this); return; } - mergeIntent(this, value); - }, - }, - currentAction: { - configurable: true, - get() { - const b = ensureBehavior(this); - return b ? b._currentAction : null; - }, - set(value) { - if (!value) { clearTarinaiBehavior(this); return; } - mergeCurrentAction(this, value); - }, - }, - activeForcedBehavior: { - configurable: true, - get() { - const b = ensureBehavior(this); - return b ? b._activeForcedBehavior : null; - }, - set(value) { - mergeActiveForcedBehavior(this, value || null); - }, - }, + Object.assign(global, { + normalizeTarinaiBehaviorState: normalizeBehaviorState, + normalizeTarinaiBehaviorValue: normalizeBehaviorValue, + getTarinaiBehavior, + currentTarinaiBehavior: getTarinaiBehavior, + getTarinaiBehaviorId, + getTarinaiBehaviorNeed, + getTarinaiBehaviorTiedNeeds, + getTarinaiBehaviorText, + patchTarinaiBehavior, + setTarinaiBehavior: assignBehavior, + clearTarinaiBehavior, + normalizeTarinaiForcedRequest: normalizeForcedRequestValue, + getTarinaiForcedBehavior, + setTarinaiForcedBehavior, + clearTarinaiForcedBehavior: function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); }, + isTarinaiBehaviorForced: function isTarinaiBehaviorForced(tarinai) { return isBehaviorForcedValue(ensureBehavior(tarinai)); }, + isTarinaiBehaviorValueForced: isBehaviorForcedValue, + tarinaiBehaviorForcedSource: forcedBehaviorSource, }); - - global.normalizeTarinaiBehaviorState = normalizeBehaviorState; - global.normalizeTarinaiBehaviorValue = normalizeBehaviorValue; - global.syncTarinaiBehaviorLegacyViews = syncLegacyViews; - global.getTarinaiBehavior = getTarinaiBehavior; - global.currentTarinaiBehavior = getTarinaiBehavior; - global.getTarinaiBehaviorId = getTarinaiBehaviorId; - global.getTarinaiBehaviorNeed = getTarinaiBehaviorNeed; - global.getTarinaiBehaviorTiedNeeds = getTarinaiBehaviorTiedNeeds; - global.getTarinaiBehaviorText = getTarinaiBehaviorText; - global.patchTarinaiBehavior = patchTarinaiBehavior; - global.setTarinaiBehavior = assignBehavior; - global.clearTarinaiBehavior = clearTarinaiBehavior; - global.normalizeTarinaiForcedRequest = normalizeForcedRequestValue; - global.getTarinaiForcedBehavior = getTarinaiForcedBehavior; - global.isTarinaiBehaviorForced = isTarinaiBehaviorForced; - global.setTarinaiForcedBehavior = setTarinaiForcedBehavior; - global.clearTarinaiForcedBehavior = function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js new file mode 100644 index 0000000..b787924 --- /dev/null +++ b/js/tarinai_behavior_text.js @@ -0,0 +1,168 @@ +"use strict"; + +function fulfillPrimaryReason(tarinai, action = null) { + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const actionId = action?.id || behavior?.actionId || ""; + if (actionId === "build_grass_bed") return "\u5bdd\u5e8a\u304c\u307b\u3057\u3044"; + if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044"; + if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"; + if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044"; + if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b"; + if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b"; + + const parts = tarinai?.fulfillReasonParts || {}; + const candidates = [ + ["bed", "\u5bdd\u5e8a\u304c\u307b\u3057\u3044", parts.bed], + ["plushie", "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044", parts.plushie], + ["boredom", "\u9000\u5c48\u3057\u3066\u3044\u308b", parts.boredom], + ["material", "\u8349\u3067\u306a\u306b\u304b\u4f5c\u308c\u305d\u3046", parts.material], + ["openness", "\u65b0\u3057\u3044\u3053\u3068\u304c\u6c17\u306b\u306a\u308b", parts.openness], + ].filter(([, , v]) => Number(v || 0) > 0); + candidates.sort((a, b) => Number(b[2] || 0) - Number(a[2] || 0)); + return candidates[0]?.[1] || TARINAI_NEED_PHRASES.fulfill || "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044"; +} + +function needPhraseForReason(need, tarinai = null, action = null) { + if (need === "fulfill") return fulfillPrimaryReason(tarinai, action); + if (need === "social" && action?.subNeed === "mate") return "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u304c\u6c17\u306b\u306a\u308b"; + if (need === "social" && action?.id === "birth_ritual") return "\u76f8\u624b\u3068\u7e41\u6b96\u3057\u3066\u3044\u308b"; + if (need === "social" && action?.subNeed === "conflict") return "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b"; + if (need === "social" && action?.subNeed === "family") return "\u5bb6\u65cf\u304c\u6c17\u306b\u306a\u308b"; + if (need === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b"; + if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044"; + return TARINAI_NEED_PHRASES[need] || need; +} + +function actionTextForReason(action = null, tarinai = null) { + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const id = action?.id || behavior?.actionId || ""; + const target = tarinai?.target || currentBehaviorTarget?.(tarinai) || null; + const name = target?.name || "\u76f8\u624b"; + if (id === "birth_ritual") return target?.name ? `${name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; + if (id === "fight_rival") { + const cause = String(behavior?.causeText || ""); + if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b"; + return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b"; + } + if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + return String(action?.label || action?.phrase || behavior?.label || "\u5f85\u3063\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, ""); +} + +function forcedCausePhrase(source = "", fallback = "") { + const key = String(source || ""); + if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c"; + if (key === "fight_mochi") return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c"; + if (key === "sleep_drug") return "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c"; + if (key === "drug") return "\u85ac\u306e\u52b9\u679c"; + return String(fallback || "").replace(/[\u3002\uff01\uff1f]+$/, ""); +} + +function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u3066\u3044\u308b") { + const c = String(cause || "").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); + const a = String(actionText || "\u884c\u52d5\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); + if (!c) return `${a}\u3002`; + if (c.endsWith("\u52b9\u679c") || c.endsWith("\u5f71\u97ff")) return `${c}\u3067\u3001${a}\u3002`; + if (c.endsWith("\u6c17\u4ed8\u3044\u305f")) return `${c.replace(/\u6c17\u4ed8\u3044\u305f$/, "\u6c17\u4ed8\u3044\u3066")}\u3001${a}\u3002`; + if (c.endsWith("\u6c17\u3065\u3044\u305f")) return `${c.replace(/\u6c17\u3065\u3044\u305f$/, "\u6c17\u3065\u3044\u3066")}\u3001${a}\u3002`; + if (c.endsWith("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f")) return `${c.replace(/\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f$/, "\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")}\u3001${a}\u3002`; + if (c.endsWith("\u5a01\u5687\u3055\u308c\u305f")) return `${c.replace(/\u5a01\u5687\u3055\u308c\u305f$/, "\u5a01\u5687\u3055\u308c")}\u3001${a}\u3002`; + return `${c}\u306e\u3067\u3001${a}\u3002`; +} + +function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) { + if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {}; + if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, ""); + if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.forcedId || b.forcedRequest)) || b.source) { + const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.sourceReasonText || ""); + if (forced) return forced; + } + if (action?.id === "panic_escape" && tarinai?.lastPanicDetail) return String(tarinai.lastPanicDetail).replace(/[\u3002\uff01\uff1f]+$/, ""); + if (action?.id === "fight_rival") { + const target = currentBehaviorTarget?.(tarinai) || tarinai?.target || null; + if (tarinai?.counterAttackFromId && target?.id === tarinai.counterAttackFromId) return `${target.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`; + if (target?.name) return `${target.name}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`; + } + if (action?.id === "birth_ritual" || action?.subNeed === "mate") { + if ((tarinai?.loveMochiTimer || 0) > 0.04) return "\u3078\u3053\u9905\u306e\u52b9\u679c"; + } + return needPhraseForReason(chosenNeed, tarinai, action); +} + +function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) { + const actionText = actionTextForReason(action, tarinai); + const cause = causePhraseForReason(chosenNeed, action, tarinai, options); + const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; + const main = formatCauseActionText(cause, actionText); + if (tied.length) { + const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; + return `${needPhraseForReason(ignored, tarinai, action)}\u3051\u3069\u3001${main}`; + } + return main; +} + + +function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") { + const primary = String(behavior?.need || fallbackNeed || "fulfill"); + const raw = tarinai?.needRaw || tarinai?.needs || {}; + const list = Array.isArray(behavior?.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [primary]; + if (!list.includes(primary)) list.unshift(primary); + return list.filter((key, idx) => key && (idx === 0 || Number(raw?.[key] || 0) > 0.5)); +} + +function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) { + const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null; + const directCause = options?.causeText || behavior?.causeText || request?.causeText || ""; + if (directCause) return String(directCause || "").replace(/[。!?]+$/, ""); + if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.forcedId || behavior?.forcedRequest)) || request) { + const source = request?.source || behavior?.source || "external"; + const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.sourceReasonText || ""; + const forcedCause = forcedCausePhrase(source, sourceReason); + if (forcedCause) return forcedCause; + } + return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options); +} + +function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) { + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; + if (!b) return ""; + const stored = String(b.text || b.reason || "").trim(); + if (stored && options.regenerate !== true) return stored; + const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null; + const need = String(options.need || b.need || action?.need || "fulfill"); + const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, need); + const causeText = causeForBehaviorText(tarinai, { ...b, need }, action, options); + const generated = action ? buildReasonText(need, tiedNeeds, action, tarinai, tarinai?.world || null, { ...options, causeText }) : ""; + const actionLabel = typeof textTarinaiAction === "function" ? textTarinaiAction(action || b.actionId, tarinai, tarinai?.world || null, { ...options, behavior: b, need, tiedNeeds, causeText, phase: options.phase || b.phase || "active" }) : ""; + const rawFallback = String(actionLabel || b.label || b.reason || b.text || "").trim(); + const catalog = globalThis.TEXT_CATALOG || (typeof window !== "undefined" ? window.TEXT_CATALOG : null); + const fallback = catalog?.cleanBehaviorText + ? catalog.cleanBehaviorText(rawFallback, b.actionId || b.specId || "") + : rawFallback; + return String(generated || fallback || "").trim(); +} + +function patchBehaviorTextFromComposer(tarinai, behavior = null, options = {}) { + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; + if (!b) return ""; + const prevReason = String(b.reason || ""); + const text = composeTarinaiBehaviorText(tarinai, b, { ...options, regenerate: true }); + if (!text) return ""; + const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, b.need || options.need || "fulfill"); + if (text !== b.reason || text !== b.text || JSON.stringify(tiedNeeds) !== JSON.stringify(b.tiedNeeds || [])) { + if (typeof patchTarinaiBehavior === "function") patchTarinaiBehavior(tarinai, { reason: text, text: text, tiedNeeds }); + else { b.reason = text; b.text = text; b.tiedNeeds = tiedNeeds; } + } + if (text && (!tarinai.thought || tarinai.thought === prevReason || tarinai.thought === b.label)) tarinai.thought = text; + return text; +} + +function refreshBehaviorReasonText(tarinai, action = null) { + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + if (!behavior?.need) return ""; + return patchBehaviorTextFromComposer(tarinai, behavior, { action }); +} + + + + diff --git a/js/tarinai_building_behavior.js b/js/tarinai_building_behavior.js new file mode 100644 index 0000000..0b6cefc --- /dev/null +++ b/js/tarinai_building_behavior.js @@ -0,0 +1,116 @@ +"use strict"; + +// Owned-structure building behavior runtime. + +function structureBuildProbe(type, x, y) { + const def = globalThis.StructureRegistry?.get?.(type) || {}; + const r = type === "plushie" ? 6.5 : (type === "grass_bed" ? 18 : 24); + return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" }; +} + +function canBuildOwnedStructureByAge(tarinai) { + if (!tarinai || tarinai.dead) return false; + if (tarinai.generation <= 1) return true; + const growth = Number.isFinite(tarinai.growth) + ? tarinai.growth + : clamp(((tarinai.world?.time || 0) - (tarinai.birthTime || 0)) / Math.max(1, CONFIG.childGrowTime || 58), 0, 1); + return growth >= 0.55; +} + +function stopFailedBuildPlan(t, world, type = "", reason = "") { + if (!t) return false; + t.buildPlan = null; + t.actionCooldowns = t.actionCooldowns || {}; + t.actionIdCooldowns = t.actionIdCooldowns || {}; + const actionId = type === "grass_bed" ? "build_grass_bed" : (type === "plushie" ? "build_plushie" : "build"); + t.actionCooldowns.fulfill = Math.max(t.actionCooldowns.fulfill || 0, 5.0); + t.actionCooldowns.sleep = Math.max(t.actionCooldowns.sleep || 0, type === "grass_bed" ? 3.8 : 0); + t.actionIdCooldowns[actionId] = Math.max(t.actionIdCooldowns[actionId] || 0, 9.0); + if (type === "grass_bed") { + t.actionIdCooldowns.sleep_build_grass_bed = Math.max(t.actionIdCooldowns.sleep_build_grass_bed || 0, 9.0); + t.lastFailedGrassBedBuildAt = world?.time || 0; + } + t.behaviorSwitchCooldownUntil = Math.max(t.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 1.2); + const text = reason || "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"; + if (typeof clearBehavior === "function") clearBehavior(t, text); + t.goIdle?.(text); + return false; +} + +function findStructureBuildSpot(t, world, type) { + const baseX = Number.isFinite(t?.x) ? t.x : 0; + const baseY = Number.isFinite(t?.y) ? t.y : 0; + const candidates = []; + for (let i = 0; i < 28; i++) { + const angle = (i / 28) * Math.PI * 2 + rand(-0.18, 0.18); + const ring = type === "grass_bed" ? rand(34, 72) : rand(18, 42); + candidates.push({ x: clamp(baseX + Math.cos(angle) * ring, CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + Math.sin(angle) * ring, CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) }); + } + candidates.unshift({ x: clamp(baseX + rand(-18, 18), CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + rand(16, 28), CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) }); + let best = null; + let bestScore = -Infinity; + for (const c of candidates) { + const probe = structureBuildProbe(type, c.x, c.y); + if (world?.placementBlocked?.(probe)) continue; + let nearestSame = Infinity; + if (type === "grass_bed") { + for (const it of world?.nearbyItems?.(c.x, c.y, 160) || world?.items || []) { + if (!it || it.dead || it.type !== "grass_bed") continue; + nearestSame = Math.min(nearestSame, distXY(c.x, c.y, it.x, it.y)); + } + } + const score = (Number.isFinite(nearestSame) ? nearestSame : 160) - distXY(c.x, c.y, baseX, baseY) * 0.12; + if (score > bestScore) { best = c; bestScore = score; } + } + return best || candidates[0] || { x: baseX, y: baseY }; +} + +function buildOwnedStructureFromGrass(t, world, type, label) { + const material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material || !globalThis.StructureRegistry) return false; + t.buildPlan = { + type, + materialId: material.id, + timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4), + }; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label); + t.setActionState?.("build", { target: material, reason: label, sleeping: false }); + return true; +} + +function continueBuildPlan(t, world, dt) { + const plan = t.buildPlan; + if (!plan?.type || !globalThis.StructureRegistry) return false; + if (findOwnedStructure(world, t, plan.type)) { + t.buildPlan = null; + return false; + } + let material = (world?.itemById?.(plan.materialId) || null); + if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null; + if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material) { + return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); + } + plan.materialId = material.id; + const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269"; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) { + moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`); + return true; + } + plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1)); + t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false }); + if (plan.timer > 0) return true; + consumeGrassMaterial(material, plan.type === "grass_bed" ? 4 : 1); + const spot = findStructureBuildSpot(t, world, plan.type); + const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world); + if (!structure) { + return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); + } + world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure); + world.drawListDirty = true; + structure.use?.(t, world); + t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); + applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); + t.buildPlan = null; + return true; +} diff --git a/js/tarinai_consumable_behavior.js b/js/tarinai_consumable_behavior.js new file mode 100644 index 0000000..a97523c --- /dev/null +++ b/js/tarinai_consumable_behavior.js @@ -0,0 +1,150 @@ +"use strict"; + +// Consumable/drink/medicine behavior runtime. + +function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = "#f1dfb7") { + if (!t || !item || !(eating > 0)) return false; + t.beginEating?.(item, label || "\u98df\u3079\u3066\u3044\u308b"); + t.vx *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); + t.vy *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); + t.eatTimer = Math.max(t.eatTimer || 0, 1.0); + t.eatCooldown = rand(0.96, 1.12); + t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16); + if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal"); + t.lastMealColor = color; + if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) { + const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y }; + t.world?.spawnEatEffect?.(mouth.x, mouth.y, color); + } + if ((t.world?.time || 0) > (t.nextEatSound || -999)) { + audio.eat?.(); + t.nextEatSound = (t.world?.time || 0) + 0.42; + } + return true; +} + +function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { + if (!t || !world || !target || target.dead) return false; + const foodType = effectiveFoodTypeForItem(target); + const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269"; + const effectiveDt = Math.max(0.10, Number(dt) || 0.12); + if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) { + t.setActionState?.(role === "medicine" ? "seek_food" : "eat", { target, reason: `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, sleeping: false }); + return true; + } + + if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) { + const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b"; + t.setActionState?.("seek_water", { target, reason: text, sleeping: false }); + t.thought = text; + t.applyWaterEffect?.(0.55, target.type); + if (target.type === "water") target.amount -= 1.2; + if ((world.time || 0) > (t.nextDrinkSound || -999)) { + audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); + t.nextDrinkSound = (world.time || 0) + 0.72; + } + applyNeedRelief(t, role === "medicine" ? { food: -2, health: -5, safety: -1 } : { food: -4, health: -1 }); + return true; + } + + + if (target.type === "ant_corpse" && (target.amount || 0) > 0) { + const bite = Math.min(target.amount, rand(3.2, 5.4)); + if (!(bite > 0)) return false; + target.amount -= bite; + finishBehaviorConsumption(t, target, "ant_corpse", bite, effectiveDt, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b", "#4a3f36"); + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.62) : (t.hunger -= bite * 0.62); + t.recoverHealth?.(bite * 0.12); + applyNeedShock(t, { safety: bite * 0.12 }); + t.makePoop?.(normalPoopUnitsFor("ant_corpse", bite)); + return true; + } + + if ((foodType === "sweet" || foodType === "food" || (isDuplicatorFoodSource(target) && foodType === "grass") || (typeof isServingFoodType === "function" && isServingFoodType(foodType)) || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && t.foodItemHasServingLeft?.(target)) { + const eating = t.consumeFoodServing?.(target); + if (!(eating > 0)) return false; + const isParam = typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType); + const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam; + const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7"; + finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color); + const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8; + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief); + t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46))); + t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; + if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" }); + if (foodType === "sweet") { + applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 }); + if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + const fromDuplicator = isDuplicatorFoodSource(target); + t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating)); + if (fromDuplicator) { + t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2); + t.behaviorLockTimer = Math.max(0, Math.min(t.behaviorLockTimer || 0, 0.18)); + } + return true; + } + + if (target.type === "grass" && (target.amount || 0) > 0) { + const bite = window.TarinaiGrass?.regress?.(target, 1) ?? 0; + if (!(bite > 0)) return false; + target.eatenAmount = (target.eatenAmount || 0) + bite; + world.markTerrainDirty?.("grass-eaten-stage"); + world.markItemBucketsDirty?.("grass-eaten-stage"); + finishBehaviorConsumption(t, target, "grass", bite, effectiveDt, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b", "#5b9d39"); + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.46) : (t.hunger -= bite * 0.46); + t.recoverHealth?.(bite * 0.18); + applyNeedRelief(t, { food: -2, fulfill: -1 }); + t.makePoop?.(normalPoopUnitsFor("grass", bite)); + return true; + } + + if (target.type === "zunchi" && (target.amount || 0) > 0) { + const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; + if (zunchiEatingBlocked(t, target, world)) return false; + if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false; + const emergencyHunger = (t.hunger || 0) >= 104 || foodNeed >= 94; + if (!emergencyHunger && !t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 150 && foodNeed < 94 && (t.hunger || 0) < 104) return false; + if (!t.isZunchiSlave && foodNeed < 90 && (t.hunger || 0) < 100) return false; + const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); + if (!(bite > 0)) return false; + target.amount -= bite; + finishBehaviorConsumption(t, target, "zunchi", bite, effectiveDt, t.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b", "#6b8d3f"); + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * (t.isZunchiSlave ? 0.66 : 0.18)) : (t.hunger -= bite * (t.isZunchiSlave ? 0.66 : 0.18)); + t.recoverHealth?.(bite * (t.isZunchiSlave ? 0.10 : 0.02)); + applyNeedShock(t, { health: bite * (t.isZunchiSlave ? 0.2 : 1.2), safety: bite * (t.isZunchiSlave ? 0.1 : 0.6) }); + t.makePoop?.(normalPoopUnitsFor("zunchi", bite)); + return true; + } + return false; +} + +function behaviorFoodTarget(t, world, role = "food", maxDist = 860) { + const best = findNearestItemWithRole(world, t, role, maxDist); + if (role === "food") return best; + const current = currentBehaviorTarget(t) || t.target; + if (current && !current.dead && (roleMatches(current, role) || (role === "food" && current.type === "duplicator" && current.storedFoodType))) return current; + return best; +} + +function updateConsumableBehavior(t, world, dt, role = "food") { + if (!t || !world) return false; + const target = behaviorFoodTarget(t, world, role, role === "food" ? 900 : 700); + if (!target || target.dead) return false; + const state = role === "drink" ? "seek_water" : "seek_food"; + const label = role === "drink" ? "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : (role === "medicine" ? "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"); + const contactDistance = target.type === "duplicator" ? foodInteractionDistance(t, target) : dist(t, target); + const contactReach = foodInteractionReach(t, target, role); + if (contactDistance > contactReach) { + const approachTarget = target.type === "duplicator" ? { x: foodInteractionPoint(target).x, y: foodInteractionPoint(target).y, id: `${target.id || "duplicator"}:dish`, dead: false, hostItem: target } : target; + t.target = target; + moveToOrUse(t, approachTarget, state, label); + return true; + } + t.target = target; + if (consumeBehaviorTarget(t, world, target, role, dt)) return "finished"; + t.setActionState?.(state, { target, reason: label, sleeping: false }); + return t.foodItemHasServingLeft?.(target) ? true : false; +} diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index ba194d1..aba1840 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -70,15 +70,6 @@ } } }, - - 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; @@ -204,11 +195,6 @@ 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) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true; diff --git a/js/tarinai_forced_behavior.js b/js/tarinai_forced_behavior.js new file mode 100644 index 0000000..1cfd7a1 --- /dev/null +++ b/js/tarinai_forced_behavior.js @@ -0,0 +1,466 @@ +"use strict"; + +function actionById(id = "") { + const key = String(id || ""); + return (typeof getTarinaiActionSpec === "function" ? getTarinaiActionSpec(key) : null) + || (Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null); +} + +function behaviorTargetId(target = null) { + if (!target) return null; + if (target.id != null) return target.id; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; + return null; +} + +function currentBehaviorTarget(tarinai, behavior = null) { + if (!tarinai) return null; + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || null; + if (!b) return tarinai.target || null; + const ref = b.target || null; + const id = ref?.id || b.targetId || null; + if (tarinai.target && !tarinai.target.dead && (!id || behaviorTargetId(tarinai.target) === id)) return tarinai.target; + if (id) { + return tarinai.world?.liveTarinaiById?.(id) + || tarinai.world?.itemById?.(id) + || (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id) + || null; + } + if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) return { x: ref.x, y: ref.y, dead: false }; + return null; +} + +function isLiveTarinaiEntity(value) { + return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y)); +} + +function relationFearSafe(holder, other) { + if (!holder || !other || typeof holder.relationTo !== "function") return 0; + return Number(holder.relationTo(other.id)?.fear || 0) || 0; +} + +function nearestForcedFightTarget(tarinai, world, maxDist = 720) { + if (!tarinai || !world) return null; + const current = currentBehaviorTarget(tarinai) || tarinai.target || null; + const ok = o => isLiveTarinaiEntity(o) && o !== tarinai && !!o.isZunchiSlave === !!tarinai.isZunchiSlave && !world.areParentChild?.(tarinai, o) && !world.areCoParents?.(tarinai, o) && !o.sleepDisease; + if (ok(current) && dist(tarinai, current) <= maxDist + 120) return current; + const conflict = conflictTargetFor(tarinai, world, 6); + if (ok(conflict?.target)) return conflict.target; + return world.nearestOther?.(tarinai, maxDist, ok) || null; +} + +function resolveForcedBehaviorTarget(tarinai, world, entry = {}, action = null) { + if (!tarinai || !world) return null; + if (entry.targetId) { + const found = world.liveTarinaiById?.(entry.targetId) + || world.itemById?.(entry.targetId) + || (world.items || []).find(item => item && !item.dead && item.id === entry.targetId) + || null; + if (found) return found; + } + const id = String(entry.id || action?.id || ""); + if (id === "fight_rival" || id === "intimidate_enemy") return nearestForcedFightTarget(tarinai, world, Number(entry.searchRange || 760)); + if (id === "approach_mate" || id === "birth_ritual") return mateTargetFor(tarinai, world, Number(entry.searchRange || 760)); + if (id === "eat_food") return behaviorFoodTarget(tarinai, world, "food", Number(entry.searchRange || 900)); + if (id === "drink_water") return behaviorFoodTarget(tarinai, world, "drink", Number(entry.searchRange || 680)); + if (id === "use_medicine") return behaviorFoodTarget(tarinai, world, "medicine", Number(entry.searchRange || 760)); + if (id === "sleep_in_bed") return findNearestSleepPlace(world, tarinai, Number(entry.searchRange || 760)); + return null; +} + +function forcedBehaviorSourcePriority(source = "external") { + return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130; +} + +function forcedBehaviorQueueOf(tarinai) { + if (!tarinai) return []; + if (!Array.isArray(tarinai.forcedBehaviorQueue)) tarinai.forcedBehaviorQueue = []; + return tarinai.forcedBehaviorQueue; +} + +function setForcedBehaviorQueue(tarinai, queue = []) { + if (!tarinai) return []; + tarinai.forcedBehaviorQueue = (Array.isArray(queue) ? queue : []).filter(Boolean); + return tarinai.forcedBehaviorQueue; +} + +function normalizeForcedBehaviorEntry(tarinai, actionId, opts = {}) { + const now = tarinai?.world?.time || 0; + const source = opts.source || "external"; + const basePriority = forcedBehaviorSourcePriority(source); + const raw = { + uid: opts.uid || `${String(actionId)}:${source}:${now.toFixed(3)}:${Math.random().toString(36).slice(2, 8)}`, + id: String(actionId), + targetId: opts.targetId ?? behaviorTargetId(opts.target), + reason: opts.reasonText || opts.reason || "", + causeText: opts.causeText || "", + source, + priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority, + force: opts.force !== false, + replaceSameSource: opts.replaceSameSource !== false, + interrupt: opts.interrupt !== false, + createdAt: now, + status: opts.status || "queued", + attempts: Number(opts.attempts || 0) || 0, + lastTriedAt: Number(opts.lastTriedAt ?? -999) || -999, + retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25, + expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18), + duration: Number(opts.duration ?? 0) || 0, + minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0, + searchRange: Number(opts.searchRange ?? 0) || 0, + }; + return typeof normalizeTarinaiForcedRequest === "function" ? { ...raw, ...normalizeTarinaiForcedRequest(raw, raw), force: raw.force, replaceSameSource: raw.replaceSameSource, interrupt: raw.interrupt, retryDelay: raw.retryDelay, attempts: raw.attempts, lastTriedAt: raw.lastTriedAt, searchRange: raw.searchRange } : raw; +} + +function markForcedBehaviorActive(tarinai, entry, action = null) { + if (!tarinai || !entry) return null; + entry.status = "active"; + entry.activeSince = entry.activeSince || tarinai.world?.time || 0; + entry.startedAt = entry.startedAt || entry.activeSince; + const payload = { ...entry, id: action?.id || entry.id, status: "active" }; + return typeof setTarinaiForcedBehavior === "function" ? setTarinaiForcedBehavior(tarinai, payload) : payload; +} + +function behaviorIsForced(behavior = null) { + return typeof isTarinaiBehaviorValueForced === "function" + ? isTarinaiBehaviorValueForced(behavior) + : Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest); +} + +function isCurrentBehaviorForced(tarinai) { + return behaviorIsForced(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior); +} + +function currentForcedBehaviorRequest(tarinai) { + if (typeof getTarinaiForcedBehavior === "function") return getTarinaiForcedBehavior(tarinai); + const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + return behaviorIsForced(b) ? (b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: b.data?.forcedSource || b.source || "external", priority: Number(b.priority || 0) || 0 }) : null; +} + +function clearForcedBehaviorQueue(tarinai, predicate = null) { + if (!tarinai) return 0; + const queue = forcedBehaviorQueueOf(tarinai); + const before = queue.length; + const next = typeof predicate !== "function" ? [] : queue.filter(entry => !predicate(entry)); + setForcedBehaviorQueue(tarinai, next); + const active = currentForcedBehaviorRequest(tarinai); + if (active?.uid && !next.some(entry => entry && entry.uid === active.uid) && typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); + return before - next.length; +} + +function behaviorText(tarinai) { + const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + if (!b) return ""; + const state = String(tarinai.state || ""); + const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04; + const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state); + if (!validByState && !behaviorIsForced(b)) return ""; + return composeTarinaiBehaviorText(tarinai, b) || String(b.text || b.reason || b.label || "").trim(); +} + + +function setBehavior(tarinai, fields = {}) { + if (!tarinai || tarinai.dead) return null; + const now = tarinai.world?.time || 0; + const previous = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const previousActionId = previous.actionId || ""; + const actionId = fields.actionId || fields.id || previousActionId || tarinai.state || "idle"; + const action = fields.action || actionById(actionId) || null; + const need = fields.need || action?.need || previous.need || "fulfill"; + const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している"; + const reason = fields.reason || fields.text || label; + const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previousActionId === actionId ? previous.startedAt : now); + const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0; + const minDuration = Number(fields.minDuration ?? previous.minDuration ?? Math.max(0.45, lockSeconds * 0.72)) || 0.45; + const target = fields.target !== undefined ? fields.target : tarinai.target; + const forced = Boolean(fields.forced ?? (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(previous) : Boolean(previous.forcedId || previous.forcedRequest))); + const source = forced ? "forced" : (fields.source || previous.source || "need"); + const forcedRequest = fields.forcedRequest || previous.forcedRequest || null; + const next = { + actionId, + need, + subNeed: fields.subNeed || action?.subNeed || previous.subNeed || "", + phase: fields.phase || previous.phase || (String(tarinai.state || "") === "idle" ? "idle" : "acting"), + label, + text: fields.text || reason, + reason, + target, + startedAt, + elapsed: Math.max(0, now - startedAt), + minDuration, + lockSeconds, + deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt, + source, + sourceReasonText: fields.sourceReasonText || previous.sourceReasonText || "", + causeText: fields.causeText || previous.causeText || "", + forcedId: fields.forcedId || previous.forcedId || "", + forcedRequest, + priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0, + interruptible: fields.interruptible !== false, + data: { ...(previous.data || {}), ...(fields.data || {}) }, + tiedNeeds: fields.tiedNeeds || previous.tiedNeeds || [need], + specId: fields.specId || action?.id || previous.specId || actionId, + specKind: fields.specKind || action?.kind || previous.specKind || "need_action", + }; + if (forcedRequest?.source) next.data.forcedSource = forcedRequest.source; + tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1; + next.serial = tarinai.behaviorSerial; + if (typeof setTarinaiBehavior === "function") tarinai.behavior = setTarinaiBehavior(tarinai, next); + else tarinai.behavior = next; + if (target !== undefined) tarinai.target = target; + if (reason && (!tarinai.thought || tarinai.thought === previous.reason || tarinai.thought === previous.text || tarinai.thought === previous.label)) tarinai.thought = reason; + return tarinai.behavior || next; +} + +function setBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", options = {}) { + if (!action) return null; + return setBehavior(tarinai, { + actionId: action.id, + action: action, + need: choice.need || action.need, + subNeed: options.subNeed || action.subNeed || "", + label: action.label || options.actionLabel || "行動している", + text: reasonText || action.label, + reason: reasonText || action.label, + target: options.target !== undefined ? options.target : tarinai?.target, + tiedNeeds: choice.tiedNeeds || [choice.need || action.need], + source: options.forced ? "forced" : (options.source || "need"), + forced: Boolean(options.forced), + forcedId: options.forcedId || "", + sourceReasonText: options.sourceReasonText || "", + causeText: options.causeText || "", + forcedRequest: options.forcedRequest || null, + priority: options.priority ?? actionPriority(action), + lockSeconds: options.lockSeconds, + minDuration: options.minDuration, + phase: options.phase || "starting", + specId: action.id, + specKind: action.kind || "need_action", + }); +} + +function clearBehavior(tarinai, reason = "") { + if (!tarinai) return; + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(tarinai, { reason }); + else tarinai.behavior = null; + if (reason) tarinai.thought = reason; +} + +function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) { + if (!tarinai || tarinai.dead || !actionId) return false; + const entry = normalizeForcedBehaviorEntry(tarinai, actionId, opts); + let queue = forcedBehaviorQueueOf(tarinai); + if (entry.replaceSameSource) queue = queue.filter(e => !(e && e.id === entry.id && e.source === entry.source)); + queue.push(entry); + queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); + setForcedBehaviorQueue(tarinai, queue.slice(0, 12)); + if (opts.target) tarinai.target = opts.target; + if (opts.applyShock && typeof applyNeedShock === "function") applyNeedShock(tarinai, opts.applyShock, opts.target || null); + return true; +} + + +function completeForcedBehavior(tarinai, behavior = null, status = "completed") { + if (!tarinai) return false; + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const forcedId = b.forcedId || ""; + if (!forcedId) return false; + const queue = forcedBehaviorQueueOf(tarinai); + const before = queue.length; + const next = queue.filter(entry => entry && entry.uid !== forcedId); + setForcedBehaviorQueue(tarinai, next); + if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); + return before !== next.length; +} + +function retryForcedBehavior(tarinai, behavior = null, reason = "retry") { + if (!tarinai) return false; + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const forcedId = b.forcedId || ""; + if (!forcedId) return false; + const now = tarinai.world?.time || 0; + const entry = forcedBehaviorQueueOf(tarinai).find(e => e && e.uid === forcedId); + if (!entry) return false; + entry.status = "queued"; + entry.lastFailedAt = now; + entry.failReason = reason; + entry.activeSince = 0; + if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); + return true; +} + +function processForcedBehaviorQueue(tarinai, world, needs) { + if (!tarinai || tarinai.dead) return false; + const now = world?.time || 0; + let queue = forcedBehaviorQueueOf(tarinai).filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now)); + if (!queue.length) { + setForcedBehaviorQueue(tarinai, []); + if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); + return false; + } + + // \u5b9f\u884c\u4e2d\u306e\u5f37\u5236\u884c\u52d5\u306f\u30ad\u30e5\u30fc\u306b\u6b8b\u3057\u3001\u5b8c\u4e86/\u5931\u6557/\u671f\u9650\u5207\u308c\u307e\u3067\u52b9\u679c\u3092\u4fdd\u6301\u3059\u308b\u3002 + const activeQueueBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior; + const activeForcedId = activeQueueBehavior?.forcedId || ""; + for (const entry of queue) { + if (!entry.uid) entry.uid = `${entry.id}:${entry.source || "external"}:${entry.createdAt || now}:${Math.random().toString(36).slice(2, 8)}`; + if (entry.uid === activeForcedId && behaviorIsForced(activeQueueBehavior) && activeQueueBehavior?.actionId === entry.id) { + markForcedBehaviorActive(tarinai, entry, actionById(entry.id)); + } else if (entry.status === "active") { + // behavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002 + entry.status = "queued"; + entry.lastFailedAt = now; + } + } + + queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); + setForcedBehaviorQueue(tarinai, queue); + + const activePriority = Number(activeQueueBehavior?.priority ?? actionPriority(activeQueueBehavior?.actionId)) || 0; + const activeForced = behaviorIsForced(activeQueueBehavior); + + for (let i = 0; i < queue.length; i++) { + const entry = queue[i]; + const action = actionById(entry.id); + if (!action) { queue.splice(i, 1); i--; continue; } + if (entry.status === "active" && activeQueueBehavior?.forcedId === entry.uid) continue; + const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0; + if (activeQueueBehavior && (tarinai.behaviorLockTimer || 0) > 0.08) { + if (activeForced && entryPriority <= activePriority + 8) continue; + if (!entry.interrupt && entryPriority <= activePriority) continue; + if (entryPriority <= activePriority && !entry.force) continue; + } + if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue; + entry.lastTriedAt = now; + entry.attempts = (Number(entry.attempts || 0) || 0) + 1; + + const target = resolveForcedBehaviorTarget(tarinai, world, entry, action); + if (target) { + tarinai.target = target; + entry.targetId = behaviorTargetId(target); + } + if ((action.id === "fight_rival" || action.id === "intimidate_enemy" || action.id === "approach_mate" || action.id === "birth_ritual") && !target) { + continue; + } + + const need = action.need || entry.need || "safety"; + const choice = { need, tiedNeeds: [need], max: Math.max(needThreshold(need, "start"), Number(needs?.[need] || 0) || 0) }; + const causeText = entry.causeText || forcedCausePhrase(entry.source || "", entry.reasonText || ""); + const reasonText = buildReasonText(need, choice.tiedNeeds, action, tarinai, world, { causeText }); + if (activeQueueBehavior && entryPriority > activePriority) { + retryForcedBehavior(tarinai, activeQueueBehavior, "interrupted_by_higher_forced_behavior"); + clearBehavior(tarinai); + tarinai.behaviorLockTimer = 0; + if (tarinai.state === "sleep" || tarinai.sleeping) { + tarinai.sleeping = false; + tarinai.sleepSession = null; + } + } + const started = startNeedAction(tarinai, world, choice, action, reasonText, { + forced: true, + forcedId: entry.uid, + forcedReasonText: entry.reasonText || reasonText, + sourceReasonText: entry.reasonText || reasonText, + causeText, + priority: entryPriority, + source: entry.source, + target, + forcedRequest: { ...entry, status: "active", reason: entry.reasonText || reasonText, causeText }, + lockSeconds: entry.duration || undefined, + minDuration: entry.minDuration || undefined, + }); + if (started) { + markForcedBehaviorActive(tarinai, entry, action); + setForcedBehaviorQueue(tarinai, queue); + return true; + } + } + setForcedBehaviorQueue(tarinai, queue); + return false; +} + +function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, reason = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) { + if (!tarinai) return; + const now = tarinai.world?.time || 0; + const previousBehavior = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const previousActionId = previousBehavior.actionId || ""; + const nextNeed = need || previousBehavior.need || "social"; + const nextActionId = actionId || previousActionId || tarinai.state || "idle"; + const displayReason = reasonText ?? reason; + const nextActionLabel = actionLabel || displayReason || previousBehavior.label || tarinai.state || "行動している"; + const action = actionById(nextActionId); + const previousForced = behaviorIsForced(previousBehavior); + const preserveForced = Boolean(previousForced && source === "state" && (!actionId || previousActionId === nextActionId)); + let nextReasonText = displayReason || nextActionLabel; + const rawNeedsForText = tarinai.needRaw || tarinai.needs || {}; + const existingTiedNeeds = (Array.isArray(previousBehavior.tiedNeeds) && previousBehavior.tiedNeeds.length ? [...previousBehavior.tiedNeeds] : [nextNeed]) + .filter((key, idx) => idx === 0 || Number(rawNeedsForText?.[key] || 0) > 0.5); + if (existingTiedNeeds.some(key => key && key !== nextNeed) && !String(nextReasonText).includes("けど、")) { + nextReasonText = buildReasonText(nextNeed, existingTiedNeeds, { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null); + } + const nextForced = forced ?? preserveForced; + const nextSource = nextForced ? "forced" : (source || previousBehavior.source || "state"); + const nextForcedId = forcedId || previousBehavior.forcedId || ""; + const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || ""; + const nextCauseText = causeText || previousBehavior.causeText || ""; + if (nextCauseText) { + nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText }); + } + if (target !== undefined) tarinai.target = target; + setBehavior(tarinai, { + actionId: nextActionId, + action, + need: nextNeed, + subNeed: subNeed || action?.subNeed || previousBehavior.subNeed || "", + label: nextActionLabel, + text: nextReasonText, + reason: nextReasonText, + target: target !== undefined ? target : tarinai.target, + tiedNeeds: existingTiedNeeds, + source: nextSource, + sourceReasonText: nextSourceReason, + causeText: nextCauseText, + forced: nextForced, + forcedId: nextForcedId, + forcedRequest: previousBehavior.forcedRequest || null, + priority: previousBehavior.priority ?? actionPriority(action), + phase, + startedAt: previousBehavior.startedAt || now, + minDuration: previousBehavior.minDuration || 0.8, + lockSeconds: previousBehavior.lockSeconds || 1.0, + }); +} + + + + +function synchronizeActionTextFromState(tarinai) { + if (!tarinai || tarinai.dead) return; + const state = String(tarinai.state || ""); + const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : ""; + if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { + const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null; + const label = partner?.name ? `${partner.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; + setBehaviorText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b", reason: label, target: partner }); + return; + } + if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) { + setBehaviorText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reason: catalogText || "\u98df\u3079\u3066\u3044\u308b" }); + return; + } + if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) { + setBehaviorText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reason: catalogText || "\u55a7\u5629\u3057\u3066\u3044\u308b" }); + return; + } + if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) { + setBehaviorText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "\u9003\u3052\u3066\u3044\u308b", reason: catalogText || "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b" }); + return; + } + if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) { + setBehaviorText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: catalogText || "\u5a01\u5687\u3057\u3066\u3044\u308b" }); + return; + } + if (state === "sleep" || tarinai.sleeping) { + setBehaviorText(tarinai, { need: "sleep", actionId: (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai.behavior?.actionId) || "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reason: catalogText || "\u7720\u3063\u3066\u3044\u308b" }); + } +} diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 5c8cd84..20ce0fe 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -118,26 +118,11 @@ 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; @@ -272,16 +257,13 @@ const before = this.stress || 0; if (this.needs && typeof calculateStressFromNeeds === "function") { const nextSafety = typeof quantizeNeed === "function" ? quantizeNeed((this.needs.safety || 0) + shock) : Math.min(100, (this.needs.safety || 0) + shock); - this.stress = calculateStressFromNeeds({ ...this.needs, safety: nextSafety }); + this.stress = typeof applyGroundStressModifier === "function" ? applyGroundStressModifier(this, calculateStressFromNeeds({ ...this.needs, safety: nextSafety })) : calculateStressFromNeeds({ ...this.needs, safety: nextSafety }); } const gained = Math.max(delta, Math.max(0, (this.stress || 0) - 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); }, @@ -318,22 +300,6 @@ 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; }, diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index efd8d49..96e468f 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -4,7 +4,7 @@ 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 EFFECT_REGISTRY = global.TarinaiItemRegistry?.effect || 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?.() || [ @@ -231,8 +231,8 @@ this.actionCooldowns.food = Math.max(this.actionCooldowns.food || 0, 5.2); } this.hunger -= actual; - if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior)?.forced)) { - this.intentLockTimer = Math.min(this.intentLockTimer || 0, 0.25); + if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !(typeof isTarinaiBehaviorForced === "function" ? isTarinaiBehaviorForced(this) : Boolean(this.behavior?.source === "forced" || this.behavior?.forcedId || this.behavior?.forcedRequest))) { + this.behaviorLockTimer = Math.min(this.behaviorLockTimer || 0, 0.25); } return actual; }, diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js new file mode 100644 index 0000000..afb332e --- /dev/null +++ b/js/tarinai_item_targeting.js @@ -0,0 +1,344 @@ +"use strict"; + +// Food/item/structure targeting helpers for Tarinai need actions. + +function effectiveFoodTypeForItem(item) { + if (!item) return ""; + return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); +} + +function isDuplicatorFoodSource(item) { + return !!item && item.type === "duplicator"; +} + +const NORMAL_POOP_MEAL_THRESHOLD = 3; + +function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) { + const type = String(foodType || ""); + if (type === "laxative" || options.laxative === true) return 0; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 1; + const value = Math.max(0, Number(amount) || 0); + return value / 18; +} +function effectiveFoodRoleMatches(item, role) { + const type = effectiveFoodTypeForItem(item); + if (!type) return false; + if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type)); + if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice"; + if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); + return false; +} + +function roleMatches(item, role) { + if (!item || item.dead) return false; + if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false; + if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role); + if (item.roles?.[role]) return true; + if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role); + if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; + if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(item.type); + if (role === "grassMaterial") return item.type === "grass"; + return false; +} + +function foodInteractionPoint(item) { + if (!item) return { x: 0, y: 0 }; + if (item.type === "duplicator") return { x: item.x + (item.r || 34) * 0.62, y: item.y + (item.r || 34) * 0.24 }; + return { x: item.x, y: item.y }; +} + +function foodInteractionDistance(tarinai, item) { + const p = foodInteractionPoint(item); + const mouth = tarinai?.mouthPosition?.(item) || { x: tarinai?.x || 0, y: tarinai?.y || 0 }; + return distXY(mouth.x, mouth.y, p.x, p.y); +} + +function foodInteractionReach(tarinai, item, role = "food") { + if (item?.type === "duplicator") return Math.max(14, (tarinai?.radius || 20) * 0.44 + 5); + if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40); + return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28); +} + +function foodPriorityRank(tarinai, item) { + const type = effectiveFoodTypeForItem(item); + if (!type) return -1; + const need = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need; + const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; + const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; + if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6; + if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7; + if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; + if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7; + if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4; + if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450; + if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000 + Math.max(0, healthNeed - 45) * 5; + if (type === "grass") { + const hunger = Number(tarinai?.hunger || 0) || 0; + const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0; + return 2000 + Math.max(0, hunger - 64) * 18 + Math.max(0, foodNeed - 58) * 12; + } + if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0); + if (type === "food" || type === "ant_corpse") return 3000; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000; + return -1; +} + +function foodPriorityScore(tarinai, item, distance = 0) { + let rank = foodPriorityRank(tarinai, item); + if (rank < 0) return -Infinity; + const hunger = Number(tarinai?.hunger || 0) || 0; + if (item?.type === "zunchi") { + if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity; + if (!tarinai?.isZunchiSlave) rank -= 1400; + if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200; + } + if (item?.type === "duplicator") rank += 85; + return rank - Math.max(0, Number(distance) || 0) * 0.65; +} + +function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { + if (!tarinai || !world) return false; + for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) { + if (!it || it.dead || it.type === "zunchi") continue; + if (tarinai.shouldAvoidTarget?.(it)) continue; + const type = effectiveFoodTypeForItem(it); + if (!type) continue; + if (["sweet", "food", "grass", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) { + if (it.type === "grass" && (it.amount || 0) <= 0) continue; + if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue; + return true; + } + } + return false; +} + +function zunchiEatingBlocked(tarinai, item, world) { + if (!tarinai || !item || item.type !== "zunchi") return false; + const itemAge = Number(item.age || 0) || 0; + const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge; + const protectedAge = Math.max(itemAge, worldAge); + const protectedUntil = Number(item.eatProtectedUntil || 0) || 0; + if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true; + const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; + const hunger = Number(tarinai.hunger || 0) || 0; + // 排泄直後のずんちは観察・接触対象であり、通常食として即消費させない。 + // ずんちどれいも数秒は待つ。通常個体は極限飢餓でも最低90秒は食べない。 + const emergency = hunger >= 104 || foodNeed >= 94; + const grace = tarinai.isZunchiSlave ? (emergency ? 4 : 18) : (emergency ? 20 : 90); + if (protectedAge < grace) return true; + if (!emergency && !tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 94 && hunger < 104) return true; + return false; +} + +function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { + let best = null, bestScore = Infinity; + const hunger = Number(tarinai?.hunger || 0) || 0; + const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0; + const foodEmergency = role === "food" && (hunger >= 96 || foodNeed >= 84); + const searchDist = foodEmergency ? Math.max(maxDist, 1180) : maxDist; + const primary = ((role === "food" || role === "drink" || role === "medicine") && world?.nearbyFood) + ? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true) + : (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []); + const candidates = Array.isArray(primary) ? primary.slice() : []; + if (foodEmergency && world?.itemsOfType) { + for (const type of ["food", "sweet", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) { + for (const it of world.itemsOfType(type) || []) { + if (it && !it.dead && !candidates.includes(it)) candidates.push(it); + } + } + } + for (const item of candidates) { + if (!roleMatches(item, role)) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item); + if (d > searchDist) continue; + let score = d; + if (role === "food" || role === "medicine") { + const priority = foodPriorityScore(tarinai, item, d); + if (!Number.isFinite(priority)) continue; + score = -priority; + } + if (score < bestScore) { best = item; bestScore = score; } + } + return best; +} + +function sleepPlaceAvailableFor(world, tarinai, item) { + if (!world || !tarinai || !item || item.dead) return false; + if (!(item.type === "nest_box" || item.type === "bed")) return false; + if (tarinai.shouldAvoidTarget?.(item)) return false; + const capacity = item.type === "nest_box" + ? (world.nestBoxCapacity ? world.nestBoxCapacity(item) : 5) + : 1; + const occupancy = item.type === "nest_box" + ? (world.nestBoxOccupants ? world.nestBoxOccupants(item, Infinity).length : world.bedOccupancy?.(item) || 0) + : (world.bedOccupancy?.(item) || 0); + return occupancy < capacity; +} + +function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) { + let best = null; + let bestScore = Infinity; + const list = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []; + for (const item of list) { + if (!item || item.dead || item.type !== type) continue; + if (!sleepPlaceAvailableFor(world, tarinai, item)) continue; + const d = dist(tarinai, item); + if (d > maxDist) continue; + const comfort = world?.bedComfort ? world.bedComfort(item) : 1; + const score = d - comfort * 18; + if (score < bestScore) { best = item; bestScore = score; } + } + return best; +} + +function findNearestSleepPlace(world, tarinai, maxDist = 520) { + return nearestSleepPlaceOfType(world, tarinai, "nest_box", maxDist) + || nearestSleepPlaceOfType(world, tarinai, "bed", maxDist) + || findOwnedStructure(world, tarinai, "grass_bed", maxDist) + || findUnownedStructure(world, tarinai, "grass_bed", maxDist) + || null; +} + +function findNearbyDanger(world, tarinai, maxDist = 150) { + const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null; + const item = findNearestItemWithRole(world, tarinai, "danger", maxDist); + if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item; + return ant || item; +} + + +function activePanicBreaker(tarinai, world, maxDist = 260) { + const breaker = tarinai?.lastNeedShockBreaker || null; + if (!breaker || !tarinai) return null; + const cause = String(tarinai.lastPanicCause || ""); + // Relation/death shocks are intentionally not active dangers. Keeping them as + // breakers made old panic spread and persist after the original event ended. + if (cause === "relation_death" || cause === "pending_relation_death") { + tarinai.lastNeedShockBreaker = null; + return null; + } + if (breaker.dead) { + tarinai.lastNeedShockBreaker = null; + return null; + } + const now = Number(world?.time || 0) || 0; + if (Number.isFinite(tarinai.lastPanicAt) && now - tarinai.lastPanicAt > 8.0) { + tarinai.lastNeedShockBreaker = null; + return null; + } + if (!Number.isFinite(breaker.x) || !Number.isFinite(breaker.y)) return null; + const d = distXY(tarinai.x, tarinai.y, breaker.x, breaker.y); + const isDangerItem = roleMatches(breaker, "danger") || breaker.kind === "ant" || breaker.type === "ant"; + const isThreatTarinai = breaker !== tarinai && !breaker.dead && breaker.id && (breaker.fightTimer > 0.04 || breaker.intimidateTimer > 0.04 || breaker.fightMochiTimer > 0.04); + const allowedRange = isDangerItem ? maxDist : (isThreatTarinai ? maxDist * 0.75 : maxDist * 0.45); + if (d <= allowedRange) return breaker; + tarinai.lastNeedShockBreaker = null; + return null; +} + +function survivalNeedShouldCancelPanic(tarinai, needs) { + if (!tarinai) return false; + const food = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; + const sleep = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0; + const health = Number(needs?.health ?? tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0; + return food >= 72 || (tarinai.hunger || 0) >= 82 || sleep >= 82 || (tarinai.energy || 100) <= 14 || health >= 88; +} + + +function evaluateConflictUrge(tarinai, world, maxDist = 96) { + if (!tarinai || tarinai.dead || !world) return null; + if ((tarinai.fightCooldown || 0) > 0.04 || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.postBirthPeaceTimer || 0) > 0.04) return null; + const p = tarinai.personalityProfile?.() || tarinai.currentPersonality || {}; + const aggression = Number(tarinai.currentPersonality?.aggression ?? p.fight ?? 0) || 0; + const battleDrug = (tarinai.fightMochiTimer || 0) > 0.04 || tarinai.hasFightMochiEffect?.(); + const near = typeof world.nearbyTarinai === "function" ? world.nearbyTarinai(tarinai.x, tarinai.y, maxDist) : (world.tarinai || []); + let best = null; + for (const other of near || []) { + if (!other || other === tarinai || other.dead) continue; + if (!world.canFightPair?.(tarinai, other)) continue; + if ((other.fightCooldown || 0) > 0.04 || (other.postBirthPeaceTimer || 0) > 0.04) continue; + if (world.areParentChild?.(tarinai, other) || world.areCoParents?.(tarinai, other)) continue; + const d = Math.max(1, dist(tarinai, other)); + if (d > maxDist) continue; + const rel = tarinai.relationTo?.(other.id) || {}; + const relOther = other.relationTo?.(tarinai.id) || {}; + const mixedZunchi = !!tarinai.isZunchiSlave !== !!other.isZunchiSlave; + const otherBattleDrug = (other.fightMochiTimer || 0) > 0.04 || other.hasFightMochiEffect?.(); + const closePressure = clamp(1 - d / maxDist, 0, 1) * 18; + const stressPressure = clamp(((tarinai.stress || 0) - 52) / 42, 0, 1) * 18; + const angerPressure = (tarinai.type === "angry" ? 12 : 0) + ((other.type === "angry" || otherBattleDrug) ? 8 : 0); + const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 22) / 90, 0, 1) * 14; + const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 26; + const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 22 : 0; + const drugBonus = battleDrug ? 56 : 0; + const zunchiBonus = mixedZunchi ? 14 : 0; + const personalityBonus = clamp(aggression, -1, 1) * 10; + const score = closePressure + stressPressure + angerPressure + fearPressure + drugBonus + zunchiBonus + personalityBonus - affinityBrake - calmBrake; + if (!best || score > best.score) { + best = { + target: other, + score, + forced: !!battleDrug || !!otherBattleDrug, + defensive: mixedZunchi || (rel.fear || 0) > 35, + reason: mixedZunchi ? "\u8fd1\u304f\u306e\u76f8\u624b\u304c\u843d\u3061\u7740\u304b\u306a\u3044" : battleDrug ? "\u304d\u305a\u3064\u304d\u9905\u3067\u6c17\u304c\u7acb\u3063\u3066\u3044\u308b" : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u8fd1\u3044", + }; + } + } + return best && best.score > (best.forced ? 4 : 12) ? best : null; +} + +function conflictTargetFor(tarinai, world, minScore = 34) { + const evaluated = evaluateConflictUrge(tarinai, world, 118); + if (evaluated && evaluated.score >= minScore) return evaluated; + const id = tarinai?.conflictTargetId || ""; + const target = id ? world?.liveTarinaiById?.(id) : null; + if (!target || !world?.canFightPair?.(tarinai, target)) return null; + const d = dist(tarinai, target); + const carried = Math.max(0, Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, d - 70) * 0.28; + return carried >= minScore ? { target, score: carried, forced: (tarinai.fightMochiTimer || 0) > 0.04, defensive: false, reason: "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b" } : null; +} + +function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) { + return findNearestItemWithRole(world, tarinai, materialRole, maxDist); +} + +function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of world?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue; + if (type && item.type !== type) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of world?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId) continue; + if (type && item.type !== type) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function consumeGrassMaterial(material, stages = 1) { + if (!material) return false; + const steps = Math.max(1, Math.floor(Number(stages) || 1)); + const consumed = window.TarinaiGrass?.regress?.(material, steps) ?? 0; + if (!(consumed > 0)) return false; + material.eatenAmount = (material.eatenAmount || 0) + consumed; + material.world?.markTerrainDirty?.("grass-material-consumed"); + return true; +} + +function moveToOrUse(t, target, state = "seek_food", reason = "") { + if (!target) return false; + t.setActionState?.(state, { target, reason, sleeping: false }); + return true; +} + diff --git a/js/tarinai_needs_core.js b/js/tarinai_needs_core.js new file mode 100644 index 0000000..56a5f27 --- /dev/null +++ b/js/tarinai_needs_core.js @@ -0,0 +1,298 @@ +"use strict"; + +const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; +const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"]; +const TARINAI_NEED_LABELS = { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", +}; +const TARINAI_NEED_PHRASES = { + food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", + sleep: "\u306d\u3080\u3044", + health: "\u8abf\u5b50\u304c\u60aa\u3044", + safety: "\u3042\u3076\u306a\u3044", + social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", + fulfill: "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044", +}; + +const TARINAI_NEED_THRESHOLDS = { + food: { start: 64, continue: 20 }, + sleep: { start: 62, continue: 30 }, + health: { start: 54, continue: 32 }, + safety: { start: 42, continue: 20 }, + social: { start: 52, continue: 30 }, + fulfill: { start: 48, continue: 26 }, +}; + +const TARINAI_NEED_SATISFACTION_DECAY = { + food: 0.85, + sleep: 0.75, + health: 0.65, + safety: 1.10, + social: 1.05, + fulfill: 1.15, +}; + +const TARINAI_ACTION_LOCK_SECONDS = { + eat_food: 0.85, + drink_water: 1.8, + sleep_in_bed: 18.0, + sleep_build_grass_bed: 10.0, + sleep_anywhere: 12.0, + use_medicine: 2.4, + rest_to_recover: 6.0, + sunbath: 10.5, + panic_escape: 2.1, + flee: 1.7, + hide_at_owned_structure: 6.4, + approach_friend: 6.4, + approach_parent_or_child: 7.0, + approach_mate: 5.6, + fight_rival: 3.6, + intimidate_enemy: 1.15, + play: 8.2, + build_grass_bed: 10.0, + build_plushie: 10.0, + return_owned_structure: 6.4, + use_plushie: 7.4, + wander_lightly: 4.2, +}; + +const TARINAI_ACTION_PRIORITY = { + fight_rival: 56, + intimidate_enemy: 42, + panic_escape: 94, + flee: 92, + hide_at_owned_structure: 90, + use_medicine: 86, + rest_to_recover: 84, + sunbath: 91, + sleep_in_bed: 86, + sleep_build_grass_bed: 85, + sleep_anywhere: 84, + birth_ritual: 72, + approach_mate: 68, + approach_parent_or_child: 66, + approach_friend: 66, + eat_food: 50, + drink_water: 48, + build_grass_bed: 30, + build_plushie: 28, + use_plushie: 24, + return_owned_structure: 22, + play: 20, + wander_lightly: 10, +}; + +function actionPriority(actionOrId) { + const id = typeof actionOrId === "string" ? actionOrId : actionOrId?.id; + if (!id) return 0; + return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0; +} + +const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"]; +const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = { + fight_mochi: 180, + love_mochi: 150, + sleep_drug: 145, + drug: 140, + user: 200, + external: 130, +}; + +function createDefaultNeeds() { + return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; +} + +function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); +} + +function getNeedDisplayValue(value) { + return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10))); +} + +function needPressure(value) { + const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100; + return x * x * 100; +} + +function groundStressMultiplierFor(tarinai) { + const mult = Number(tarinai?.world?.groundStressMultiplier?.()); + return Number.isFinite(mult) && mult > 0 ? mult : 1; +} + +function applyGroundStressModifier(tarinai, nextStress) { + const next = clamp(Number(nextStress) || 0, 0, 130); + const before = Number(tarinai?.stress); + if (!Number.isFinite(before) || next <= before) return next; + return clamp(before + (next - before) * groundStressMultiplierFor(tarinai), 0, 130); +} + +function calculateStressFromNeeds(needs) { + const weightedLinear = + (Number(needs.food) || 0) * 0.09 + + (Number(needs.sleep) || 0) * 0.11 + + (Number(needs.health) || 0) * 0.22 + + (Number(needs.safety) || 0) * 0.26 + + (Number(needs.social) || 0) * 0.07 + + (Number(needs.fulfill) || 0) * 0.13; + const peakNeed = Math.max(0, ...TARINAI_NEED_KEYS.map(key => Number(needs?.[key] || 0) || 0)); + const peakPressure = Math.max(0, peakNeed - 62) * 0.10; + const quadraticPressure = + needPressure(needs.food) * 0.10 + + needPressure(needs.sleep) * 0.12 + + needPressure(needs.health) * 0.25 + + needPressure(needs.safety) * 0.30 + + needPressure(needs.social) * 0.08 + + needPressure(needs.fulfill) * 0.15; + return clamp( + weightedLinear + + peakPressure + + quadraticPressure * 0.35, + 0, + 130 + ); +} + + +function applyNeedShock(tarinai, deltas = {}, breaker = null) { + if (!tarinai || tarinai.dead) return false; + tarinai.needShock = tarinai.needShock || {}; + for (const key of TARINAI_NEED_KEYS) { + const delta = Number(deltas[key] || 0) || 0; + if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta); + else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta); + } + if (breaker) tarinai.lastNeedShockBreaker = breaker; + if (tarinai.needs && typeof calculateStressFromNeeds === "function") { + const projected = { ...tarinai.needs }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0)); + tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(projected)); + } + return true; +} + +function applyNeedSatisfaction(tarinai, deltas = {}, source = "") { + if (!tarinai || tarinai.dead) return false; + tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds(); + tarinai.lastSatisfiedAt = tarinai.lastSatisfiedAt || {}; + const now = tarinai.world?.time || 0; + let changed = false; + for (const key of TARINAI_NEED_KEYS) { + const amount = Math.max(0, Number(deltas[key] || 0) || 0); + if (amount <= 0) continue; + tarinai.needSatisfaction[key] = clamp((tarinai.needSatisfaction[key] || 0) + amount, 0, 88); + tarinai.lastSatisfiedAt[key] = now; + changed = true; + } + if (source) tarinai.lastSatisfactionSource = source; + return changed; +} + +function applyNeedRelief(tarinai, deltas = {}) { + if (!tarinai || tarinai.dead) return false; + const shockDeltas = {}; + const satisfactionDeltas = {}; + let hasShock = false; + let hasSatisfaction = false; + for (const key of TARINAI_NEED_KEYS) { + const delta = Number(deltas[key] || 0) || 0; + if (delta < 0) { + satisfactionDeltas[key] = -delta; + hasSatisfaction = true; + } else if (delta > 0) { + shockDeltas[key] = delta; + hasShock = true; + } + } + if (hasSatisfaction) applyNeedSatisfaction(tarinai, satisfactionDeltas, "relief"); + if (hasShock) applyNeedShock(tarinai, shockDeltas, null); + return hasSatisfaction || hasShock; +} + +function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { + let actions = null; + try { actions = TARINAI_ACTIONS; } catch (err) { actions = null; } + if (!tarinai || tarinai.dead || !Array.isArray(actions)) return false; + const world = tarinai.world || globalThis.world || null; + const aggression = Number(tarinai.currentPersonality?.aggression || tarinai.personalityProfile?.().fight || 0); + const fightLikeTarget = breaker && breaker.type !== "firecracker" && breaker.kind !== "ant" && breaker.id && world?.canFightPair?.(tarinai, breaker); + const fightAction = actions.find(a => a.id === "fight_rival"); + if (fightLikeTarget && aggression > 0.62 && fightAction) { + tarinai.conflictTargetId = breaker.id; + tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72); + const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 }; + const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world); + if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) { + tarinai.lastNeedShockBreaker = null; + return true; + } + } + const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee"); + if (!panicAction) return false; + const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 }; + const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world); + if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) { + tarinai.lastNeedShockBreaker = null; + return true; + } + return false; +} + +function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { + if (!tarinai || tarinai.dead) return false; + const before = tarinai.previousNeeds || createDefaultNeeds(); + const projected = { ...(tarinai.needs || createDefaultNeeds()) }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0)); + const safetyDelta = (projected.safety || 0) - (before.safety || 0); + const healthDelta = (projected.health || 0) - (before.health || 0); + const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (!shockTriggered) return false; + if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false; + return startNeedDrivenEmergencyReaction(tarinai, breaker, reason); +} + +function needThreshold(need, kind = "start") { + return TARINAI_NEED_THRESHOLDS[need]?.[kind] ?? (kind === "continue" ? 30 : 50); +} + +function actionLockSeconds(action, tarinai = null) { + const base = Number(action?.lockSeconds ?? TARINAI_ACTION_LOCK_SECONDS[action?.id] ?? 2.0) || 2.0; + const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${action?.id || "idle"}`; + const jitter = typeof stableUnit === "function" ? (stableUnit(key, "action-lock") - 0.5) * 0.18 : (Math.random() - 0.5) * 0.12; + return Math.max(0.5, base * (1 + jitter)); +} + +function scoreNeedForAction(needs, tarinai, world, need) { + const value = Number(needs?.[need]) || 0; + let score = value; + if (need === "safety") score += 14; + else if (need === "health") score += 8; + else if (need === "food") { + const hunger = Number(tarinai?.hunger || 0) || 0; + score += 3 + Math.max(0, hunger - 70) * 0.55 + Math.max(0, hunger - 92) * 0.95; + } + if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need) score += 10; + const cooldown = Number(tarinai?.actionCooldowns?.[need] || 0) || 0; + if (cooldown > 0) score -= Math.min(22, cooldown * 2.8); + const nowBucket = Math.floor((world?.time || 0) / 8); + const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${need}:${nowBucket}`; + const noise = typeof stableUnit === "function" ? (stableUnit(key, "need-score") - 0.5) * 3.2 : (Math.random() - 0.5) * 2.0; + return score + noise; +} + +function chooseTopNeedRandom(needs, tarinai = null, world = null) { + const scores = {}; + for (const key of TARINAI_NEED_KEYS) scores[key] = scoreNeedForAction(needs, tarinai, world, key); + const max = Math.max(...TARINAI_NEED_KEYS.map(key => scores[key])); + const tiedNeeds = TARINAI_NEED_KEYS.filter(key => max - scores[key] <= 1.5); + const need = TARINAI_NEED_PRIORITY.find(key => tiedNeeds.includes(key)) || tiedNeeds[0] || "fulfill"; + return { need, tiedNeeds, max: Number(needs?.[need]) || 0, score: max, scores }; +} + diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 915fe72..98c5b0f 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,1194 +1,10 @@ "use strict"; -const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; -const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"]; -const TARINAI_NEED_LABELS = { - food: "\u6442\u990c", - sleep: "\u7761\u7720", - health: "\u5065\u5eb7", - safety: "\u5b89\u5168", - social: "\u95a2\u4fc2", - fulfill: "\u5145\u8db3", -}; -const TARINAI_NEED_PHRASES = { - food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", - sleep: "\u306d\u3080\u3044", - health: "\u8abf\u5b50\u304c\u60aa\u3044", - safety: "\u3042\u3076\u306a\u3044", - social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", - fulfill: "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044", -}; - -const TARINAI_NEED_THRESHOLDS = { - food: { start: 64, continue: 20 }, - sleep: { start: 62, continue: 30 }, - health: { start: 54, continue: 32 }, - safety: { start: 42, continue: 20 }, - social: { start: 52, continue: 30 }, - fulfill: { start: 48, continue: 26 }, -}; - -const TARINAI_NEED_SATISFACTION_DECAY = { - food: 0.85, - sleep: 0.75, - health: 0.65, - safety: 1.10, - social: 1.05, - fulfill: 1.15, -}; - -const TARINAI_ACTION_LOCK_SECONDS = { - eat_food: 0.85, - drink_water: 1.8, - sleep_in_bed: 18.0, - sleep_build_grass_bed: 10.0, - sleep_anywhere: 12.0, - use_medicine: 2.4, - rest_to_recover: 6.0, - sunbath: 10.5, - panic_escape: 2.1, - flee: 1.7, - hide_at_owned_structure: 6.4, - approach_friend: 6.4, - approach_parent_or_child: 7.0, - approach_mate: 5.6, - fight_rival: 3.6, - intimidate_enemy: 1.15, - play: 8.2, - build_grass_bed: 10.0, - build_plushie: 10.0, - return_owned_structure: 6.4, - use_plushie: 7.4, - wander_lightly: 4.2, -}; - -const TARINAI_ACTION_PRIORITY = { - fight_rival: 56, - intimidate_enemy: 42, - panic_escape: 94, - flee: 92, - hide_at_owned_structure: 90, - use_medicine: 86, - rest_to_recover: 84, - sunbath: 91, - sleep_in_bed: 86, - sleep_build_grass_bed: 85, - sleep_anywhere: 84, - birth_ritual: 72, - approach_mate: 68, - approach_parent_or_child: 66, - approach_friend: 66, - eat_food: 50, - drink_water: 48, - build_grass_bed: 30, - build_plushie: 28, - use_plushie: 24, - return_owned_structure: 22, - play: 20, - wander_lightly: 10, -}; - -function actionPriority(actionOrId) { - const id = typeof actionOrId === "string" ? actionOrId : actionOrId?.id; - if (!id) return 0; - return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0; -} - -const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"]; -const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = { - fight_mochi: 180, - love_mochi: 150, - sleep_drug: 145, - drug: 140, - user: 200, - external: 130, -}; - -function createDefaultNeeds() { - return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; -} - -function quantizeNeed(value) { - return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); -} - -function getNeedDisplayValue(value) { - return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10))); -} - -function needPressure(value) { - const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100; - return x * x * 100; -} - -function calculateStressFromNeeds(needs) { - const weightedLinear = - (Number(needs.food) || 0) * 0.09 + - (Number(needs.sleep) || 0) * 0.11 + - (Number(needs.health) || 0) * 0.22 + - (Number(needs.safety) || 0) * 0.26 + - (Number(needs.social) || 0) * 0.07 + - (Number(needs.fulfill) || 0) * 0.13; - const peakNeed = Math.max(0, ...TARINAI_NEED_KEYS.map(key => Number(needs?.[key] || 0) || 0)); - const peakPressure = Math.max(0, peakNeed - 62) * 0.10; - const quadraticPressure = - needPressure(needs.food) * 0.10 + - needPressure(needs.sleep) * 0.12 + - needPressure(needs.health) * 0.25 + - needPressure(needs.safety) * 0.30 + - needPressure(needs.social) * 0.08 + - needPressure(needs.fulfill) * 0.15; - return clamp( - weightedLinear + - peakPressure + - quadraticPressure * 0.35, - 0, - 130 - ); -} - - -function applyNeedShock(tarinai, deltas = {}, breaker = null) { - if (!tarinai || tarinai.dead) return false; - tarinai.needShock = tarinai.needShock || {}; - for (const key of TARINAI_NEED_KEYS) { - const delta = Number(deltas[key] || 0) || 0; - if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta); - else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta); - } - if (breaker) tarinai.lastNeedShockBreaker = breaker; - if (tarinai.needs && typeof calculateStressFromNeeds === "function") { - const projected = { ...tarinai.needs }; - for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0)); - tarinai.stress = calculateStressFromNeeds(projected); - } - return true; -} - -function applyNeedSatisfaction(tarinai, deltas = {}, source = "") { - if (!tarinai || tarinai.dead) return false; - tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds(); - tarinai.lastSatisfiedAt = tarinai.lastSatisfiedAt || {}; - const now = tarinai.world?.time || 0; - let changed = false; - for (const key of TARINAI_NEED_KEYS) { - const amount = Math.max(0, Number(deltas[key] || 0) || 0); - if (amount <= 0) continue; - tarinai.needSatisfaction[key] = clamp((tarinai.needSatisfaction[key] || 0) + amount, 0, 88); - tarinai.lastSatisfiedAt[key] = now; - changed = true; - } - if (source) tarinai.lastSatisfactionSource = source; - return changed; -} - -function applyNeedRelief(tarinai, deltas = {}) { - if (!tarinai || tarinai.dead) return false; - const shockDeltas = {}; - const satisfactionDeltas = {}; - let hasShock = false; - let hasSatisfaction = false; - for (const key of TARINAI_NEED_KEYS) { - const delta = Number(deltas[key] || 0) || 0; - if (delta < 0) { - satisfactionDeltas[key] = -delta; - hasSatisfaction = true; - } else if (delta > 0) { - shockDeltas[key] = delta; - hasShock = true; - } - } - if (hasSatisfaction) applyNeedSatisfaction(tarinai, satisfactionDeltas, "relief"); - if (hasShock) applyNeedShock(tarinai, shockDeltas, null); - return hasSatisfaction || hasShock; -} - -function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { - let actions = null; - try { actions = TARINAI_ACTIONS; } catch (err) { actions = null; } - if (!tarinai || tarinai.dead || !Array.isArray(actions)) return false; - const world = tarinai.world || globalThis.world || null; - const aggression = Number(tarinai.currentPersonality?.aggression || tarinai.personalityProfile?.().fight || 0); - const fightLikeTarget = breaker && breaker.type !== "firecracker" && breaker.kind !== "ant" && breaker.id && world?.canFightPair?.(tarinai, breaker); - const fightAction = actions.find(a => a.id === "fight_rival"); - if (fightLikeTarget && aggression > 0.62 && fightAction) { - tarinai.conflictTargetId = breaker.id; - tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72); - const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 }; - const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world); - if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) { - tarinai.lastNeedShockBreaker = null; - return true; - } - } - const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee"); - if (!panicAction) return false; - const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 }; - const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world); - if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) { - tarinai.lastNeedShockBreaker = null; - return true; - } - return false; -} - -function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { - if (!tarinai || tarinai.dead) return false; - const before = tarinai.previousNeeds || createDefaultNeeds(); - const projected = { ...(tarinai.needs || createDefaultNeeds()) }; - for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0)); - const safetyDelta = (projected.safety || 0) - (before.safety || 0); - const healthDelta = (projected.health || 0) - (before.health || 0); - const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0); - const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; - if (!shockTriggered) return false; - if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false; - return startNeedDrivenEmergencyReaction(tarinai, breaker, reason); -} - -function needThreshold(need, kind = "start") { - return TARINAI_NEED_THRESHOLDS[need]?.[kind] ?? (kind === "continue" ? 30 : 50); -} - -function actionLockSeconds(action, tarinai = null) { - const base = Number(action?.lockSeconds ?? TARINAI_ACTION_LOCK_SECONDS[action?.id] ?? 2.0) || 2.0; - const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${action?.id || "idle"}`; - const jitter = typeof stableUnit === "function" ? (stableUnit(key, "action-lock") - 0.5) * 0.18 : (Math.random() - 0.5) * 0.12; - return Math.max(0.5, base * (1 + jitter)); -} - -function scoreNeedForAction(needs, tarinai, world, need) { - const value = Number(needs?.[need]) || 0; - let score = value; - if (need === "safety") score += 14; - else if (need === "health") score += 8; - else if (need === "food") { - const hunger = Number(tarinai?.hunger || 0) || 0; - score += 3 + Math.max(0, hunger - 70) * 0.55 + Math.max(0, hunger - 92) * 0.95; - } - if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need) score += 10; - const cooldown = Number(tarinai?.actionCooldowns?.[need] || 0) || 0; - if (cooldown > 0) score -= Math.min(22, cooldown * 2.8); - const nowBucket = Math.floor((world?.time || 0) / 8); - const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${need}:${nowBucket}`; - const noise = typeof stableUnit === "function" ? (stableUnit(key, "need-score") - 0.5) * 3.2 : (Math.random() - 0.5) * 2.0; - return score + noise; -} - -function chooseTopNeedRandom(needs, tarinai = null, world = null) { - const scores = {}; - for (const key of TARINAI_NEED_KEYS) scores[key] = scoreNeedForAction(needs, tarinai, world, key); - const max = Math.max(...TARINAI_NEED_KEYS.map(key => scores[key])); - const tiedNeeds = TARINAI_NEED_KEYS.filter(key => max - scores[key] <= 1.5); - const need = TARINAI_NEED_PRIORITY.find(key => tiedNeeds.includes(key)) || tiedNeeds[0] || "fulfill"; - return { need, tiedNeeds, max: Number(needs?.[need]) || 0, score: max, scores }; -} - -function fulfillPrimaryReason(tarinai, action = null) { - const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - const actionId = action?.id || behavior?.id || ""; - if (actionId === "build_grass_bed") return "\u5bdd\u5e8a\u304c\u307b\u3057\u3044"; - if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044"; - if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"; - if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044"; - if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b"; - if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b"; - - const parts = tarinai?.fulfillReasonParts || {}; - const candidates = [ - ["bed", "\u5bdd\u5e8a\u304c\u307b\u3057\u3044", parts.bed], - ["plushie", "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044", parts.plushie], - ["boredom", "\u9000\u5c48\u3057\u3066\u3044\u308b", parts.boredom], - ["material", "\u8349\u3067\u306a\u306b\u304b\u4f5c\u308c\u305d\u3046", parts.material], - ["openness", "\u65b0\u3057\u3044\u3053\u3068\u304c\u6c17\u306b\u306a\u308b", parts.openness], - ].filter(([, , v]) => Number(v || 0) > 0); - candidates.sort((a, b) => Number(b[2] || 0) - Number(a[2] || 0)); - return candidates[0]?.[1] || TARINAI_NEED_PHRASES.fulfill || "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044"; -} - -function needPhraseForReason(need, tarinai = null, action = null) { - if (need === "fulfill") return fulfillPrimaryReason(tarinai, action); - if (need === "social" && action?.subNeed === "mate") return "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u304c\u6c17\u306b\u306a\u308b"; - if (need === "social" && action?.id === "birth_ritual") return "\u76f8\u624b\u3068\u7e41\u6b96\u3057\u3066\u3044\u308b"; - if (need === "social" && action?.subNeed === "conflict") return "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b"; - if (need === "social" && action?.subNeed === "family") return "\u5bb6\u65cf\u304c\u6c17\u306b\u306a\u308b"; - if (need === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b"; - if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044"; - return TARINAI_NEED_PHRASES[need] || need; -} - -function actionTextForReason(action = null, tarinai = null) { - const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - const id = action?.id || behavior?.id || ""; - const target = tarinai?.target || currentBehaviorTarget?.(tarinai) || null; - const name = target?.name || "\u76f8\u624b"; - if (id === "birth_ritual") return target?.name ? `${name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; - if (id === "fight_rival") { - const cause = String(behavior?.causeText || ""); - if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b"; - return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b"; - } - if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - return String(action?.label || action?.phrase || behavior?.label || "\u5f85\u3063\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, ""); -} - -function forcedCausePhrase(source = "", fallback = "") { - const key = String(source || ""); - if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c"; - if (key === "fight_mochi") return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c"; - if (key === "sleep_drug") return "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c"; - if (key === "drug") return "\u85ac\u306e\u52b9\u679c"; - return String(fallback || "").replace(/[\u3002\uff01\uff1f]+$/, ""); -} - -function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u3066\u3044\u308b") { - const c = String(cause || "").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); - const a = String(actionText || "\u884c\u52d5\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); - if (!c) return `${a}\u3002`; - if (c.endsWith("\u52b9\u679c") || c.endsWith("\u5f71\u97ff")) return `${c}\u3067\u3001${a}\u3002`; - if (c.endsWith("\u6c17\u4ed8\u3044\u305f")) return `${c.replace(/\u6c17\u4ed8\u3044\u305f$/, "\u6c17\u4ed8\u3044\u3066")}\u3001${a}\u3002`; - if (c.endsWith("\u6c17\u3065\u3044\u305f")) return `${c.replace(/\u6c17\u3065\u3044\u305f$/, "\u6c17\u3065\u3044\u3066")}\u3001${a}\u3002`; - if (c.endsWith("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f")) return `${c.replace(/\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f$/, "\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")}\u3001${a}\u3002`; - if (c.endsWith("\u5a01\u5687\u3055\u308c\u305f")) return `${c.replace(/\u5a01\u5687\u3055\u308c\u305f$/, "\u5a01\u5687\u3055\u308c")}\u3001${a}\u3002`; - return `${c}\u306e\u3067\u3001${a}\u3002`; -} - -function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) { - if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, ""); - const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {}; - if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, ""); - if (b.forced || b.source) { - const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.forcedReasonText || ""); - if (forced) return forced; - } - if (action?.id === "panic_escape" && tarinai?.lastPanicDetail) return String(tarinai.lastPanicDetail).replace(/[\u3002\uff01\uff1f]+$/, ""); - if (action?.id === "fight_rival") { - const target = currentBehaviorTarget?.(tarinai) || tarinai?.target || null; - if (tarinai?.counterAttackFromId && target?.id === tarinai.counterAttackFromId) return `${target.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`; - if (target?.name) return `${target.name}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`; - } - if (action?.id === "birth_ritual" || action?.subNeed === "mate") { - if ((tarinai?.loveMochiTimer || 0) > 0.04) return "\u3078\u3053\u9905\u306e\u52b9\u679c"; - } - return needPhraseForReason(chosenNeed, tarinai, action); -} - -function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) { - const actionText = actionTextForReason(action, tarinai); - const cause = causePhraseForReason(chosenNeed, action, tarinai, options); - const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; - const main = formatCauseActionText(cause, actionText); - if (tied.length) { - const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; - return `${needPhraseForReason(ignored, tarinai, action)}\u3051\u3069\u3001${main}`; - } - return main; -} - - -function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") { - const primary = String(behavior?.need || fallbackNeed || "fulfill"); - const raw = tarinai?.needRaw || tarinai?.needs || {}; - const list = Array.isArray(behavior?.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [primary]; - if (!list.includes(primary)) list.unshift(primary); - return list.filter((key, idx) => key && (idx === 0 || Number(raw?.[key] || 0) > 0.5)); -} - -function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) { - const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null; - const directCause = options?.causeText || behavior?.causeText || request?.causeText || ""; - if (directCause) return String(directCause || "").replace(/[。!?]+$/, ""); - if (behavior?.forced || request) { - const source = request?.source || behavior?.source || "external"; - const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.forcedReasonText || ""; - const forcedCause = forcedCausePhrase(source, sourceReason); - if (forcedCause) return forcedCause; - } - return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options); -} - -function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) { - const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; - if (!b) return ""; - const stored = String(b.presentText || b.reasonText || "").trim(); - if (stored && options.regenerate !== true) return stored; - const action = options.action || actionById(b.specId || b.id) || actionById(b.id) || null; - const need = String(options.need || b.need || action?.need || "fulfill"); - const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, need); - const causeText = causeForBehaviorText(tarinai, { ...b, need }, action, options); - const generated = action ? buildReasonText(need, tiedNeeds, action, tarinai, tarinai?.world || null, { ...options, causeText }) : ""; - const actionLabel = typeof textTarinaiAction === "function" ? textTarinaiAction(action || b.id, tarinai, tarinai?.world || null, { ...options, behavior: b, need, tiedNeeds, causeText, phase: options.phase || b.phase || "active" }) : ""; - const fallback = String(actionLabel || b.label || "").trim(); - return String(generated || fallback || "").trim(); -} - -function patchBehaviorTextFromComposer(tarinai, behavior = null, options = {}) { - const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; - if (!b) return ""; - const prevReason = String(b.reasonText || ""); - const text = composeTarinaiBehaviorText(tarinai, b, { ...options, regenerate: true }); - if (!text) return ""; - const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, b.need || options.need || "fulfill"); - if (text !== b.reasonText || text !== b.presentText || JSON.stringify(tiedNeeds) !== JSON.stringify(b.tiedNeeds || [])) { - if (typeof patchTarinaiBehavior === "function") patchTarinaiBehavior(tarinai, { reasonText: text, presentText: text, tiedNeeds }); - else { b.reasonText = text; b.presentText = text; b.tiedNeeds = tiedNeeds; if (typeof syncTarinaiBehaviorLegacyViews === "function") syncTarinaiBehaviorLegacyViews(b); } - } - if (text && (!tarinai.thought || tarinai.thought === prevReason || tarinai.thought === b.label)) tarinai.thought = text; - return text; -} - -function refreshIntentReasonText(tarinai, action = null) { - const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - if (!behavior?.need) return ""; - return patchBehaviorTextFromComposer(tarinai, behavior, { action }); -} - - - - - -function actionById(id = "") { - const key = String(id || ""); - return (typeof getTarinaiActionSpec === "function" ? getTarinaiActionSpec(key) : null) - || (Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null); -} - -function behaviorTargetId(target = null) { - if (!target) return null; - if (target.id != null) return target.id; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; - return null; -} - -function currentBehaviorTarget(tarinai, behavior = null) { - if (!tarinai) return null; - const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || null; - if (!b) return tarinai.target || null; - if (tarinai.target && !tarinai.target.dead && (!b.targetId || behaviorTargetId(tarinai.target) === b.targetId)) return tarinai.target; - if (!b.targetId) return null; - const id = b.targetId; - return tarinai.world?.liveTarinaiById?.(id) - || tarinai.world?.itemById?.(id) - || (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id) - || null; -} - -function isLiveTarinaiEntity(value) { - return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y)); -} - -function relationFearSafe(holder, other) { - if (!holder || !other || typeof holder.relationTo !== "function") return 0; - return Number(holder.relationTo(other.id)?.fear || 0) || 0; -} - -function nearestForcedFightTarget(tarinai, world, maxDist = 720) { - if (!tarinai || !world) return null; - const current = currentBehaviorTarget(tarinai) || tarinai.target || null; - const ok = o => isLiveTarinaiEntity(o) && o !== tarinai && !!o.isZunchiSlave === !!tarinai.isZunchiSlave && !world.areParentChild?.(tarinai, o) && !world.areCoParents?.(tarinai, o) && !o.sleepDisease; - if (ok(current) && dist(tarinai, current) <= maxDist + 120) return current; - const conflict = conflictTargetFor(tarinai, world, 6); - if (ok(conflict?.target)) return conflict.target; - return world.nearestOther?.(tarinai, maxDist, ok) || null; -} - -function resolveForcedBehaviorTarget(tarinai, world, entry = {}, action = null) { - if (!tarinai || !world) return null; - if (entry.targetId) { - const found = world.liveTarinaiById?.(entry.targetId) - || world.itemById?.(entry.targetId) - || (world.items || []).find(item => item && !item.dead && item.id === entry.targetId) - || null; - if (found) return found; - } - const id = String(entry.id || action?.id || ""); - if (id === "fight_rival" || id === "intimidate_enemy") return nearestForcedFightTarget(tarinai, world, Number(entry.searchRange || 760)); - if (id === "approach_mate" || id === "birth_ritual") return mateTargetFor(tarinai, world, Number(entry.searchRange || 760)); - if (id === "eat_food") return behaviorFoodTarget(tarinai, world, "food", Number(entry.searchRange || 900)); - if (id === "drink_water") return behaviorFoodTarget(tarinai, world, "drink", Number(entry.searchRange || 680)); - if (id === "use_medicine") return behaviorFoodTarget(tarinai, world, "medicine", Number(entry.searchRange || 760)); - if (id === "sleep_in_bed") return findNearestSleepPlace(world, tarinai, Number(entry.searchRange || 760)); - return null; -} - -function forcedBehaviorSourcePriority(source = "external") { - return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130; -} - -function forcedBehaviorQueueOf(tarinai) { - if (!tarinai) return []; - if (!Array.isArray(tarinai.forcedBehaviorQueue)) tarinai.forcedBehaviorQueue = []; - return tarinai.forcedBehaviorQueue; -} - -function setForcedBehaviorQueue(tarinai, queue = []) { - if (!tarinai) return []; - tarinai.forcedBehaviorQueue = (Array.isArray(queue) ? queue : []).filter(Boolean); - return tarinai.forcedBehaviorQueue; -} - -function normalizeForcedBehaviorEntry(tarinai, actionId, opts = {}) { - const now = tarinai?.world?.time || 0; - const source = opts.source || "external"; - const basePriority = forcedBehaviorSourcePriority(source); - const raw = { - uid: opts.uid || `${String(actionId)}:${source}:${now.toFixed(3)}:${Math.random().toString(36).slice(2, 8)}`, - id: String(actionId), - targetId: opts.targetId ?? behaviorTargetId(opts.target), - reasonText: opts.reasonText || opts.reason || "", - causeText: opts.causeText || "", - source, - priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority, - force: opts.force !== false, - replaceSameSource: opts.replaceSameSource !== false, - interrupt: opts.interrupt !== false, - createdAt: now, - status: opts.status || "queued", - attempts: Number(opts.attempts || 0) || 0, - lastTriedAt: Number(opts.lastTriedAt ?? -999) || -999, - retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25, - expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18), - duration: Number(opts.duration ?? 0) || 0, - minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0, - searchRange: Number(opts.searchRange ?? 0) || 0, - }; - return typeof normalizeTarinaiForcedRequest === "function" ? { ...raw, ...normalizeTarinaiForcedRequest(raw, raw), force: raw.force, replaceSameSource: raw.replaceSameSource, interrupt: raw.interrupt, retryDelay: raw.retryDelay, attempts: raw.attempts, lastTriedAt: raw.lastTriedAt, searchRange: raw.searchRange } : raw; -} - -function markForcedBehaviorActive(tarinai, entry, action = null) { - if (!tarinai || !entry) return null; - entry.status = "active"; - entry.activeSince = entry.activeSince || tarinai.world?.time || 0; - entry.startedAt = entry.startedAt || entry.activeSince; - const payload = { ...entry, id: action?.id || entry.id, status: "active" }; - return typeof setTarinaiForcedBehavior === "function" ? setTarinaiForcedBehavior(tarinai, payload) : payload; -} - -function currentForcedBehaviorRequest(tarinai) { - if (typeof getTarinaiForcedBehavior === "function") return getTarinaiForcedBehavior(tarinai); - const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - return b?.forced ? (b.forcedRequest || b._activeForcedBehavior || { uid: b.forcedId || "", id: b.id || "", source: b.source || "external", priority: Number(b.priority || 0) || 0 }) : null; -} - -function clearForcedBehaviorQueue(tarinai, predicate = null) { - if (!tarinai) return 0; - const queue = forcedBehaviorQueueOf(tarinai); - const before = queue.length; - const next = typeof predicate !== "function" ? [] : queue.filter(entry => !predicate(entry)); - setForcedBehaviorQueue(tarinai, next); - const active = currentForcedBehaviorRequest(tarinai); - if (active?.uid && !next.some(entry => entry && entry.uid === active.uid) && typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); - return before - next.length; -} - -function activeBehaviorText(tarinai) { - const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - if (!b) return ""; - const state = String(tarinai.state || ""); - const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04; - const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state); - if (!validByState && !b.forced) return ""; - return composeTarinaiBehaviorText(tarinai, b) || String(b.presentText || b.reasonText || b.label || "").trim(); -} - - -function setActiveBehavior(tarinai, fields = {}) { - if (!tarinai || tarinai.dead) return null; - const now = tarinai.world?.time || 0; - const previous = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; - const actionId = fields.id || fields.actionId || previous.id || tarinai.state || "idle"; - const action = fields.action || actionById(actionId) || null; - const need = fields.need || action?.need || previous.need || "fulfill"; - const label = fields.label || fields.actionLabel || action?.label || previous.label || "\u884c\u52d5\u3057\u3066\u3044\u308b"; - const reasonText = fields.reasonText || fields.presentText || label; - const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previous.id === actionId ? previous.startedAt : now); - const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0; - const minDuration = Number(fields.minDuration ?? previous.minDuration ?? Math.max(0.45, lockSeconds * 0.72)) || 0.45; - const target = fields.target !== undefined ? fields.target : tarinai.target; - const targetId = fields.targetId !== undefined ? fields.targetId : behaviorTargetId(target); - const next = { - id: actionId, - need, - subNeed: fields.subNeed || action?.subNeed || previous.subNeed || "", - phase: fields.phase || previous.phase || (String(tarinai.state || "") === "idle" ? "idle" : "active"), - state: fields.state || action?.state || tarinai.state || previous.state || "idle", - label, - presentText: fields.presentText || reasonText, - reasonText, - targetId, - startedAt, - updatedAt: now, - minDuration, - lockSeconds, - deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt, - source: fields.source || previous.source || "need", - sourceReasonText: fields.sourceReasonText || fields.forcedReasonText || previous.sourceReasonText || previous.forcedReasonText || "", - forcedReasonText: fields.forcedReasonText || fields.sourceReasonText || previous.forcedReasonText || previous.sourceReasonText || "", - causeText: fields.causeText || previous.causeText || "", - forced: Boolean(fields.forced ?? previous.forced ?? false), - forcedId: fields.forcedId || previous.forcedId || "", - forcedRequest: fields.forcedRequest || previous.forcedRequest || null, - priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0, - specId: fields.specId || action?.id || previous.specId || actionId, - specKind: fields.specKind || action?.kind || previous.specKind || "need_action", - }; - tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1; - next.serial = tarinai.behaviorSerial; - if (typeof setTarinaiBehavior === "function") setTarinaiBehavior(tarinai, next); - else tarinai.behavior = typeof normalizeTarinaiBehaviorValue === "function" ? normalizeTarinaiBehaviorValue(next, tarinai, previous) : next; - if (target !== undefined) tarinai.target = target; - if (reasonText) tarinai.thought = reasonText; - return next; -} - -function setActiveBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", options = {}) { - if (!action) return null; - return setActiveBehavior(tarinai, { - id: action.id, - action: action, - need: choice.need || action.need, - subNeed: options.subNeed || action.subNeed || "", - state: options.state || action.state || tarinai?.state || "idle", - label: action.label || options.actionLabel || "\u884c\u52d5\u3057\u3066\u3044\u308b", - presentText: reasonText || action.label, - reasonText: reasonText || action.label, - target: options.target !== undefined ? options.target : tarinai?.target, - tiedNeeds: choice.tiedNeeds || [choice.need || action.need], - source: options.source || "need", - forced: Boolean(options.forced), - forcedId: options.forcedId || "", - sourceReasonText: options.sourceReasonText || options.forcedReasonText || "", - forcedReasonText: options.forcedReasonText || options.sourceReasonText || "", - causeText: options.causeText || "", - forcedRequest: options.forcedRequest || null, - priority: options.priority ?? actionPriority(action), - lockSeconds: options.lockSeconds, - minDuration: options.minDuration, - phase: options.phase || "start", - specId: action.id, - specKind: action.kind || "need_action", - }); -} - -function clearActiveBehavior(tarinai, reason = "") { - if (!tarinai) return; - if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(tarinai, { reason }); - else tarinai.behavior = null; - if (reason) tarinai.thought = reason; -} - -function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) { - if (!tarinai || tarinai.dead || !actionId) return false; - const entry = normalizeForcedBehaviorEntry(tarinai, actionId, opts); - let queue = forcedBehaviorQueueOf(tarinai); - if (entry.replaceSameSource) queue = queue.filter(e => !(e && e.id === entry.id && e.source === entry.source)); - queue.push(entry); - queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); - setForcedBehaviorQueue(tarinai, queue.slice(0, 12)); - if (opts.target) tarinai.target = opts.target; - if (opts.applyShock && typeof applyNeedShock === "function") applyNeedShock(tarinai, opts.applyShock, opts.target || null); - return true; -} - - -function completeForcedBehavior(tarinai, behavior = null, status = "completed") { - if (!tarinai) return false; - const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; - const forcedId = b.forcedId || ""; - if (!forcedId) return false; - const queue = forcedBehaviorQueueOf(tarinai); - const before = queue.length; - const next = queue.filter(entry => entry && entry.uid !== forcedId); - setForcedBehaviorQueue(tarinai, next); - if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); - return before !== next.length; -} - -function retryForcedBehavior(tarinai, behavior = null, reason = "retry") { - if (!tarinai) return false; - const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; - const forcedId = b.forcedId || ""; - if (!forcedId) return false; - const now = tarinai.world?.time || 0; - const entry = forcedBehaviorQueueOf(tarinai).find(e => e && e.uid === forcedId); - if (!entry) return false; - entry.status = "queued"; - entry.lastFailedAt = now; - entry.failReason = reason; - entry.activeSince = 0; - if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); - return true; -} - -function processForcedBehaviorQueue(tarinai, world, needs) { - if (!tarinai || tarinai.dead) return false; - const now = world?.time || 0; - let queue = forcedBehaviorQueueOf(tarinai).filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now)); - if (!queue.length) { - setForcedBehaviorQueue(tarinai, []); - if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); - return false; - } - - // \u5b9f\u884c\u4e2d\u306e\u5f37\u5236\u884c\u52d5\u306f\u30ad\u30e5\u30fc\u306b\u6b8b\u3057\u3001\u5b8c\u4e86/\u5931\u6557/\u671f\u9650\u5207\u308c\u307e\u3067\u52b9\u679c\u3092\u4fdd\u6301\u3059\u308b\u3002 - const activeQueueBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior; - const activeForcedId = activeQueueBehavior?.forcedId || ""; - for (const entry of queue) { - if (!entry.uid) entry.uid = `${entry.id}:${entry.source || "external"}:${entry.createdAt || now}:${Math.random().toString(36).slice(2, 8)}`; - if (entry.uid === activeForcedId && activeQueueBehavior?.forced && activeQueueBehavior?.id === entry.id) { - markForcedBehaviorActive(tarinai, entry, actionById(entry.id)); - } else if (entry.status === "active") { - // activeBehavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002 - entry.status = "queued"; - entry.lastFailedAt = now; - } - } - - queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); - setForcedBehaviorQueue(tarinai, queue); - - const activePriority = Number(activeQueueBehavior?.priority ?? actionPriority(activeQueueBehavior?.id)) || 0; - const activeForced = Boolean(activeQueueBehavior?.forced); - - for (let i = 0; i < queue.length; i++) { - const entry = queue[i]; - const action = actionById(entry.id); - if (!action) { queue.splice(i, 1); i--; continue; } - if (entry.status === "active" && activeQueueBehavior?.forcedId === entry.uid) continue; - const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0; - if (activeQueueBehavior && (tarinai.intentLockTimer || 0) > 0.08) { - if (activeForced && entryPriority <= activePriority + 8) continue; - if (!entry.interrupt && entryPriority <= activePriority) continue; - if (entryPriority <= activePriority && !entry.force) continue; - } - if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue; - entry.lastTriedAt = now; - entry.attempts = (Number(entry.attempts || 0) || 0) + 1; - - const target = resolveForcedBehaviorTarget(tarinai, world, entry, action); - if (target) { - tarinai.target = target; - entry.targetId = behaviorTargetId(target); - } - if ((action.id === "fight_rival" || action.id === "intimidate_enemy" || action.id === "approach_mate" || action.id === "birth_ritual") && !target) { - continue; - } - - const need = action.need || entry.need || "safety"; - const choice = { need, tiedNeeds: [need], max: Math.max(needThreshold(need, "start"), Number(needs?.[need] || 0) || 0) }; - const causeText = entry.causeText || forcedCausePhrase(entry.source || "", entry.reasonText || ""); - const reasonText = buildReasonText(need, choice.tiedNeeds, action, tarinai, world, { causeText }); - if (activeQueueBehavior && entryPriority > activePriority) { - retryForcedBehavior(tarinai, activeQueueBehavior, "interrupted_by_higher_forced_behavior"); - clearActiveBehavior(tarinai); - tarinai.intentLockTimer = 0; - if (tarinai.state === "sleep" || tarinai.sleeping) { - tarinai.sleeping = false; - tarinai.sleepSession = null; - } - } - const started = startNeedAction(tarinai, world, choice, action, reasonText, { - forced: true, - forcedId: entry.uid, - forcedReasonText: entry.reasonText || reasonText, - sourceReasonText: entry.reasonText || reasonText, - causeText, - priority: entryPriority, - source: entry.source, - target, - forcedRequest: { ...entry, status: "active", reasonText: entry.reasonText || reasonText, causeText }, - lockSeconds: entry.duration || undefined, - minDuration: entry.minDuration || undefined, - }); - if (started) { - markForcedBehaviorActive(tarinai, entry, action); - setForcedBehaviorQueue(tarinai, queue); - return true; - } - } - setForcedBehaviorQueue(tarinai, queue); - return false; -} - -function setLiveActionText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) { - if (!tarinai) return; - const now = tarinai.world?.time || 0; - const previousBehavior = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; - const nextNeed = need || previousBehavior.need || "social"; - const nextActionId = actionId || previousBehavior.id || tarinai.state || "idle"; - const nextActionLabel = actionLabel || reasonText || previousBehavior.label || tarinai.state || "行動している"; - const action = actionById(nextActionId); - const preserveForced = Boolean(previousBehavior.forced && source === "state" && (!actionId || previousBehavior.id === nextActionId)); - let nextReasonText = reasonText || nextActionLabel; - const rawNeedsForText = tarinai.needRaw || tarinai.needs || {}; - const existingTiedNeeds = (Array.isArray(previousBehavior.tiedNeeds) && previousBehavior.tiedNeeds.length ? [...previousBehavior.tiedNeeds] : [nextNeed]) - .filter((key, idx) => idx === 0 || Number(rawNeedsForText?.[key] || 0) > 0.5); - if (existingTiedNeeds.some(key => key && key !== nextNeed) && !String(nextReasonText).includes("けど、")) { - nextReasonText = buildReasonText(nextNeed, existingTiedNeeds, { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null); - } - const nextSource = preserveForced ? (previousBehavior.source || source || "state") : (source || previousBehavior.source || "state"); - const nextForced = forced ?? Boolean(previousBehavior.forced); - const nextForcedId = forcedId || previousBehavior.forcedId || ""; - const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || previousBehavior.forcedReasonText || ""; - const nextCauseText = causeText || previousBehavior.causeText || ""; - if (nextCauseText) { - nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText }); - } - if (target !== undefined) tarinai.target = target; - setActiveBehavior(tarinai, { - id: nextActionId, - action, - need: nextNeed, - subNeed: subNeed || action?.subNeed || previousBehavior.subNeed || "", - state: tarinai.state || action?.state || "idle", - label: nextActionLabel, - presentText: nextReasonText, - reasonText: nextReasonText, - target: target !== undefined ? target : tarinai.target, - tiedNeeds: existingTiedNeeds, - source: nextSource, - sourceReasonText: nextSourceReason, - forcedReasonText: nextSourceReason, - causeText: nextCauseText, - forced: nextForced, - forcedId: nextForcedId, - forcedRequest: previousBehavior.forcedRequest || null, - priority: previousBehavior.priority ?? actionPriority(action), - phase, - startedAt: previousBehavior.startedAt || now, - minDuration: previousBehavior.minDuration || 0.8, - lockSeconds: previousBehavior.lockSeconds || 1.0, - }); -} - - - -function synchronizeActionTextFromState(tarinai) { - if (!tarinai || tarinai.dead) return; - const state = String(tarinai.state || ""); - const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : ""; - if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { - const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null; - const label = partner?.name ? `${partner.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; - setLiveActionText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b", reasonText: label, target: partner }); - return; - } - if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: catalogText || "\u98df\u3079\u3066\u3044\u308b" }); - return; - } - if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: catalogText || "\u55a7\u5629\u3057\u3066\u3044\u308b" }); - return; - } - if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "\u9003\u3052\u3066\u3044\u308b", reasonText: catalogText || "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b" }); - return; - } - if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: catalogText || "\u5a01\u5687\u3057\u3066\u3044\u308b" }); - return; - } - if (state === "sleep" || tarinai.sleeping) { - setLiveActionText(tarinai, { need: "sleep", actionId: (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai.behavior?.id) || "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: catalogText || "\u7720\u3063\u3066\u3044\u308b" }); - } -} -function effectiveFoodTypeForItem(item) { - if (!item) return ""; - return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); -} - -function isDuplicatorFoodSource(item) { - return !!item && item.type === "duplicator"; -} - -const NORMAL_POOP_MEAL_THRESHOLD = 3; - -function duplicatorPoopMultiplier(foodType = "") { - if (foodType === "sweet") return 0.035; - if (foodType === "food") return 0.040; - if (foodType === "grass") return 0.028; - return 0.042; -} - -function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) { - const type = String(foodType || ""); - if (type === "laxative" || options.laxative === true) return 0; - if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 1; - const value = Math.max(0, Number(amount) || 0); - return value / 18; -} -function effectiveFoodRoleMatches(item, role) { - const type = effectiveFoodTypeForItem(item); - if (!type) return false; - if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type)); - if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice"; - if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); - return false; -} - -function roleMatches(item, role) { - if (!item || item.dead) return false; - if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false; - if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role); - if (item.roles?.[role]) return true; - if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role); - if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; - if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(item.type); - if (role === "grassMaterial") return item.type === "grass"; - return false; -} - -function foodInteractionPoint(item) { - if (!item) return { x: 0, y: 0 }; - if (item.type === "duplicator") return { x: item.x + (item.r || 34) * 0.62, y: item.y + (item.r || 34) * 0.24 }; - return { x: item.x, y: item.y }; -} - -function foodInteractionDistance(tarinai, item) { - const p = foodInteractionPoint(item); - const mouth = tarinai?.mouthPosition?.(item) || { x: tarinai?.x || 0, y: tarinai?.y || 0 }; - return distXY(mouth.x, mouth.y, p.x, p.y); -} - -function foodInteractionReach(tarinai, item, role = "food") { - if (item?.type === "duplicator") return Math.max(14, (tarinai?.radius || 20) * 0.44 + 5); - if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40); - return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28); -} - -function foodPriorityRank(tarinai, item) { - const type = effectiveFoodTypeForItem(item); - if (!type) return -1; - const need = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need; - const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; - const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; - if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6; - if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7; - if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; - if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7; - if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4; - if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450; - if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000 + Math.max(0, healthNeed - 45) * 5; - if (type === "grass") { - const hunger = Number(tarinai?.hunger || 0) || 0; - const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0; - return 2000 + Math.max(0, hunger - 64) * 18 + Math.max(0, foodNeed - 58) * 12; - } - if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0); - if (type === "food" || type === "ant_corpse") return 3000; - if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000; - return -1; -} - -function foodPriorityScore(tarinai, item, distance = 0) { - let rank = foodPriorityRank(tarinai, item); - if (rank < 0) return -Infinity; - const hunger = Number(tarinai?.hunger || 0) || 0; - if (item?.type === "zunchi") { - if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity; - if (!tarinai?.isZunchiSlave) rank -= 1400; - if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200; - } - if (item?.type === "duplicator") rank += 85; - return rank - Math.max(0, Number(distance) || 0) * 0.65; -} - -function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { - if (!tarinai || !world) return false; - for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) { - if (!it || it.dead || it.type === "zunchi") continue; - if (tarinai.shouldAvoidTarget?.(it)) continue; - const type = effectiveFoodTypeForItem(it); - if (!type) continue; - if (["sweet", "food", "grass", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) { - if (it.type === "grass" && (it.amount || 0) <= 0) continue; - if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue; - return true; - } - } - return false; -} - -function zunchiEatingBlocked(tarinai, item, world) { - if (!tarinai || !item || item.type !== "zunchi") return false; - const itemAge = Number(item.age || 0) || 0; - const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge; - const protectedAge = Math.max(itemAge, worldAge); - const protectedUntil = Number(item.eatProtectedUntil || 0) || 0; - if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true; - const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0; - const hunger = Number(tarinai.hunger || 0) || 0; - // 排泄直後のずんちは観察・接触対象であり、通常食として即消費させない。 - // ずんちどれいも数秒は待つ。通常個体は極限飢餓でも最低90秒は食べない。 - const grace = tarinai.isZunchiSlave ? 18 : 90; - if (protectedAge < grace) return true; - if (!tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 98 && hunger < 108) return true; - return false; -} - -function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { - let best = null, bestScore = Infinity; - const hunger = Number(tarinai?.hunger || 0) || 0; - const candidates = ((role === "food" || role === "drink" || role === "medicine") && world?.nearbyFood) - ? world.nearbyFood(tarinai.x, tarinai.y, maxDist, true) - : (world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world?.items || []); - for (const item of candidates) { - if (!roleMatches(item, role)) continue; - if (tarinai?.shouldAvoidTarget?.(item)) continue; - const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item); - if (d > maxDist) continue; - let score = d; - if (role === "food" || role === "medicine") { - const priority = foodPriorityScore(tarinai, item, d); - score = -priority; - } - if (score < bestScore) { best = item; bestScore = score; } - } - return best; -} - -function sleepPlaceAvailableFor(world, tarinai, item) { - if (!world || !tarinai || !item || item.dead) return false; - if (!(item.type === "nest_box" || item.type === "bed")) return false; - if (tarinai.shouldAvoidTarget?.(item)) return false; - const capacity = item.type === "nest_box" - ? (world.nestBoxCapacity ? world.nestBoxCapacity(item) : 5) - : 1; - const occupancy = item.type === "nest_box" - ? (world.nestBoxOccupants ? world.nestBoxOccupants(item, Infinity).length : world.bedOccupancy?.(item) || 0) - : (world.bedOccupancy?.(item) || 0); - return occupancy < capacity; -} - -function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) { - let best = null; - let bestScore = Infinity; - const list = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []; - for (const item of list) { - if (!item || item.dead || item.type !== type) continue; - if (!sleepPlaceAvailableFor(world, tarinai, item)) continue; - const d = dist(tarinai, item); - if (d > maxDist) continue; - const comfort = world?.bedComfort ? world.bedComfort(item) : 1; - const score = d - comfort * 18; - if (score < bestScore) { best = item; bestScore = score; } - } - return best; -} - -function findNearestSleepPlace(world, tarinai, maxDist = 520) { - return nearestSleepPlaceOfType(world, tarinai, "nest_box", maxDist) - || nearestSleepPlaceOfType(world, tarinai, "bed", maxDist) - || findOwnedStructure(world, tarinai, "grass_bed", maxDist) - || findUnownedStructure(world, tarinai, "grass_bed", maxDist) - || null; -} - -function findNearbyDanger(world, tarinai, maxDist = 150) { - const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null; - const item = findNearestItemWithRole(world, tarinai, "danger", maxDist); - if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item; - return ant || item; -} - - -function evaluateConflictUrge(tarinai, world, maxDist = 96) { - if (!tarinai || tarinai.dead || !world) return null; - if ((tarinai.fightCooldown || 0) > 0.04 || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.postBirthPeaceTimer || 0) > 0.04) return null; - const p = tarinai.personalityProfile?.() || tarinai.currentPersonality || {}; - const aggression = Number(tarinai.currentPersonality?.aggression ?? p.fight ?? 0) || 0; - const battleDrug = (tarinai.fightMochiTimer || 0) > 0.04 || tarinai.hasFightMochiEffect?.(); - const near = typeof world.nearbyTarinai === "function" ? world.nearbyTarinai(tarinai.x, tarinai.y, maxDist) : (world.tarinai || []); - let best = null; - for (const other of near || []) { - if (!other || other === tarinai || other.dead) continue; - if (!world.canFightPair?.(tarinai, other)) continue; - if ((other.fightCooldown || 0) > 0.04 || (other.postBirthPeaceTimer || 0) > 0.04) continue; - if (world.areParentChild?.(tarinai, other) || world.areCoParents?.(tarinai, other)) continue; - const d = Math.max(1, dist(tarinai, other)); - if (d > maxDist) continue; - const rel = tarinai.relationTo?.(other.id) || {}; - const relOther = other.relationTo?.(tarinai.id) || {}; - const mixedZunchi = !!tarinai.isZunchiSlave !== !!other.isZunchiSlave; - const otherBattleDrug = (other.fightMochiTimer || 0) > 0.04 || other.hasFightMochiEffect?.(); - const closePressure = clamp(1 - d / maxDist, 0, 1) * 18; - const stressPressure = clamp(((tarinai.stress || 0) - 52) / 42, 0, 1) * 18; - const angerPressure = (tarinai.type === "angry" ? 12 : 0) + ((other.type === "angry" || otherBattleDrug) ? 8 : 0); - const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 22) / 90, 0, 1) * 14; - const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 26; - const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 22 : 0; - const drugBonus = battleDrug ? 56 : 0; - const zunchiBonus = mixedZunchi ? 14 : 0; - const personalityBonus = clamp(aggression, -1, 1) * 10; - const score = closePressure + stressPressure + angerPressure + fearPressure + drugBonus + zunchiBonus + personalityBonus - affinityBrake - calmBrake; - if (!best || score > best.score) { - best = { - target: other, - score, - forced: !!battleDrug || !!otherBattleDrug, - defensive: mixedZunchi || (rel.fear || 0) > 35, - reason: mixedZunchi ? "\u8fd1\u304f\u306e\u76f8\u624b\u304c\u843d\u3061\u7740\u304b\u306a\u3044" : battleDrug ? "\u304d\u305a\u3064\u304d\u9905\u3067\u6c17\u304c\u7acb\u3063\u3066\u3044\u308b" : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u8fd1\u3044", - }; - } - } - return best && best.score > (best.forced ? 4 : 12) ? best : null; -} - -function conflictTargetFor(tarinai, world, minScore = 34) { - const evaluated = evaluateConflictUrge(tarinai, world, 118); - if (evaluated && evaluated.score >= minScore) return evaluated; - const id = tarinai?.conflictTargetId || ""; - const target = id ? world?.liveTarinaiById?.(id) : null; - if (!target || !world?.canFightPair?.(tarinai, target)) return null; - const d = dist(tarinai, target); - const carried = Math.max(0, Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, d - 70) * 0.28; - return carried >= minScore ? { target, score: carried, forced: (tarinai.fightMochiTimer || 0) > 0.04, defensive: false, reason: "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b" } : null; -} - -function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) { - return findNearestItemWithRole(world, tarinai, materialRole, maxDist); -} - -function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { - let best = null, bestD = maxDist; - for (const item of world?.items || []) { - if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue; - if (type && item.type !== type) continue; - const d = dist(tarinai, item); - if (d < bestD) { best = item; bestD = d; } - } - return best; -} - -function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) { - let best = null, bestD = maxDist; - for (const item of world?.items || []) { - if (!item?.isStructure || item.dead || item.ownerId) continue; - if (type && item.type !== type) continue; - if (tarinai?.shouldAvoidTarget?.(item)) continue; - const d = dist(tarinai, item); - if (d < bestD) { best = item; bestD = d; } - } - return best; -} - -function consumeGrassMaterial(material) { - if (!material) return false; - material.amount = Math.max(0, (material.amount || 0) - 18); - material.eatenAmount = (material.eatenAmount || 0) + 18; - if (Number.isFinite(material.growth)) material.growth = clamp(material.growth - 0.18, 0.04, 1.2); - return true; -} - -function moveToOrUse(t, target, state = "seek_food", reason = "") { - if (!target) return false; - t.setActionState?.(state, { target, reason, sleeping: false }); - return true; -} +// Need planning runtime and Tarinai prototype extensions. +// Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules. if (typeof window !== "undefined") { - Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, formatCauseActionText, forcedCausePhrase, refreshIntentReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setLiveActionText, setActiveBehavior, clearActiveBehavior, activeBehaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, relationFearSafe, isLiveTarinaiEntity, actionById }); + Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, formatCauseActionText, forcedCausePhrase, refreshBehaviorReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyGroundStressModifier, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setBehaviorText, setBehavior, clearBehavior, behaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, relationFearSafe, isLiveTarinaiEntity, actionById }); } function decayNeedSatisfaction(tarinai, dt = 0) { @@ -1337,7 +153,7 @@ function updateNeeds(tarinai, world, dt = 0) { tarinai.needRaw = effective; tarinai.needDisplay = display; tarinai.needs = display; - tarinai.stress = calculateStressFromNeeds(effective); + tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(effective)); decayNeedShock(tarinai, dt); return effective; } @@ -1363,8 +179,6 @@ function shouldForceNeedRecalc(tarinai, world) { function updateNeedsCached(tarinai, world, dt = 0, options = {}) { if (!tarinai) return createDefaultNeeds(); const now = Number(world?.time || 0) || 0; - const step = Math.max(0, Number(dt) || 0); - tarinai.needCalcAccumDt = Math.min(3.0, (Number(tarinai.needCalcAccumDt || 0) || 0) + step); if (!Number.isFinite(tarinai.nextNeedCalcAt)) { const firstInterval = tarinaiNeedCalcInterval(tarinai, world); const offset = typeof stableUnit === "function" ? stableUnit(tarinai.familyKey || tarinai.id || "tarinai", "need-calc-offset") * firstInterval : Math.random() * firstInterval; @@ -1372,8 +186,11 @@ function updateNeedsCached(tarinai, world, dt = 0, options = {}) { } const force = Boolean(options.force || shouldForceNeedRecalc(tarinai, world)); if (force || now >= (tarinai.nextNeedCalcAt || 0)) { - const calcDt = Math.max(0.016, Math.min(1.50, tarinai.needCalcAccumDt || step || 0.016)); - tarinai.needCalcAccumDt = 0; + // Low-frequency AI uses a fixed needs step. This intentionally avoids + // carrying accumulated elapsed time into need arithmetic; the player only + // perceives the resulting trend, not sub-second precision. + const urgent = (tarinai?.fightTimer || 0) > 0.04 || (tarinai?.birthRitualTimer || 0) > 0.04 || (tarinai?.eatTimer || 0) > 0.04 || (tarinai?.sunbathTimer || 0) > 0.04 || forcedBehaviorQueueOf(tarinai).length > 0; + const calcDt = Number(options.fixedNeedDt) || (urgent ? 0.50 : 1.00); const result = updateNeeds(tarinai, world, calcDt); tarinai.lastNeedCalcAt = now; tarinai.nextNeedCalcAt = now + tarinaiNeedCalcInterval(tarinai, world); @@ -1381,886 +198,10 @@ function updateNeedsCached(tarinai, world, dt = 0, options = {}) { } return tarinai.needRaw || tarinai.needs || createDefaultNeeds(); } -function selectRoleTarget(role, range) { - return function selectTarget(tarinai, world) { - return findNearestItemWithRole(world, tarinai, role, range); - }; -} - -function startMoveToTarget(state, fallbackLabel = "行動している") { - return function start(tarinai, world, ctx = {}) { - const target = ctx.target ?? this.selectTarget?.(tarinai, world, ctx) ?? null; - return moveToOrUse(tarinai, target, state, this.label || fallbackLabel); - }; -} - -function selectOwnedStructure(type, range = Infinity) { - return function selectTarget(tarinai, world) { - return findOwnedStructure(world, tarinai, type, range); - }; -} - -function selectMateTarget(tarinai, world, range = 440) { - return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null; -} - -function basicCanStartWithTarget(t, world, ctx = {}) { - return !!(ctx.target ?? this.selectTarget?.(t, world, ctx)); -} - -function createConsumableActionSpec({ id, subNeed = "", state, label, phrase, weight, role, range }) { - return { - id, - need: role === "medicine" ? "health" : "food", - subNeed, - state, - label, - phrase, - weight, - timerBacked: true, - selectTarget: selectRoleTarget(role, range), - canStart: basicCanStartWithTarget, - start: startMoveToTarget(state, label), - tick(t, world, dt) { return updateConsumableBehavior(t, world, dt, role); }, - finish(t) { - if (role === "drink" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: 8, health: 2 }, "drink"); - return true; - }, - fail(t) { - if (t) t.target = null; - return false; - }, - }; -} - -function createSleepInBedActionSpec() { - return { - id: "sleep_in_bed", - need: "sleep", - state: "seek_bed", - label: "寝る場所を探している", - phrase: "寝る場所へ戻った", - weight: 42, - timerBacked: true, - selectTarget(t, world) { return findNearestSleepPlace(world, t, 760); }, - canStart: basicCanStartWithTarget, - start(t, world, ctx = {}) { - const bed = ctx.target ?? this.selectTarget(t, world, ctx); - if (!bed) return false; - if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); - bed.use?.(t, world); - t.startSleeping?.(bed, bed.type === "nest_box" ? "巣箱の中で眠っている" : "干草寝床で眠っている"); - return true; - }, - tick(t) { - if (t?.state === "sleep" || t?.sleeping) return true; - return basicCanStartWithTarget.call(this, t, t?.world || null, {}) ? true : false; - }, - }; -} - -function createSleepBuildGrassBedActionSpec() { - return { - id: "sleep_build_grass_bed", - need: "sleep", - subNeed: "bed", - state: "build", - label: "寝床がないのでかんたんベッドを作っている", - phrase: "かんたんベッドを作った", - weight: 18, - timerBacked: true, - selectTarget: selectRoleTarget("grassMaterial", 520), - canStart(t, world, ctx = {}) { - return canBuildOwnedStructureByAge(t) - && !findNearestSleepPlace(world, t, 760) - && !findOwnedStructure(world, t, "grass_bed") - && !!(ctx.target ?? this.selectTarget(t, world, ctx)) - && !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state); - }, - start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, - tick(t, world, dt) { - if (findOwnedStructure(world, t, "grass_bed")) return "finished"; - return continueBuildPlan(t, world, dt) ? true : false; - }, - }; -} - -function createSleepAnywhereActionSpec() { - return { - id: "sleep_anywhere", - need: "sleep", - state: "sleep", - label: "その場で休んでいる", - phrase: "休んだ", - weight: 8, - timerBacked: true, - canStart(t, world) { return !findNearestSleepPlace(world, t, 760); }, - start(t) { t.startSleeping?.(null, this.label); return true; }, - tick(t) { return (t?.state === "sleep" || t?.sleeping) ? true : false; }, - }; -} - -function createRestActionSpec() { - return { - id: "rest_to_recover", - need: "health", - state: "idle", - label: "回復のため休んでいる", - phrase: "休んだ", - weight: 24, - canStart() { return true; }, - start(t) { - t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); - t.energy = clamp((t.energy || 0) + 1.6, 0, 100); - return true; - }, - tick(t) { - t.energy = clamp((t.energy || 0) + 0.8, 0, 100); - return true; - }, - }; -} - -function createSunbathActionSpec() { - return { - id: "sunbath", - need: "health", - subNeed: "sunbath", - state: "sunbath", - label: "日光浴している", - phrase: "日光浴している", - weight: 32, - timerBacked: true, - canStart(t, world, ctx = {}) { - const healthNeed = Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0; - return (ctx.leisure || healthNeed >= needThreshold("health", "start")) - && !!t?.canStartSunbath?.({ leisure: !!ctx.leisure }); - }, - start(t, world, ctx = {}) { - return !!t?.startSunbath?.({ leisure: !!ctx.leisure }); - }, - tick(t) { - return (t?.sunbathTimer || 0) > 0.04 ? true : "finished"; - }, - finish(t) { - if ((t?.sunbathTimer || 0) <= 0 && t?.state === "sunbath") t.finishSunbath?.(); - return true; - }, - fail(t) { - if (t && t.state === "sunbath") { - t.sunbathTimer = 0; - t.goIdle?.("日光浴をやめた"); - } - return false; - }, - }; -} - -function createWanderActionSpec() { - function targetFor(t, world) { - const radius = rand(80, 180); - return { - x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)), - y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)), - dead: false, - detour: true, - }; - } - return { - id: "wander_lightly", - need: "fulfill", - state: "wander", - label: "少し歩いている", - phrase: "少し歩いた", - weight: 14, - canStart() { return true; }, - selectTarget: targetFor, - start(t, world, ctx = {}) { - t.wanderAngle += rand(-1.2, 1.2); - const target = ctx.target && Number.isFinite(ctx.target.x) ? ctx.target : targetFor(t, world); - t.wanderTarget = target; - t.setActionState?.("wander", { target, reason: this.label, sleeping: false }); - return true; - }, - tick(t, world) { - const target = t?.wanderTarget || currentBehaviorTarget(t) || null; - if (!target || !Number.isFinite(target.x) || dist(t, target) <= Math.max(28, (t.radius || 20) + 10)) return "finished"; - moveToOrUse(t, target, "wander", this.label); - return true; - }, - }; -} - -function selectFightRivalTarget(tarinai, world, range = 360) { - const candidate = conflictTargetFor(tarinai, world, (tarinai?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced ? 18 : 34); - const current = currentBehaviorTarget(tarinai); - return isLiveTarinaiEntity(candidate?.target) - ? candidate.target - : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(tarinai, world, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced ? 760 : range)); -} - - -function createPanicEscapeActionSpec() { - return { - id: "panic_escape", - need: "safety", - subNeed: "panic", - state: "panic", - label: "怖くて逃げている", - phrase: "逃げた", - weight: 62, - timerBacked: true, - selectTarget(t, world) { - return t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.liveTarinaiById?.(t.defeatedById) : null); - }, - canStart(t, world, ctx = {}) { - return (Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) >= 58) - || !!(ctx.target ?? this.selectTarget(t, world, ctx)) - || (t?.fearTimer || 0) > 0.18 - || (t?.defeatedTimer || 0) > 0.04; - }, - start(t, world, ctx = {}) { - const threat = ctx.target ?? this.selectTarget(t, world, ctx); - t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false }); - t.fearTimer = Math.max(t.fearTimer || 0, 1.6); - return true; - }, - tick: updatePanicBehavior, - }; -} - -function createFleeActionSpec() { - return { - id: "flee", - need: "safety", - subNeed: "avoid", - state: "flee", - label: "危ないものから離れている", - phrase: "離れた", - weight: 40, - selectTarget(t, world) { return findNearbyDanger(world, t, 220); }, - canStart: basicCanStartWithTarget, - start(t, world, ctx = {}) { - const danger = ctx.target ?? this.selectTarget(t, world, ctx); - t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); - return true; - }, - tick: updatePanicBehavior, - }; -} - -function createApproachFriendActionSpec() { - return { - id: "approach_friend", - need: "social", - subNeed: "bond", - state: "seek_friend", - label: "仲間に近づいている", - phrase: "仲間に近づいた", - weight: 46, - selectTarget(t, world) { return t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420) || null; }, - canStart: basicCanStartWithTarget, - start: startMoveToTarget("seek_friend", "仲間に近づいている"), - tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); }, - }; -} - -function createApproachFamilyActionSpec() { - return { - id: "approach_parent_or_child", - need: "social", - subNeed: "family", - state: "follow_parent", - label: "家族に近づいている", - phrase: "家族に近づいた", - weight: 28, - selectTarget(t) { return t.parentToFollow?.() || null; }, - canStart: basicCanStartWithTarget, - start: startMoveToTarget("follow_parent", "家族に近づいている"), - tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); }, - }; -} - -function createApproachMateActionSpec() { - return { - id: "approach_mate", - need: "social", - subNeed: "mate", - state: "seek_friend", - label: "繁殖できる相手に近づいている", - phrase: "繁殖できる相手に近づいている", - weight: 48, - selectTarget(t, world) { return selectMateTarget(t, world, 520); }, - canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, - start(t, world, ctx = {}) { - const other = ctx.target ?? this.selectTarget(t, world, ctx); - if (!other) return false; - if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; - return moveToOrUse(t, other, "seek_friend", this.label); - }, - tick: updateMateBehavior, - }; -} - -function createFightRivalActionSpec() { - return { - id: "fight_rival", - need: "social", - subNeed: "conflict", - state: "seek_enemy", - label: "気に入らない相手に向かっている", - phrase: "喧嘩を始めた", - weight: 18, - timerBacked: true, - selectTarget(t, world) { return selectFightRivalTarget(t, world); }, - canStart(t, world, ctx = {}) { - if ((t.fightCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; - const forced = (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior)?.forced; - const candidate = conflictTargetFor(t, world, forced ? 24 : 42); - if (!forced) { - const parts = t?.socialReasonParts || {}; - const conflict = Number(parts.conflict || 0) || 0; - const socialPull = Math.max(Number(parts.bond || 0) || 0, Number(parts.mate || 0) || 0, Number(parts.family || 0) || 0); - if (!candidate?.target || conflict < 30 || conflict < socialPull * 0.82) return false; - } - return !!(ctx.target ?? candidate?.target ?? this.selectTarget(t, world, ctx)); - }, - start(t, world, ctx = {}) { - const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 18 : 34); - const rival = ctx.target ?? this.selectTarget(t, world, ctx); - if (!isLiveTarinaiEntity(rival)) return false; - t.conflictTargetId = rival.id; - t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48); - if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label); - if (candidate?.forced || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); - world.startConflict?.(t, rival); - return true; - }, - tick: updateFightBehavior, - }; -} - -function createIntimidateEnemyActionSpec() { - return { - id: "intimidate_enemy", - need: "social", - subNeed: "conflict", - state: "intimidate", - label: "気になる相手を威嚇している", - phrase: "威嚇した", - weight: 8, - timerBacked: true, - selectTarget(t) { return t.recentFightRival?.(80, 180) || null; }, - canStart(t, world, ctx = {}) { - if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; - const parts = t?.socialReasonParts || {}; - if ((t.fightMochiTimer || 0) <= 0.04 && Number(parts.conflict || 0) < 24) return false; - return basicCanStartWithTarget.call(this, t, world, ctx); - }, - start(t, world, ctx = {}) { - const rival = ctx.target ?? this.selectTarget(t, world, ctx); - t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); - t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85); - return true; - }, - tick(t, world) { - if ((t.intimidateTimer || 0) > 0.04) return true; - const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180); - if (!rival) return false; - return world?.startIntimidation?.(t, rival) || true; - }, - }; -} - -function createBirthRitualActionSpec() { - return { - id: "birth_ritual", - need: "social", - subNeed: "mate", - label: "繁殖の前ぶれをしている", - phrase: "繁殖の前ぶれをしている", - weight: 0, - state: "birth_ritual", - timerBacked: true, - canStart(t) { return (t.birthRitualTimer || 0) > 0.04; }, - start() { return true; }, - tick(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; }, - }; -} - -const TARINAI_ACTION_DEFINITIONS = [ - createConsumableActionSpec({ - id: "eat_food", - subNeed: "meal", - state: "seek_food", - label: "食べ物を探している", - phrase: "食べ物を食べている", - weight: 62, - role: "food", - range: 760, - }), - createConsumableActionSpec({ - id: "drink_water", - subNeed: "water", - state: "seek_water", - label: "水を探している", - phrase: "水を飲んだ", - weight: 24, - role: "drink", - range: 560, - }), - createSleepInBedActionSpec(), - createSleepBuildGrassBedActionSpec(), - createSleepAnywhereActionSpec(), - createConsumableActionSpec({ - id: "use_medicine", - subNeed: "medicine", - state: "seek_food", - label: "回復できるものを探している", - phrase: "回復した", - weight: 34, - role: "medicine", - range: 620, - }), - createRestActionSpec(), - createSunbathActionSpec(), - createPanicEscapeActionSpec(), - createFleeActionSpec(), - { - id: "hide_at_owned_structure", - need: "safety", - state: "seek_bed", - label: "自分の場所に隠れている", - phrase: "自分の場所へ戻った", - weight: 30, - selectTarget: selectOwnedStructure("grass_bed", 620), - canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, - start: startMoveToTarget("seek_bed", "自分の場所に隠れている"), - }, - createApproachFriendActionSpec(), - createApproachFamilyActionSpec(), - createApproachMateActionSpec(), - createFightRivalActionSpec(), - createIntimidateEnemyActionSpec(), - { - id: "play", - need: "fulfill", - state: "play_ball", - label: "遊んでいる", - phrase: "遊んだ", - weight: 24, - selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; }, - canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, - start: startMoveToTarget("play_ball", "遊んでいる"), - }, - { - id: "build_grass_bed", - need: "fulfill", - state: "build", - label: "かんたんベッドを作っている", - phrase: "かんたんベッドを作った", - weight: 34, - selectTarget: selectRoleTarget("grassMaterial", 520), - canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, - start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, - }, - { - id: "build_plushie", - need: "fulfill", - state: "build", - label: "ぬいぐるみを作っている", - phrase: "ぬいぐるみを作った", - weight: 28, - selectTarget: selectRoleTarget("grassMaterial", 520), - canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, - start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); }, - }, - { - id: "return_owned_structure", - need: "fulfill", - state: "seek_bed", - label: "自分の場所へ戻っている", - phrase: "自分の場所へ戻った", - weight: 22, - selectTarget: selectOwnedStructure("grass_bed", 620), - canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, - start: startMoveToTarget("seek_bed", "自分の場所へ戻っている"), - }, - { - id: "use_plushie", - need: "fulfill", - state: "idle", - label: "ぬいぐるみを抱えている", - phrase: "ぬいぐるみを抱えた", - weight: 18, - selectTarget: selectOwnedStructure("plushie", Infinity), - canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, - start(t, world, ctx = {}) { - const plushie = ctx.target ?? this.selectTarget(t, world, ctx); - plushie?.use?.(t, world); - t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); - return true; - }, - }, - createBirthRitualActionSpec(), - createWanderActionSpec(), -]; - -const TARINAI_ACTIONS = typeof registerTarinaiActionSpecs === "function" - ? registerTarinaiActionSpecs(TARINAI_ACTION_DEFINITIONS) - : TARINAI_ACTION_DEFINITIONS; -function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = "#f1dfb7") { - if (!t || !item || !(eating > 0)) return false; - t.beginEating?.(item, label || "\u98df\u3079\u3066\u3044\u308b"); - t.vx *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); - t.vy *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); - t.eatTimer = Math.max(t.eatTimer || 0, 1.0); - t.eatCooldown = rand(0.96, 1.12); - t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16); - if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal"); - t.lastMealColor = color; - if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) { - const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y }; - t.world?.spawnEatEffect?.(mouth.x, mouth.y, color); - } - if ((t.world?.time || 0) > (t.nextEatSound || -999)) { - audio.eat?.(); - t.nextEatSound = (t.world?.time || 0) + 0.42; - } - return true; -} -function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { - if (!t || !world || !target || target.dead) return false; - const foodType = effectiveFoodTypeForItem(target); - const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269"; - const effectiveDt = Math.max(0.10, Number(dt) || 0.12); - if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) { - t.setActionState?.(role === "medicine" ? "seek_food" : "eat", { target, reason: `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, sleeping: false }); - return true; - } - - if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) { - const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b"; - t.setActionState?.("seek_water", { target, reason: text, sleeping: false }); - t.thought = text; - t.applyWaterEffect?.(0.55, target.type); - if (target.type === "water") target.amount -= 1.2; - if ((world.time || 0) > (t.nextDrinkSound || -999)) { - audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); - t.nextDrinkSound = (world.time || 0) + 0.72; - } - applyNeedRelief(t, role === "medicine" ? { food: -2, health: -5, safety: -1 } : { food: -4, health: -1 }); - return true; - } - - - if (target.type === "ant_corpse" && (target.amount || 0) > 0) { - const bite = Math.min(target.amount, rand(3.2, 5.4)); - if (!(bite > 0)) return false; - target.amount -= bite; - finishBehaviorConsumption(t, target, "ant_corpse", bite, effectiveDt, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b", "#4a3f36"); - t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.62) : (t.hunger -= bite * 0.62); - t.recoverHealth?.(bite * 0.12); - applyNeedShock(t, { safety: bite * 0.12 }); - t.makePoop?.(normalPoopUnitsFor("ant_corpse", bite)); - return true; - } - - if ((foodType === "sweet" || foodType === "food" || (typeof isServingFoodType === "function" && isServingFoodType(foodType)) || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && t.foodItemHasServingLeft?.(target)) { - const eating = t.consumeFoodServing?.(target); - if (!(eating > 0)) return false; - const isParam = typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType); - const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam; - const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7"; - finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color); - const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8; - t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief); - t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46))); - t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; - if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" }); - if (foodType === "sweet") { - applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 }); - if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); - if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); - } - const fromDuplicator = isDuplicatorFoodSource(target); - t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating)); - if (fromDuplicator) { - t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2); - t.intentLockTimer = Math.max(0, Math.min(t.intentLockTimer || 0, 0.18)); - } - return true; - } - - if (target.type === "grass" && (target.amount || 0) > 0) { - const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(target.amount, rand(4.2, 7.2) * servingScale); - if (!(bite > 0)) return false; - target.amount -= bite; - target.eatenAmount = (target.eatenAmount || 0) + bite; - target.growth = clamp((target.growth ?? target.amount / 120) - bite / 150, 0.04, 1.2); - finishBehaviorConsumption(t, target, "grass", bite, effectiveDt, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b", "#5b9d39"); - t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.46) : (t.hunger -= bite * 0.46); - t.recoverHealth?.(bite * 0.18); - applyNeedRelief(t, { food: -2, fulfill: -1 }); - t.makePoop?.(normalPoopUnitsFor("grass", bite)); - return true; - } - - if (target.type === "zunchi" && (target.amount || 0) > 0) { - const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; - if (zunchiEatingBlocked(t, target, world)) return false; - if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false; - if (!t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 180 && foodNeed < 98 && (t.hunger || 0) < 108) return false; - if (!t.isZunchiSlave && foodNeed < 94 && (t.hunger || 0) < 104) return false; - const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1)); - const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - if (!(bite > 0)) return false; - target.amount -= bite; - finishBehaviorConsumption(t, target, "zunchi", bite, effectiveDt, t.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b", "#6b8d3f"); - t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * (t.isZunchiSlave ? 0.66 : 0.18)) : (t.hunger -= bite * (t.isZunchiSlave ? 0.66 : 0.18)); - t.recoverHealth?.(bite * (t.isZunchiSlave ? 0.10 : 0.02)); - applyNeedShock(t, { health: bite * (t.isZunchiSlave ? 0.2 : 1.2), safety: bite * (t.isZunchiSlave ? 0.1 : 0.6) }); - t.makePoop?.(normalPoopUnitsFor("zunchi", bite)); - return true; - } - return false; -} - -function behaviorFoodTarget(t, world, role = "food", maxDist = 860) { - const best = findNearestItemWithRole(world, t, role, maxDist); - if (role === "food") return best; - const current = currentBehaviorTarget(t) || t.target; - if (current && !current.dead && (roleMatches(current, role) || (role === "food" && current.type === "duplicator" && current.storedFoodType))) return current; - return best; -} - -function updateConsumableBehavior(t, world, dt, role = "food") { - if (!t || !world) return false; - const target = behaviorFoodTarget(t, world, role, role === "food" ? 900 : 700); - if (!target || target.dead) return false; - const state = role === "drink" ? "seek_water" : "seek_food"; - const label = role === "drink" ? "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : (role === "medicine" ? "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"); - const contactDistance = target.type === "duplicator" ? foodInteractionDistance(t, target) : dist(t, target); - const contactReach = foodInteractionReach(t, target, role); - if (contactDistance > contactReach) { - const approachTarget = target.type === "duplicator" ? { x: foodInteractionPoint(target).x, y: foodInteractionPoint(target).y, id: `${target.id || "duplicator"}:dish`, dead: false, hostItem: target } : target; - t.target = target; - moveToOrUse(t, approachTarget, state, label); - return true; - } - t.target = target; - if (consumeBehaviorTarget(t, world, target, role, dt)) return "finished"; - t.setActionState?.(state, { target, reason: label, sleeping: false }); - return t.foodItemHasServingLeft?.(target) ? true : false; -} - -function mateTargetFor(t, world, maxDist = 520) { - if (!t || !world) return null; - const current = currentBehaviorTarget(t) || t.target; - const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; - if (ok(current) && dist(t, current) <= maxDist + 120) return current; - return world.nearestOther?.(t, maxDist, ok) || null; -} - -function triggerFriendContact(t, other, world, dt = 0) { - if (!t || !other || !world) return false; - const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85; - t.adjustRelation?.(other, amount, -0.05, "friend_contact"); - other.adjustRelation?.(t, amount * 0.78, -0.04, "friend_contact"); - t.loneliness = clamp((t.loneliness || 0) - 8, 0, 110); - if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -16, fulfill: -3 }); - if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u306f\u3046", 2.2, "rgba(92,132,78,0.78)"); - t.setActionState?.("idle", { target: other, reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, sleeping: false }); - if (typeof setLiveActionText === "function") setLiveActionText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "\u4ef2\u9593\u3068\u4e00\u7dd2\u306b\u3044\u308b", reasonText: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, target: other, phase: "perform", source: "behavior" }); - return "finished"; -} - -function updateSocialContactBehavior(t, world, dt, mode = "bond") { - if (!t || !world) return false; - let other = currentBehaviorTarget(t) || t.target || null; - if (!other || other.dead || other === t) { - other = mode === "family" ? t.parentToFollow?.() : (t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 560) || world.nearestOther?.(t, 520)); - } - if (!other || other.dead || other === t) return false; - const near = dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12); - if (!near) return moveToOrUse(t, other, mode === "family" ? "follow_parent" : "seek_friend", mode === "family" ? "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b" : "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); - - const conflict = conflictTargetFor(t, world, 30); - const forcedConflict = (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04; - const socialPartsForContact = t.socialReasonParts || {}; - const conflictDominant = Number(socialPartsForContact.conflict || 0) >= Math.max(Number(socialPartsForContact.bond || 0), Number(socialPartsForContact.mate || 0), Number(socialPartsForContact.family || 0)) + 8; - if (forcedConflict || (conflict?.target === other && conflict.score >= 34 && conflictDominant)) { - return updateFightBehavior(t, world, dt) || true; - } - const mate = mateTargetFor(t, world, 150); - const parts = t.socialReasonParts || {}; - const mateIntent = mode === "mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate" || (t.loveMochiTimer || 0) > 0.04 || (other.loveMochiTimer || 0) > 0.04; - const mateDominant = Number(parts.mate || 0) >= Math.max(Number(parts.bond || 0), Number(parts.conflict || 0)) + 10; - if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10 && (mateIntent || mateDominant)) { - if (world.startBirthRitual?.(t, other)) return "finished"; - } - return triggerFriendContact(t, other, world, dt); -} - -function updateMateBehavior(t, world, dt) { - const other = mateTargetFor(t, world, 560); - if (!other) return false; - const d = dist(t, other); - if (d <= Math.max(50, (t.radius || 20) + (other.radius || 20) + 14)) { - const otherWantsMate = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.id) === "approach_mate" || (other.loveMochiTimer || 0) > 0.04 || (other.reproductionTimer || 0) <= 4; - const selfForced = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.loveMochiTimer || 0) > 0.04; - if ((otherWantsMate || selfForced || (t.reproductionTimer || 0) <= 4) && world.startBirthRitual?.(t, other)) return "finished"; - moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); - if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t); - return true; - } - return moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); -} - -function updateFightBehavior(t, world, dt) { - if (!t || !world) return false; - if ((t.fightTimer || 0) > 0.04) { - const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.liveTarinaiById?.(t.fightTargetId) : null); - t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b", sleeping: false }); - return true; - } - const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 24 : 42); - const current = currentBehaviorTarget(t); - const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 760 : 360)); - if (!isLiveTarinaiEntity(rival)) return false; - t.conflictTargetId = rival.id; - if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u306b\u5411\u304b\u3063\u3066\u3044\u308b"); - if (candidate?.forced || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); - world.startConflict?.(t, rival); - return true; -} - -function updatePanicBehavior(t, world, dt, needs) { - const danger = t.lastNeedShockBreaker || findNearbyDanger(world, t, 260) || currentBehaviorTarget(t); - if (t.state !== "panic" || !t.target || (t.target.dead && !Number.isFinite(t.target.x))) { - t.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b", wake: true, sleeping: false }); - } - t.fearTimer = Math.max(t.fearTimer || 0, 0.26); - if ((Number(needs?.safety || 0) || 0) < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.08) return "finished"; - return true; -} - -if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINITIONS, TARINAI_ACTIONS, composeTarinaiBehaviorText, patchBehaviorTextFromComposer }); - -function structureBuildProbe(type, x, y) { - const def = globalThis.StructureRegistry?.get?.(type) || {}; - const r = type === "plushie" ? 6.5 : (type === "grass_bed" ? 18 : 24); - return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" }; -} - -function canBuildOwnedStructureByAge(tarinai) { - if (!tarinai || tarinai.dead) return false; - if (tarinai.generation <= 1) return true; - const growth = Number.isFinite(tarinai.growth) - ? tarinai.growth - : clamp(((tarinai.world?.time || 0) - (tarinai.birthTime || 0)) / Math.max(1, CONFIG.childGrowTime || 58), 0, 1); - return growth >= 0.55; -} - -function stopFailedBuildPlan(t, world, type = "", reason = "") { - if (!t) return false; - t.buildPlan = null; - t.actionCooldowns = t.actionCooldowns || {}; - t.actionIdCooldowns = t.actionIdCooldowns || {}; - const actionId = type === "grass_bed" ? "build_grass_bed" : (type === "plushie" ? "build_plushie" : "build"); - t.actionCooldowns.fulfill = Math.max(t.actionCooldowns.fulfill || 0, 5.0); - t.actionCooldowns.sleep = Math.max(t.actionCooldowns.sleep || 0, type === "grass_bed" ? 3.8 : 0); - t.actionIdCooldowns[actionId] = Math.max(t.actionIdCooldowns[actionId] || 0, 9.0); - if (type === "grass_bed") t.actionIdCooldowns.sleep_build_grass_bed = Math.max(t.actionIdCooldowns.sleep_build_grass_bed || 0, 9.0); - t.behaviorSwitchCooldownUntil = Math.max(t.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 1.2); - const text = reason || "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"; - if (typeof clearActiveBehavior === "function") clearActiveBehavior(t, text); - t.goIdle?.(text); - return false; -} - -function findStructureBuildSpot(t, world, type) { - const baseX = Number.isFinite(t?.x) ? t.x : 0; - const baseY = Number.isFinite(t?.y) ? t.y : 0; - const candidates = []; - for (let i = 0; i < 28; i++) { - const angle = (i / 28) * Math.PI * 2 + rand(-0.18, 0.18); - const ring = type === "grass_bed" ? rand(34, 72) : rand(18, 42); - candidates.push({ x: clamp(baseX + Math.cos(angle) * ring, CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + Math.sin(angle) * ring, CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) }); - } - candidates.unshift({ x: clamp(baseX + rand(-18, 18), CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + rand(16, 28), CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) }); - let best = null; - let bestScore = -Infinity; - for (const c of candidates) { - const probe = structureBuildProbe(type, c.x, c.y); - if (world?.placementBlocked?.(probe)) continue; - let nearestSame = Infinity; - if (type === "grass_bed") { - for (const it of world?.nearbyItems?.(c.x, c.y, 160) || world?.items || []) { - if (!it || it.dead || it.type !== "grass_bed") continue; - nearestSame = Math.min(nearestSame, distXY(c.x, c.y, it.x, it.y)); - } - } - const score = (Number.isFinite(nearestSame) ? nearestSame : 160) - distXY(c.x, c.y, baseX, baseY) * 0.12; - if (score > bestScore) { best = c; bestScore = score; } - } - return best || candidates[0] || { x: baseX, y: baseY }; -} - -function buildOwnedStructureFromGrass(t, world, type, label) { - const material = findNearbyMaterial(world, t, "grassMaterial", 520); - if (!material || !globalThis.StructureRegistry) return false; - t.buildPlan = { - type, - materialId: material.id, - timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4), - }; - if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label); - t.setActionState?.("build", { target: material, reason: label, sleeping: false }); - return true; -} - -function continueBuildPlan(t, world, dt) { - const plan = t.buildPlan; - if (!plan?.type || !globalThis.StructureRegistry) return false; - if (findOwnedStructure(world, t, plan.type)) { - t.buildPlan = null; - return false; - } - let material = (world?.itemById?.(plan.materialId) || null); - if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null; - if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); - if (!material) { - return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); - } - plan.materialId = material.id; - const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269"; - if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) { - moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`); - return true; - } - plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1)); - t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false }); - if (plan.timer > 0) return true; - consumeGrassMaterial(material); - const spot = findStructureBuildSpot(t, world, plan.type); - const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world); - if (!structure) { - return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044"); - } - world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure); - world.drawListDirty = true; - structure.use?.(t, world); - t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); - applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); - t.buildPlan = null; - return true; -} function weightedPickAction(actions) { const total = actions.reduce((sum, action) => sum + Math.max(0.01, Number(action.weight) || 1), 0); @@ -2272,28 +213,36 @@ function weightedPickAction(actions) { return actions[0] || null; } -function chooseActionForNeed(need, tarinai, world) { - const actions = TARINAI_ACTIONS.filter(action => action.need === need && (typeof canStartTarinaiAction === "function" ? canStartTarinaiAction(action, tarinai, world) : (!action.condition || action.condition(tarinai, world)))); - const weighted = actions.map(action => { - let bonus = 0; - if (need === "social") { - const p = tarinai?.socialReasonParts || {}; - const prof = tarinai?.personalityProfile?.() || {}; - const cur = tarinai?.currentPersonality || {}; - const aggression = Number(cur.aggression ?? prof.fight ?? 0) || 0; - const sociability = Number(cur.sociability ?? prof.social ?? 0) || 0; - const openness = Number(cur.openness ?? prof.play ?? 0) || 0; - if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 2.2 + Math.max(0, openness) * 22; - else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 0.78 + Math.max(0, aggression) * 10; - else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.6 + Math.max(0, sociability) * 10; - else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 1.15 + Math.max(0, sociability) * 24; - } else if (need === "fulfill") { - const p = tarinai?.fulfillReasonParts || {}; - if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0); - else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0); - else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; - else if (action.id === "return_owned_structure" || action.id === "use_plushie") bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5; +function actionSelectionBonus(action, need, tarinai, options = {}) { + if (!action) return 0; + let bonus = 0; + if (need === "social") { + const p = tarinai?.socialReasonParts || {}; + const prof = tarinai?.personalityProfile?.() || {}; + const cur = tarinai?.currentPersonality || {}; + const aggression = Number(cur.aggression ?? prof.fight ?? 0) || 0; + const sociability = Number(cur.sociability ?? prof.social ?? 0) || 0; + const openness = Number(cur.openness ?? prof.play ?? 0) || 0; + if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 2.2 + Math.max(0, openness) * 22; + else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 0.78 + Math.max(0, aggression) * 10; + else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.6 + Math.max(0, sociability) * 10; + else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 1.15 + Math.max(0, sociability) * 24; + } else if (need === "fulfill") { + const p = tarinai?.fulfillReasonParts || {}; + if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0); + else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0); + else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; + else if (options.includeComfortActions && (action.id === "return_owned_structure" || action.id === "use_plushie")) { + bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5; } + } + return bonus; +} + +function chooseActionForNeed(need, tarinai, world) { + const actions = TARINAI_ACTIONS.filter(action => action.need === need && canStartTarinaiAction(action, tarinai, world)); + const weighted = actions.map(action => { + const bonus = actionSelectionBonus(action, need, tarinai, { includeComfortActions: true }); return { ...action, weight: Math.max(0.01, (Number(action.weight) || 1) + bonus) }; }); const picked = weightedPickAction(weighted) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); @@ -2319,7 +268,7 @@ function shouldStartActionBySubNeed(tarinai, action, needs) { : subNeed === "mate" ? Math.max(0, Number(cur.openness ?? prof.play ?? 0) || 0) * 7 : 0; const threshold = Math.max(6, socialSubNeedStartThreshold(subNeed) - modifier); - const isContinuingSameAction = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai?.behavior?.id) === action.id; + const isContinuingSameAction = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai?.behavior?.actionId) === action.id; if (subNeed !== "conflict" && subValue >= threshold) return true; if (subNeed !== "conflict" && isContinuingSameAction && subValue >= threshold * 0.55) return true; if (subNeed === "mate") return total >= needThreshold("social", "start") && subValue >= threshold * 0.62 && ((tarinai?.loveMochiTimer || 0) > 0.04 || (tarinai?.reproductionTimer || 0) <= 6 || Number(parts.mate || 0) >= Number(parts.bond || 0) - 2); @@ -2354,7 +303,7 @@ function eligiblePriorityActions(tarinai, world, needs) { if ((Number(tarinai?.actionIdCooldowns?.[action.id] || 0) || 0) > 0.01) continue; const need = action.need || "fulfill"; if (!shouldStartActionBySubNeed(tarinai, action, needs)) continue; - if (typeof canStartTarinaiAction === "function" ? !canStartTarinaiAction(action, tarinai, world) : (action.condition && !action.condition(tarinai, world))) continue; + if (!canStartTarinaiAction(action, tarinai, world)) continue; result.push(action); } return result; @@ -2365,24 +314,7 @@ function choosePriorityNeedAction(needs, tarinai, world) { if (!actions.length) return null; const weighted = actions.map(action => { const need = action.need || "fulfill"; - let bonus = 0; - if (need === "social") { - const p = tarinai?.socialReasonParts || {}; - const prof = tarinai?.personalityProfile?.() || {}; - const cur = tarinai?.currentPersonality || {}; - const aggression = Number(cur.aggression ?? prof.fight ?? 0) || 0; - const sociability = Number(cur.sociability ?? prof.social ?? 0) || 0; - const openness = Number(cur.openness ?? prof.play ?? 0) || 0; - if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 2.2 + Math.max(0, openness) * 22; - else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 0.78 + Math.max(0, aggression) * 10; - else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.6 + Math.max(0, sociability) * 10; - else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 1.15 + Math.max(0, sociability) * 24; - } else if (need === "fulfill") { - const p = tarinai?.fulfillReasonParts || {}; - if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0); - else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0); - else if (action.id === "play" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; - } + const bonus = actionSelectionBonus(action, need, tarinai); let priority = actionPriority(action); const needValue = Number(needs?.[need] || 0) || 0; if (action.id === "eat_food") { @@ -2431,7 +363,7 @@ function shouldStartNeedAction(tarinai, need, needs) { const now = tarinai?.world?.time || 0; const inMealCooldown = (tarinai?.mealCooldownUntil || 0) > now; const hunger = Number(tarinai?.hunger || 0) || 0; - if (inMealCooldown && hunger < 82 && value < 78 && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced)) return false; + if (inMealCooldown && hunger < 82 && value < 78 && !(isCurrentBehaviorForced(tarinai))) return false; } if (need === "sleep" && circadianSleepPhase(tarinai?.world).night && value >= Math.max(44, needThreshold("sleep", "start") - 16)) return true; if (value >= needThreshold(need, "start")) return true; @@ -2441,29 +373,29 @@ function shouldStartNeedAction(tarinai, need, needs) { function startNeedAction(tarinai, world, choice, action, reasonText, options = {}) { const lockSeconds = Number(options.lockSeconds || actionLockSeconds(action, tarinai)) || 1.0; - setActiveBehaviorFromAction(tarinai, action, choice, reasonText, { + setBehaviorFromAction(tarinai, action, choice, reasonText, { ...options, lockSeconds, minDuration: options.minDuration || Math.max(0.45, lockSeconds * 0.72), phase: "start", }); - tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, lockSeconds); + tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, lockSeconds); const ran = typeof startTarinaiAction === "function" ? startTarinaiAction(action, tarinai, world, options) : action?.run?.(tarinai, world, options); if (!ran) { if (typeof failTarinaiAction === "function") failTarinaiAction(action, tarinai, world, { phase: "start_failed" }); - if ((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced) retryForcedBehavior(tarinai, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior), "start_failed"); + if (isCurrentBehaviorForced(tarinai)) retryForcedBehavior(tarinai, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior), "start_failed"); tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {}; tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] = Math.max(tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] || 0, 1.4); tarinai.actionIdCooldowns[action?.id || "unknown"] = Math.max(tarinai.actionIdCooldowns[action?.id || "unknown"] || 0, 2.2); tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); - clearActiveBehavior(tarinai, (typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(tarinai) : "") || action?.label || "\u5f85\u3063\u3066\u3044\u308b"); + clearBehavior(tarinai, (typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(tarinai) : "") || action?.label || "\u5f85\u3063\u3066\u3044\u308b"); tarinai.goIdle?.(action?.label || "\u5f85\u3063\u3066\u3044\u308b"); } else { const target = tarinai.target || options.target; - setActiveBehaviorFromAction(tarinai, action, choice, reasonText, { + setBehaviorFromAction(tarinai, action, choice, reasonText, { ...options, target, state: tarinai.state || action?.state || "idle", @@ -2482,16 +414,16 @@ function shouldInterruptCurrentAction(tarinai, needs) { return false; } -function continueCurrentIntent(tarinai, world, needs, dt) { +function continueCurrentBehavior(tarinai, world, needs, dt) { const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; - const actionId = behavior?.id; + const actionId = behavior?.actionId; if (!actionId) return false; - if (shouldInterruptCurrentAction(tarinai, needs) && !(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced) return false; + if (shouldInterruptCurrentAction(tarinai, needs) && !isCurrentBehaviorForced(tarinai)) return false; const action = actionById(actionId); if (!action) return false; if (tarinai.target && tarinai.target.dead) return false; const timerBacked = Boolean(action.timerBacked || ["eat_food", "drink_water", "use_medicine", "fight_rival", "intimidate_enemy", "approach_mate", "birth_ritual", "panic_escape", "sunbath"].includes(action.id)); - if (!timerBacked && (typeof canStartTarinaiAction === "function" ? !canStartTarinaiAction(action, tarinai, world) : (action.condition && !action.condition(tarinai, world)))) return false; + if (!timerBacked && !canStartTarinaiAction(action, tarinai, world)) return false; const need = action.need || behavior?.need; const value = Number(needs?.[need]) || 0; const now = world?.time || 0; @@ -2504,49 +436,49 @@ function continueCurrentIntent(tarinai, world, needs, dt) { || (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04) || (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04) || ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15); - const foodSuppressed = need === "food" && !(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15); - if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced)) { - tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed))); - const refreshedReason = refreshIntentReasonText(tarinai, action); + const foodSuppressed = need === "food" && !isCurrentBehaviorForced(tarinai) && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15); + if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) { + tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed))); + const refreshedReason = refreshBehaviorReasonText(tarinai, action); const ranUpdate = typeof tickTarinaiAction === "function" ? tickTarinaiAction(action, tarinai, world, dt, needs, { phase: "continue" }) : action.update?.(tarinai, world, dt, needs); if (ranUpdate === "finished") { if (typeof finishTarinaiAction === "function") finishTarinaiAction(action, tarinai, world, { phase: "finished" }); const finishedBehavior = behavior; - if (finishedBehavior?.forced) completeForcedBehavior(tarinai, finishedBehavior, "completed"); + if (behaviorIsForced(finishedBehavior)) completeForcedBehavior(tarinai, finishedBehavior, "completed"); tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); - clearActiveBehavior(tarinai, refreshedReason || action.label || ""); + clearBehavior(tarinai, refreshedReason || action.label || ""); return false; } if (ranUpdate === false) { if (typeof failTarinaiAction === "function") failTarinaiAction(action, tarinai, world, { phase: "update_failed" }); - if (behavior?.forced) retryForcedBehavior(tarinai, behavior, "update_failed"); + if (behaviorIsForced(behavior)) retryForcedBehavior(tarinai, behavior, "update_failed"); tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {}; tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 2.4); tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); - clearActiveBehavior(tarinai); + clearBehavior(tarinai); return false; } - setActiveBehaviorFromAction(tarinai, action, { need, tiedNeeds: behavior?.tiedNeeds || [need] }, refreshedReason || behavior?.reasonText || action.label, { + setBehaviorFromAction(tarinai, action, { need, tiedNeeds: behavior?.tiedNeeds || [need] }, refreshedReason || behavior?.reason || action.label, { target: tarinai.target, state: tarinai.state || action.state || "idle", source: behavior?.source || "need", - forced: Boolean(behavior?.forced), + forced: behaviorIsForced(behavior), priority: behavior?.priority || 0, causeText: behavior?.causeText || "", sourceReasonText: behavior?.sourceReasonText || "", - forcedReasonText: behavior?.forcedReasonText || "", + forcedReasonText: behavior?.sourceReasonText || "", forcedRequest: behavior?.forcedRequest || null, startedAt, lockSeconds: behavior?.lockSeconds, minDuration, phase: mustContinueByTimer ? "perform" : "active", }); - tarinai.thought = activeBehaviorText(tarinai) || refreshedReason || behavior?.label || tarinai.thought; + tarinai.thought = behaviorText(tarinai) || refreshedReason || behavior?.label || tarinai.thought; const needsRefresh = !tarinai.target || tarinai.target.dead || ["rest_to_recover", "use_plushie", "intimidate_enemy"].includes(action.id); if (needsRefresh && elapsed >= Math.min(0.7, minDuration)) { if (typeof startTarinaiAction === "function") startTarinaiAction(action, tarinai, world, { phase: "refresh" }); @@ -2557,7 +489,7 @@ function continueCurrentIntent(tarinai, world, needs, dt) { tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 2.8); tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.35); - clearActiveBehavior(tarinai); + clearBehavior(tarinai); return false; } @@ -2591,12 +523,13 @@ function protectSleepSession(tarinai, world, needs) { tarinai.sleeping = true; if (!tarinai.sleepSession) { const now = world?.time || 0; - tarinai.sleepSession = { startedAt: now, minDuration: rand(12, 22), targetEnergy: rand(80, 92), maxDuration: rand(34, 54), targetId: tarinai.target?.id || null }; + tarinai.sleepSession = { startedAt: now, minDuration: rand(12, 22), targetEnergy: rand(80, 92), maxDuration: rand(34, 54), targetId: tarinai.target?.id || null, consumesGrassBedOnWake: tarinai.target?.type === "grass_bed" }; + if (tarinai.target?.type === "grass_bed") tarinai.pendingGrassBedWakeId = tarinai.target.id || tarinai.pendingGrassBedWakeId || ""; } if (!shouldWakeFromSleep(tarinai, world, needs)) { tarinai.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b"; - setActiveBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reasonText: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", presentText: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" }); - tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, 0.8); + setBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reason: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", text: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" }); + tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, 0.8); const now = world?.time || 0; if (now >= (tarinai.nextSleepBubbleAt || 0)) { tarinai.nextSleepBubbleAt = now + rand(3.6, 5.6); @@ -2605,9 +538,10 @@ function protectSleepSession(tarinai, world, needs) { return true; } const sleptLongEnough = (world?.time || 0) - (Number(tarinai.sleepSession?.startedAt) || 0) >= 2; + tarinai.consumePendingGrassBedOnWake?.("\u76ee\u304c\u899a\u3081\u305f"); tarinai.sleeping = false; tarinai.sleepSession = null; - clearActiveBehavior(tarinai); + clearBehavior(tarinai); if (sleptLongEnough) applyNeedSatisfaction(tarinai, { sleep: 28, safety: 6 }, "sleep"); tarinai.goIdle?.("\u76ee\u304c\u899a\u3081\u305f"); return false; @@ -2617,18 +551,9 @@ function protectSleepSession(tarinai, world, needs) { 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; - }, - forceBehavior(actionId, options = {}) { return queueForcedTarinaiBehavior(this, actionId, options); }, - - clearForcedBehavior(filter = null) { - return clearForcedBehaviorQueue(this, filter); - }, - hasForcedBehavior(actionId = "") { const key = String(actionId || ""); const active = typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(this) : null; @@ -2905,7 +830,7 @@ function protectSleepSession(tarinai, world, needs) { this.sunbathRecoveryChecked = false; this.vx = 0; this.vy = 0; - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reasonText: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reason: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior" }); if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("\u307d\u304b\u3063", 4.0, "rgba(112,86,36,0.78)"); return true; }, @@ -3095,7 +1020,7 @@ function protectSleepSession(tarinai, world, needs) { this.hunger = clamp(this.hunger, 0, 115); this.loneliness = clamp(this.loneliness, 0, 110); this.energy = clamp(this.energy, 0, 100); - this.stress = calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()); + this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); @@ -3105,21 +1030,27 @@ function protectSleepSession(tarinai, world, needs) { return; } - this.intentLockTimer = Math.max(0, (this.intentLockTimer || 0) - dt); + this.behaviorLockTimer = Math.max(0, (this.behaviorLockTimer || 0) - dt); 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 urgentFoodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; + 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 || this.hunger >= 96 || urgentFoodNeed >= 84; const aiJitter = stableUnit(this.familyKey || this.id, "ai-interval") * 0.35; const aiInterval = urgentAi ? 0.35 : (1.0 + aiJitter); if (this.aiTimer >= aiInterval && this.world.spendScheduledWork?.("ai", urgentAi) !== false) { - const aiDt = Math.min(this.aiTimer, 0.48); + const elapsedAiDt = Math.max(0.016, Math.min(this.aiTimer, urgentAi ? 0.75 : 1.50)); + const scanDt = Math.min(elapsedAiDt, 0.75); this.aiTimer = 0; - this.resolveNeeds(aiDt); + // Needs intentionally advance by a fixed coarse step per AI update. + // Exact elapsed-time integration is unnecessary for this observation game + // and can over-amplify safety/fear transitions when updates are sparse. + const needStepDt = urgentAi ? 0.50 : 1.00; + this.resolveNeeds(needStepDt); const sleepingNow = this.state === "sleep"; if (!sleepingNow) { - this.reactToFreshZunchi(aiDt); + this.reactToFreshZunchi(scanDt); this.updateFacing(); - this.interactWithItems(aiDt); - this.interactWithOthers(aiDt); + this.interactWithItems(scanDt); + this.interactWithOthers(scanDt); } else { this.updateFacing(); } @@ -3197,12 +1128,6 @@ function protectSleepSession(tarinai, world, needs) { isSleepFurniture(target) { return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box")))); }, - - sleepFurnitureLabel(target) { - if (target?.label) return target.label; - return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; - }, - isServingFoodItem(it) { if (it?.type === "duplicator" && it.storedFoodType) return true; return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); @@ -3233,14 +1158,14 @@ function protectSleepSession(tarinai, world, needs) { consumeFoodServing(it, nutrition) { if (it?.type === "duplicator") { if (!it.storedFoodType) return 0; - const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.storedFoodType, 0) ?? 42); + const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry?.food?.nutrition?.(it.storedFoodType, 0) ?? 42); it.usedCount = (it.usedCount || 0) + 1; this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value }); return value; } this.ensureFoodServings(it); if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; - const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0); + const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry?.food?.nutrition?.(it.type, 0) ?? 0); it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); it.amount = it.foodServingsRemaining; this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value }); @@ -3248,7 +1173,7 @@ function protectSleepSession(tarinai, world, needs) { }, foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) { - const relief = window.TarinaiFoodRegistry?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier; + const relief = window.TarinaiItemRegistry?.food?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier; return (Number(nutrition) || 0) * relief; }, @@ -3319,9 +1244,9 @@ function protectSleepSession(tarinai, world, needs) { const lockedTargetInvalid = this.target && this.target.dead; const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior; - if ((this.intentLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - if (continueCurrentIntent(this, this.world, needs, dt)) return; - this.thought = lockedBehavior.reasonText || lockedBehavior.label || this.thought; + if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + if (continueCurrentBehavior(this, this.world, needs, dt)) return; + this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought; return; } @@ -3335,7 +1260,7 @@ function protectSleepSession(tarinai, world, needs) { if (this.intimidateTimer > 0.04) { const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null; this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" }); this.bubble("!", 1.2); return; } @@ -3352,7 +1277,7 @@ function protectSleepSession(tarinai, world, needs) { } const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002"; this.setActionState?.("fight", { target: best, reason: reasonText }); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" }); this.bubble("!", 1.6); return; } @@ -3400,10 +1325,10 @@ function protectSleepSession(tarinai, world, needs) { return; } - if (continueCurrentIntent(this, this.world, needs, dt)) return; + if (continueCurrentBehavior(this, this.world, needs, dt)) return; if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) { - this.intentLockTimer = Math.max(this.intentLockTimer || 0, 0.25); + this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25); if (!(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true }); return; } @@ -3432,9 +1357,9 @@ function protectSleepSession(tarinai, world, needs) { return; } const choice = chooseTopNeedRandom(needs, this, this.world); - setActiveBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reasonText: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); - clearActiveBehavior(this); - this.intentLockTimer = Math.max(this.intentLockTimer || 0, 3.0); + setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); + clearBehavior(this); + this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0); this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b"); return; } @@ -3481,13 +1406,16 @@ function protectSleepSession(tarinai, world, needs) { const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start"); const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18; const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7; - const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger > 108 || foodNeed > 98)) && (this.isZunchiSlave || !hasBetterFoodThanZunchiNearby(this, this.world, 240)); - const scanLimit = 16; - const nearbyItems = this.world.nearbyItems(this.x, this.y, this.radius + 46); + const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger >= 102 || foodNeed >= 92)) && (this.isZunchiSlave || !hasBetterFoodThanZunchiNearby(this, this.world, 260)); + const scanLimit = (this.hunger >= 96 || foodNeed >= 84) ? 32 : 18; + const foodScanRadius = this.radius + ((this.hunger >= 96 || foodNeed >= 84) ? 104 : 56); + const nearbyFood = this.world.nearbyFood?.(this.x, this.y, foodScanRadius, true) || []; + const nearbyItems = nearbyFood.length ? nearbyFood : this.world.nearbyItems(this.x, this.y, foodScanRadius); const foodCandidates = []; if (this.target && !this.target.dead && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.push(this.target); for (const it of nearbyItems) { if (!it || it.dead || foodCandidates.includes(it)) continue; + if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue; foodCandidates.push(it); if (foodCandidates.length >= scanLimit) break; } @@ -3640,7 +1568,8 @@ function protectSleepSession(tarinai, world, needs) { 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); - const zunchiTooFresh = zunchiEatingBlocked(this, it, this.world) || (!this.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 180 && foodNeed < 98 && (this.hunger || 0) < 108); + const emergencyHunger = (this.hunger || 0) >= 104 || foodNeed >= 94; + const zunchiTooFresh = zunchiEatingBlocked(this, it, this.world) || (!emergencyHunger && !this.isZunchiSlave && it.stage === "fresh" && (it.age || 0) < 150 && foodNeed < 94 && (this.hunger || 0) < 104); if (canZunchiBite && !zunchiTooFresh) { 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); @@ -3685,10 +1614,11 @@ function protectSleepSession(tarinai, world, needs) { if (this.isSleepFurniture(it)) { if (this.energy < 74 + 6 * this.personalityProfile().sleep) { - it.use?.(this, this.world); + const isEasyBed = it.type === "grass_bed"; const occ = this.world.bedOccupancy(it); const comfort = this.world.bedComfort(it); - this.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u5e8a\u306e\u8fd1\u304f\u3067\u4f11\u3093\u3067\u3044\u308b"); + it.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined); + this.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; const crowded = occ > 5; @@ -3725,7 +1655,7 @@ function protectSleepSession(tarinai, world, needs) { } this.hunger = clamp(this.hunger, 0, 115); this.energy = clamp(this.energy, 0, 100); - this.stress = calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()); + this.stress = applyGroundStressModifier(this, calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds())); this.loneliness = clamp(this.loneliness, 0, 110); } })); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 64179fe..f82189d 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -3,6 +3,21 @@ (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_render.js"); + function shouldShowTarinaiDecor(tarinai) { + const worldRef = tarinai?.world; + if (!tarinai || !worldRef) return true; + if (worldRef.selected === tarinai || tarinai.favorite) return true; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.02 || (tarinai.pokeFlashTimer || 0) > 0.03) return true; + if ((tarinai.focusPulseTimer || 0) > 0.04 || tarinai.state === "cursor_friend" || tarinai.state === "cursor_enemy") return true; + const tier = window.TarinaiPerf?.renderQualityTier?.() || "high"; + if (tier === "low") return false; + if (tier === "mid") { + const p = worldRef.pointer; + return Boolean(p?.inside && typeof distXY === "function" && distXY(tarinai.x, tarinai.y, p.x, p.y) < tarinai.radius * 2.0); + } + return true; + } + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ drawSunbathPose(ctx, t, lighting = null) { if (this.dead) return true; @@ -85,7 +100,7 @@ if (nestHideAlpha <= 0.01) return true; const lightState = lighting || getLightingState(this.world); - const showDecor = true || this.world.selected === this; + const showDecor = shouldShowTarinaiDecor(this); if (showDecor) { const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8); drawProjectedShadow(ctx, drawX, drawY + drawH * 0.30, drawW * 0.58, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.90 * nestHideAlpha }); @@ -134,7 +149,7 @@ this.visibleSpriteId = ""; this.spriteLockUntil = 0; } - const showDecor = true || this.world.selected === this; + const showDecor = shouldShowTarinaiDecor(this); if (sunbathActive) this.preloadSunbathSprites?.(); let sprite = this.spriteId(); if (!sunbathActive && String(sprite || "").startsWith("sunbath")) { diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js new file mode 100644 index 0000000..580bec1 --- /dev/null +++ b/js/tarinai_social_action_runtime.js @@ -0,0 +1,121 @@ +"use strict"; + +// Social, panic, fight, mate behavior runtime ticks. + +function mateTargetFor(t, world, maxDist = 520) { + if (!t || !world) return null; + const current = currentBehaviorTarget(t) || t.target; + const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; + if (ok(current) && dist(t, current) <= maxDist + 120) return current; + return world.nearestOther?.(t, maxDist, ok) || null; +} + +function triggerFriendContact(t, other, world, dt = 0) { + if (!t || !other || !world) return false; + const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85; + t.adjustRelation?.(other, amount, -0.05, "friend_contact"); + other.adjustRelation?.(t, amount * 0.78, -0.04, "friend_contact"); + t.loneliness = clamp((t.loneliness || 0) - 8, 0, 110); + if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -16, fulfill: -3 }); + if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u306f\u3046", 2.2, "rgba(92,132,78,0.78)"); + t.setActionState?.("idle", { target: other, reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, sleeping: false }); + if (typeof setBehaviorText === "function") setBehaviorText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "\u4ef2\u9593\u3068\u4e00\u7dd2\u306b\u3044\u308b", reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, target: other, phase: "perform", source: "behavior" }); + return "finished"; +} + +function updateSocialContactBehavior(t, world, dt, mode = "bond") { + if (!t || !world) return false; + let other = currentBehaviorTarget(t) || t.target || null; + if (!other || other.dead || other === t) { + other = mode === "family" ? t.parentToFollow?.() : (t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 560) || world.nearestOther?.(t, 520)); + } + if (!other || other.dead || other === t) return false; + const near = dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12); + if (!near) return moveToOrUse(t, other, mode === "family" ? "follow_parent" : "seek_friend", mode === "family" ? "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b" : "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); + + const conflict = conflictTargetFor(t, world, 30); + const forcedConflict = (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04; + const socialPartsForContact = t.socialReasonParts || {}; + const conflictDominant = Number(socialPartsForContact.conflict || 0) >= Math.max(Number(socialPartsForContact.bond || 0), Number(socialPartsForContact.mate || 0), Number(socialPartsForContact.family || 0)) + 8; + if (forcedConflict || (conflict?.target === other && conflict.score >= 34 && conflictDominant)) { + return updateFightBehavior(t, world, dt) || true; + } + const mate = mateTargetFor(t, world, 150); + const parts = t.socialReasonParts || {}; + const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.actionId; + const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId; + const mateIntent = mode === "mate" || selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || (t.loveMochiTimer || 0) > 0.04 || (other.loveMochiTimer || 0) > 0.04; + const mateDominant = Number(parts.mate || 0) >= Math.max(Number(parts.bond || 0), Number(parts.conflict || 0)) + 10; + if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10 && (mateIntent || mateDominant)) { + if (world.startBirthRitual?.(t, other)) return "finished"; + } + return triggerFriendContact(t, other, world, dt); +} + +function updateMateBehavior(t, world, dt) { + const other = mateTargetFor(t, world, 560); + if (!other) return false; + const d = dist(t, other); + if (d <= Math.max(50, (t.radius || 20) + (other.radius || 20) + 14)) { + const otherWantsMate = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId) === "approach_mate" || (other.loveMochiTimer || 0) > 0.04 || (other.reproductionTimer || 0) <= 4; + const selfForced = isCurrentBehaviorForced(t) || (t.loveMochiTimer || 0) > 0.04; + if ((otherWantsMate || selfForced || (t.reproductionTimer || 0) <= 4) && world.startBirthRitual?.(t, other)) return "finished"; + moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); + if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t); + return true; + } + return moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); +} + +function updateFightBehavior(t, world, dt) { + if (!t || !world) return false; + if ((t.fightTimer || 0) > 0.04) { + const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.liveTarinaiById?.(t.fightTargetId) : null); + t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b", sleeping: false }); + return true; + } + const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 24 : 42); + const current = currentBehaviorTarget(t); + const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, isCurrentBehaviorForced(t) ? 760 : 360)); + if (!isLiveTarinaiEntity(rival)) return false; + t.conflictTargetId = rival.id; + if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u306b\u5411\u304b\u3063\u3066\u3044\u308b"); + if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); + world.startConflict?.(t, rival); + return true; +} + +function updatePanicBehavior(t, world, dt, needs) { + const breakerDanger = activePanicBreaker(t, world, 260); + const nearbyDanger = findNearbyDanger(world, t, 260) || null; + const realDanger = breakerDanger || nearbyDanger || null; + const currentTarget = currentBehaviorTarget(t); + const safety = Number(needs?.safety || 0) || 0; + const now = Number(world?.time || 0) || 0; + if (!Number.isFinite(t.panicStartedAt) || t.state !== "panic") t.panicStartedAt = now; + + // Survival needs must be able to break panic. Otherwise a stale fear state + // can suppress eating/sleeping long enough to kill the colony. + if (!realDanger && survivalNeedShouldCancelPanic(t, needs)) { + t.fearTimer = 0; + t.lastNeedShockBreaker = null; + return "finished"; + } + + const panicAge = Math.max(0, now - (Number(t.panicStartedAt) || now)); + const hardStop = Number(t.panicHardStopAt || 0) || 0; + if (!realDanger && (panicAge >= 7.5 || (hardStop > 0 && now >= hardStop))) { + t.fearTimer = 0; + t.lastNeedShockBreaker = null; + return "finished"; + } + + if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.12) return "finished"; + const danger = realDanger || currentTarget; + if (t.state !== "panic" || !t.target || (t.target.dead && !Number.isFinite(t.target.x))) { + t.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b", wake: true, sleeping: false }); + } + if (realDanger || (t.defeatedTimer || 0) > 0.04) t.fearTimer = Math.max(t.fearTimer || 0, 0.26); + if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.18) return "finished"; + return true; +} diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 76d461a..652541c 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -163,7 +163,9 @@ queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" }); } - const mateIntent = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.id) === "approach_mate"; + const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId; + const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId; + const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug; if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) { this.world.startBirthRitual(this, o); } @@ -294,7 +296,7 @@ if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) { if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander"); if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; - this.intentLockTimer = 0; + this.behaviorLockTimer = 0; this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f"); return; } @@ -306,9 +308,11 @@ ? (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.target.use?.(this, this.world); - this.startSleeping?.(this.target, this.target.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u308b\u5834\u6240\u306b\u7740\u3044\u305f\u306e\u3067\u4f11\u3093\u3067\u3044\u308b"); - if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); + const sleepTarget = this.target; + const isEasyBed = sleepTarget?.type === "grass_bed"; + sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined); + this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); + if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt); return; } } diff --git a/js/text_catalog.js b/js/text_catalog.js index 27bba71..79a8679 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -58,6 +58,10 @@ TEXT_CATALOG.stateLabels = Object.freeze({ seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046", play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", seek_water: "\u6c34\u3092\u63a2\u3059", + seek_material: "\u6750\u6599\u3092\u63a2\u3059", + seek_enemy: "\u76f8\u624b\u3078\u5411\u304b\u3046", + wander: "\u79fb\u52d5\u4e2d", + build: "\u4f5c\u6210\u4e2d", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", system: "\u305a\u3093\u3061\u75c5", @@ -77,12 +81,40 @@ TEXT_CATALOG.stateLabels = Object.freeze({ frozen: "\u51b7\u51cd\u4e2d", }); + +TEXT_CATALOG.behaviorLabels = Object.freeze({ + seek_material: "材料を探している", + build_structure: "作成している", + build: "作成している", + seek_enemy: "気になる相手へ向かっている", + wander_lightly: "少し移動している", + eat_food: "食事をしている", + drink_water: "水を飲んでいる", + sleep_in_bed: "寝床で休んでいる", + sleep_anywhere: "休んでいる", + intimidate_enemy: "威嚇している", + panic_escape: "逃げている", +}); + +TEXT_CATALOG.behaviorLabel = function behaviorLabelFromCatalog(value = "") { + const key = String(value || ""); + return TEXT_CATALOG.behaviorLabels?.[key] || TEXT_CATALOG.stateLabels?.[key] || key; +}; + +TEXT_CATALOG.cleanBehaviorText = function cleanBehaviorText(text = "", fallbackKey = "") { + const raw = String(text || "").trim(); + const key = String(fallbackKey || raw || "").trim(); + const mapped = TEXT_CATALOG.behaviorLabel(key); + if (!raw) return mapped || ""; + if (/^[a-z][a-z0-9_:-]*$/i.test(raw)) return TEXT_CATALOG.behaviorLabel(raw); + return raw; +}; + TEXT_CATALOG.targetLabelOverrides = Object.freeze({ system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", }); -// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel(). TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides; TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) { @@ -107,8 +139,12 @@ TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) { TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { if (!t) return "\u306a\u3057"; if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; - const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim(); - if (behaviorText) return behaviorText; + const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior; + const behaviorLabel = typeof globalThis.behaviorText === "function" + ? globalThis.behaviorText(t) + : String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (behaviorState?.text || behaviorState?.reason || "")).trim(); + const cleanedBehaviorLabel = TEXT_CATALOG.cleanBehaviorText(behaviorLabel, behaviorState?.actionId || t.state || ""); + if (cleanedBehaviorLabel) return cleanedBehaviorLabel; const target = TEXT_CATALOG.targetLabel(t.target); if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b"; if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b"; diff --git a/js/ui.js b/js/ui.js index 8cc8fbc..705fcbb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -15,6 +15,8 @@ const ui = { resetBtn: document.getElementById("resetBtn"), creditsDialog: document.getElementById("creditsDialog"), creditsCloseBtn: document.getElementById("creditsCloseBtn"), + panel: document.querySelector(".panel"), + panelQuickTabs: document.getElementById("panelQuickTabs"), toolPalette: document.getElementById("toolPalette"), toolCard: document.getElementById("toolCard"), selectedCard: document.getElementById("selectedCard"), @@ -31,6 +33,8 @@ const ui = { statPop: document.getElementById("statPop"), statDead: document.getElementById("statDead"), statColonyMood: document.getElementById("statColonyMood"), + statGroundType: document.getElementById("statGroundType"), + groundTypeBtn: document.getElementById("groundTypeBtn"), colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), @@ -50,6 +54,7 @@ const uiCache = { stats: {}, selectedSnapshot: "", selectedEmpty: false, + showSelectedEmpty: false, selectedCollapsedCategories: new Set(), logPushEnabled: false, logPushKinds: new Set(["death", "accident", "birth"]), diff --git a/js/ui_bind.js b/js/ui_bind.js index 7be46cb..83a0bb1 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -6,6 +6,7 @@ function bindUI() { syncLogPushControls(); syncAudioControls(); applyToolTips(); + window.bindGroundUI?.(); renderEcologyCards(); window.TarinaiSaveSystem?.bindSaveSystem?.(); window.TarinaiFreezeSystem?.bindFreezeSystem?.(); @@ -15,9 +16,71 @@ function bindUI() { return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(world.tool)); } function clickedInsideGameOrToolUi(target) { - return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target)); + return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target)); } + + function panelSectionForTab(tab = "") { + if (tab === "selected") return ui.selectedCard; + if (tab === "tools") return ui.toolCard; + if (tab === "colony") return document.querySelector(".colony-card"); + if (tab === "events") return document.querySelector(".log-card"); + return null; + } + + function setActivePanelTab(tab = "") { + for (const btn of ui.panelQuickTabs?.querySelectorAll("[data-panel-tab]") || []) { + btn.classList.toggle("active", btn.dataset.panelTab === tab); + } + } + + function scrollPanelToTab(tab = "") { + const panel = ui.panel; + if (!panel) return false; + if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) { + selectTool("observe"); + uiCache.showSelectedEmpty = true; + uiCache.selectedEmpty = false; + renderSelected(); + showToast("たりないが未選択です。観察モードにしました。"); + } + const target = panelSectionForTab(tab); + if (!target) return false; + target.classList?.remove?.("hidden"); + const tabsH = ui.panelQuickTabs?.offsetHeight || 0; + const top = Math.max(0, target.offsetTop - tabsH - 8); + panel.scrollTo({ top, behavior: "smooth" }); + setActivePanelTab(tab); + return true; + } + + ui.panelQuickTabs?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-panel-tab]"); + if (!btn) return; + e.preventDefault(); + audio.uiClick?.(); + scrollPanelToTab(btn.dataset.panelTab || ""); + }); + + ui.panel?.addEventListener("scroll", () => { + const panel = ui.panel; + if (!panel || !ui.panelQuickTabs) return; + const tabsH = ui.panelQuickTabs.offsetHeight || 0; + const entries = [ + ["selected", ui.selectedCard], + ["tools", ui.toolCard], + ["colony", document.querySelector(".colony-card")], + ["events", document.querySelector(".log-card")], + ].filter(([, el]) => el && !el.classList.contains("hidden")); + let active = entries[0]?.[0] || ""; + const y = panel.scrollTop + tabsH + 18; + for (const [key, el] of entries) { + if (el.offsetTop <= y) active = key; + } + setActivePanelTab(active); + }, { passive: true }); + syncToolSizeBadges(); + setActivePanelTab(ui.selectedCard?.classList.contains("hidden") ? "tools" : "selected"); for (const toggle of document.querySelectorAll(".panel-card-toggle")) { toggle.addEventListener("click", () => { const card = toggle.closest(".collapsible-card"); @@ -38,7 +101,6 @@ function bindUI() { uiCache.lastChartDrawAt = 0; renderStats(); }); - syncToolSizeBadges(); ui.logPushEnabled?.addEventListener("change", () => { uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked); audio.notify?.(); @@ -147,6 +209,7 @@ function bindUI() { }); ui.selectedCloseBtn?.addEventListener("click", () => { world.selected = null; + uiCache.showSelectedEmpty = false; uiCache.selectedSnapshot = ""; uiCache.selectedEmpty = false; renderSelected(); @@ -279,556 +342,10 @@ function bindUI() { chainScroll(ui.archiveContent); transferEdgeScroll(ui.log, rightPanel); - const inputModeManager = window.TarinaiInputMode; - const setMobileInputMode = (mode = "auto") => { - mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto"); - uiCache.mobileInputMode = mode; - ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode)); - document.body.classList.toggle("mobile-mode-camera", mode === "camera"); - document.body.classList.toggle("mobile-mode-tool", mode === "tool"); - document.body.classList.toggle("mobile-mode-family", mode === "family"); - }; - const updateTouchMobileUiClass = () => { - const enabled = Boolean(inputModeManager?.refresh?.().touchFirst); - document.body.classList.toggle("touch-mobile-ui", enabled); - if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled; - }; - const mobileInputMode = () => inputModeManager?.currentMode || uiCache.mobileInputMode || "auto"; - updateTouchMobileUiClass(); - window.addEventListener("resize", updateTouchMobileUiClass, { passive: true }); - window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass); - setMobileInputMode(uiCache.mobileInputMode || "auto"); - ui.mobileModeControls?.addEventListener("click", (e) => { - const btn = e.target.closest("[data-mobile-mode]"); - if (!btn) return; - audio.uiClick?.(); - setMobileInputMode(btn.dataset.mobileMode || "auto"); - showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`); - }); - - const touchPoint = (touches, index = 0) => { - const t = touches?.[index]; - if (!t) return null; - return { clientX: t.clientX, clientY: t.clientY }; - }; - const touchCenter = (touches) => { - const a = touchPoint(touches, 0); - const b = touchPoint(touches, 1); - if (!a) return null; - if (!b) return a; - return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 }; - }; - const touchDistance = (touches) => { - const a = touchPoint(touches, 0); - const b = touchPoint(touches, 1); - if (!a || !b) return 0; - return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); - }; - const screenToWorldClient = (clientX, clientY) => { - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 }; - }; - const clientToWorldRaw = (clientX, clientY) => { - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - const scale = world.viewScale ? world.viewScale() : 1; - const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; - const rawX = (sx - off.x) / scale + (world.cameraX || 0); - const rawY = (sy - off.y) / scale + (world.cameraY || 0); - return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h }; - }; - const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => { - if (!world.setFieldZoom) return false; - const rect = canvas.getBoundingClientRect(); - const sx = clientX - rect.left; - const sy = clientY - rect.top; - const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; - if (!world.setFieldZoom(nextZoom)) return false; - const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; - const scale = world.viewScale ? world.viewScale() : 1; - world.cameraX = focus.x - (sx - off.x) / scale; - world.cameraY = focus.y - (sy - off.y) / scale; - world.clampCamera?.(); - return true; - }; - - function isClientOverFrozenPanel(clientX, clientY) { - const panel = document.getElementById("frozenPanel"); - if (!panel) return false; - const r = panel.getBoundingClientRect(); - return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; - } - - function rememberGrabOrigin(target) { - if (!target || uiCache.grabKind !== "tarinai") return; - uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 }; - } - - function finishTarinaiGrab(target, clientX, clientY) { - if (!target || uiCache.grabKind !== "tarinai") return false; - if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) { - window.TarinaiFreezeSystem.freezeTarinai(target, world); - uiCache.grabOrigin = null; - return true; - } - const p = screenToWorldClient(clientX, clientY); - if (!p.inside && uiCache.grabOrigin) { - target.x = uiCache.grabOrigin.x; - target.y = uiCache.grabOrigin.y; - target.vx = uiCache.grabOrigin.vx || 0; - target.vy = uiCache.grabOrigin.vy || 0; - target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002"); - uiCache.grabOrigin = null; - return true; - } - uiCache.grabOrigin = null; - return false; - } - const beginTouchGrab = (p, e) => { - const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; - if (!target) return false; - audio.grab?.(); - uiCache.grabbing = true; - uiCache.grabMoved = false; - uiCache.grabTarget = target; - uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; - rememberGrabOrigin(target); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - uiCache.grabStartX = e.touches?.[0]?.clientX || 0; - uiCache.grabStartY = e.touches?.[0]?.clientY || 0; - if (uiCache.grabKind === "tarinai") { - const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); - if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch"); - target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); - target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); - } else { - if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); - target.dropTimer = 0; - target.dropMax = 0; - target.dropImpactDone = true; - } - uiCache.touchMode = "grab"; - renderStats(); - return true; - }; - - canvas?.addEventListener("touchstart", (e) => { - if (!e.touches?.length) return; - e.preventDefault(); - uiCache.touchMoved = false; - const mode = mobileInputMode(); - if ((mode === "camera" || mode === "family") && e.touches.length === 1) { - const t = e.touches[0]; - uiCache.touchStartX = t.clientX; - uiCache.touchStartY = t.clientY; - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - uiCache.touchMode = "cameraPan"; - return; - } - if (e.touches.length >= 2) { - const c = touchCenter(e.touches); - uiCache.touchMode = "pinchZoom"; - uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches)); - uiCache.pinchStartZoom = world.fieldZoom || 1; - uiCache.pinchLastCenterX = c?.clientX || 0; - uiCache.pinchLastCenterY = c?.clientY || 0; - return; - } - const t = e.touches[0]; - const p = screenToWorldClient(t.clientX, t.clientY); - uiCache.touchStartX = t.clientX; - uiCache.touchStartY = t.clientY; - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - uiCache.touchLastWorldX = p.x; - uiCache.touchLastWorldY = p.y; - if (mode === "tool" && world.tool === "observe") { - uiCache.touchMode = "toolTapOnly"; - return; - } - if (world.tool === "water_hose" && p.inside) { - audio.waterHose?.(); - uiCache.hosing = true; - uiCache.hoseMoved = false; - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - uiCache.hoseStartX = t.clientX; - uiCache.hoseStartY = t.clientY; - world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); - uiCache.touchMode = "hose"; - render(); - return; - } - if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return; - uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap"; - }, { passive: false }); - - canvas?.addEventListener("touchmove", (e) => { - if (!e.touches?.length || !uiCache.touchMode) return; - e.preventDefault(); - if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) { - const c = touchCenter(e.touches); - const distNow = Math.max(1, touchDistance(e.touches)); - const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow); - setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio); - const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX); - const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY); - uiCache.pinchLastCenterX = c.clientX; - uiCache.pinchLastCenterY = c.clientY; - if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy); - uiCache.touchMoved = true; - render(); - return; - } - const t = e.touches[0]; - const p = screenToWorldClient(t.clientX, t.clientY); - const screenDx = t.clientX - (uiCache.touchLastX || t.clientX); - const screenDy = t.clientY - (uiCache.touchLastY || t.clientY); - const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY)); - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - if (uiCache.touchMode === "cameraPan") { - if (totalMove > 2) uiCache.touchMoved = true; - world.panCamera?.(-screenDx, -screenDy); - render(); - return; - } - if (uiCache.touchMode === "toolTapOnly") { - if (totalMove > 8) uiCache.touchMoved = true; - return; - } - if (uiCache.touchMode === "hose" && uiCache.hosing) { - const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); - const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; } - if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); - render(); - return; - } - if (uiCache.touchMode === "grab" && uiCache.grabbing) { - const target = uiCache.grabTarget; - if (!target || target.dead) { - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.touchMode = ""; - return; - } - const dx = p.x - (uiCache.grabLastWorldX ?? p.x); - const dy = p.y - (uiCache.grabLastWorldY ?? p.y); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } - const pad = target.radius || target.r || 16; - if (uiCache.grabKind === "tarinai" && !p.inside) { - const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0); - target.x = raw.x; - target.y = raw.y; - } else { - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); - } - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); - if (uiCache.grabKind === "tarinai") { - target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - } else if (target.type === "ball") { - target.prevX = target.x - dx; - target.prevY = target.y - dy; - target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); - } - world.drawListDirty = true; - const now = performance.now(); - if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = now; - } - render(); - return; - } - if (uiCache.touchMode === "panOrTap") { - if (totalMove > 7) { - uiCache.touchMoved = true; - world.panCamera?.(-screenDx, -screenDy); - render(); - } - } - }, { passive: false }); - - canvas?.addEventListener("touchend", (e) => { - e.preventDefault(); - if (uiCache.hosing) { - uiCache.hosing = false; - world.rebuildSpatial?.(); - renderStats(); - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (target) { - if (uiCache.grabKind === "tarinai") { - target.target = null; - target.targetKey = ""; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); - finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; - } - } - audio.drop?.(); - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = 0; - renderStats(); - } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { - const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - world.handleClick(p.x, p.y); - renderStats(); - } - } else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) { - const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - world.handleClick(p.x, p.y); - renderStats(); - } - } - if (!e.touches?.length) { - uiCache.touchMode = ""; - uiCache.touchMoved = false; - } - }, { passive: false }); - - canvas?.addEventListener("touchcancel", () => { - uiCache.touchMode = ""; - uiCache.touchMoved = false; - uiCache.hosing = false; - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - }, { passive: true }); - - - canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); - canvas?.addEventListener("mousedown", (e) => { - if (e.button === 0 && world.tool === "water_hose") { - audio.waterHose?.(); - const p = screenToWorld(e); - if (!p.inside) return; - e.preventDefault(); - uiCache.hosing = true; - uiCache.hoseMoved = false; - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - uiCache.hoseStartX = e.clientX; - uiCache.hoseStartY = e.clientY; - world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); - render(); - return; - } - if (e.button === 0 && world.tool === "pinch") { - const p = screenToWorld(e); - if (!p.inside) return; - const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; - if (!target) { - showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); - return; - } - e.preventDefault(); - audio.grab?.(); - uiCache.grabbing = true; - uiCache.grabMoved = false; - uiCache.grabTarget = target; - uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; - rememberGrabOrigin(target); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - uiCache.grabStartX = e.clientX; - uiCache.grabStartY = e.clientY; - if (uiCache.grabKind === "tarinai") { - const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); - if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch"); - target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); - target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); - } else { - if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); - target.dropTimer = 0; - target.dropMax = 0; - target.dropImpactDone = true; - } - canvas.style.cursor = "grabbing"; - renderStats(); - return; - } - if (e.button !== 2) return; - e.preventDefault(); - uiCache.panning = true; - uiCache.panMoved = false; - uiCache.panButton = e.button; - uiCache.panStartX = e.clientX; - uiCache.panStartY = e.clientY; - uiCache.panLastX = e.clientX; - uiCache.panLastY = e.clientY; - }); - window.addEventListener("mouseup", (e) => { - if (uiCache.hosing) { - uiCache.hosing = false; - canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"))); - world.rebuildSpatial?.(); - renderStats(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (target) { - if (uiCache.grabKind === "tarinai") { - target.target = null; - target.targetKey = ""; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); - finishTarinaiGrab(target, e.clientX, e.clientY); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; - } - } - audio.drop?.(); - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = 0; - canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - renderStats(); - return; - } - if (!uiCache.panning) return; - if (uiCache.panButton !== null && e.button !== uiCache.panButton) return; - uiCache.panning = false; - uiCache.panButton = null; - canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - }); - window.addEventListener("mousemove", (e) => { - if (uiCache.hosing) { - const p = screenToWorld(e); - const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); - const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY)); - if (totalMove > 3) uiCache.hoseMoved = true; - if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); - canvas.style.cursor = "crosshair"; - render(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (!target || target.dead) { - uiCache.grabbing = false; - uiCache.grabTarget = null; - canvas.style.cursor = world.tool === "pinch" ? "grab" : "default"; - return; - } - const p = screenToWorld(e); - const dx = p.x - (uiCache.grabLastWorldX ?? p.x); - const dy = p.y - (uiCache.grabLastWorldY ?? p.y); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); - if (totalMove > 3) uiCache.grabMoved = true; - const pad = target.radius || target.r || 16; - if (uiCache.grabKind === "tarinai" && !p.inside) { - const raw = clientToWorldRaw(e.clientX, e.clientY); - target.x = raw.x; - target.y = raw.y; - } else { - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); - } - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); - if (uiCache.grabKind === "tarinai") { - target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); - } 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; - }); + const inputContext = window.TarinaiUIInputShared?.createInputContext?.({ canvas, world, ui, uiCache }); + if (!inputContext) throw new Error("Tarinai UI input context is not available"); + window.TarinaiTouchInput?.bindTouchInput?.(inputContext); + window.TarinaiMouseInput?.bindMouseInput?.(inputContext); window.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; diff --git a/js/ui_charts.js b/js/ui_charts.js index 0b97725..9dc0f45 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -18,7 +18,8 @@ function renderStats() { }; setTextIfChanged(ui.statPop, "pop", values.pop); setTextIfChanged(ui.statDead, "dead", values.dead); - setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "\u306e\u3093\u3073\u308a"); + setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "安定"); + window.TarinaiGroundUI?.update?.(world); updateColonyHistory(values); drawColonyChart(values); renderSelected(); @@ -111,9 +112,6 @@ function drawColonyChart(values) { const chart = ui.colonyChart; if (!chart) return; const now = performance.now(); - const perf = world.performanceLevel ? world.performanceLevel() : 0; - const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0; - if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return; const mode = uiCache.chartMode || "population"; if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) { uiCache.environmentInitialRow = { t: world.time || 0, ...values }; @@ -178,17 +176,17 @@ function chartSeries(mode = "population") { { key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true }, { key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true }, { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true }, - { key: "mood", label: "\u7dcf\u5408\u7684\u6c17\u5206", color: "#0072b2", percent: true }, + { key: "mood", label: "気分", color: "#0072b2", percent: true }, ]; if (mode === "objects") return [ { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, - { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, + { key: "obj_trace", label: "足跡", color: "#8a7054" }, ]; if (mode === "environment") return [ - { key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true }, - { key: "hygiene", label: "\u885b\u751f", color: "#6f8a3f", percent: true }, - { key: "happiness", label: "\u5e78\u798f", color: "#d98bc0", percent: true }, + { key: "security", label: "安全", color: "#d65e46", percent: true }, + { key: "hygiene", label: "衛生", color: "#6f8a3f", percent: true }, + { key: "happiness", label: "満足", color: "#d98bc0", percent: true }, ]; return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; } diff --git a/js/ui_family_data.js b/js/ui_family_data.js index c7f883b..0374602 100644 --- a/js/ui_family_data.js +++ b/js/ui_family_data.js @@ -1,9 +1,4 @@ "use strict"; - -function buildArchiveRows(family) { - return liveFamilyComponents(family); -} - function lineageEdgeFamily(family = world.family || {}) { return family || {}; } @@ -85,47 +80,3 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) { children.sort((a, b) => lineageNodeSort(family[a], family[b])); return children; } - -function lineageHasActualRelation(n, family = world.family || {}) { - return lineageMutualParentIds(n, family).length > 0 || lineageMutualChildIds(n, family).length > 0; -} - -function lineageRelatedIdSet(family) { - family = lineageEdgeFamily(family); - const relationIndex = lineageRelationIndex(family); - const ids = new Set(); - for (const n of Object.values(family || {}).filter(Boolean)) { - const parents = relationIndex.parentsByChild.get(n.id) || []; - const children = relationIndex.childrenByParent.get(n.id) || []; - if (parents.size || children.size) ids.add(n.id); - for (const id of parents) ids.add(id); - for (const id of children) ids.add(id); - } - return ids; -} - -function liveFamilyComponents(family) { - // Childless pairs, including pairs currently performing the birth ritual, are - // intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child - // relation exists; otherwise ritual participants become isolated pseudo-families. - family = lineageEdgeFamily(family); - const relatedIds = lineageRelatedIdSet(family); - const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); - const ids = new Set(nodes.map(n => n.id)); - const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, { - sort: lineageNodeSort, - edgesOf: (n) => [ - ...lineageMutualParentIds(n, family, ids), - ...lineageMutualChildIds(n, family, ids), - ], - filter: comp => comp.some(x => x.alive), - }); - comps.sort((a, b) => { - const ag = Math.min(...a.map(n => n.generation || 1)); - const bg = Math.min(...b.map(n => n.generation || 1)); - const at = Math.min(...a.map(n => n.birthTime || 0)); - const bt = Math.min(...b.map(n => n.birthTime || 0)); - return ag - bg || at - bt || b.length - a.length; - }); - return comps; -} diff --git a/js/ui_family_paths.js b/js/ui_family_paths.js index 6cc8158..0ab6775 100644 --- a/js/ui_family_paths.js +++ b/js/ui_family_paths.js @@ -201,37 +201,6 @@ function lineageExpectedChildEdgeCount(component, family) { 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(); diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 227e3cf..3e98528 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -360,11 +360,6 @@ function validateFamilyTree() { }); return issues; } - -function renderArchiveWindow() { - renderArchive(); -} - function scheduleArchiveWindowRender() { if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) { uiCache.archiveScheduled = false; diff --git a/js/ui_ground.js b/js/ui_ground.js new file mode 100644 index 0000000..3fb35e9 --- /dev/null +++ b/js/ui_ground.js @@ -0,0 +1,57 @@ +"use strict"; + +(function (global) { + function groundDef(worldRef = global.world) { + const id = worldRef?.groundType || "soil"; + return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "土", description: "いつものじめん" }); + } + + function groundTip(worldRef = global.world) { + const def = groundDef(worldRef); + if (global.TarinaiGround?.tooltipText) return global.TarinaiGround.tooltipText(def.id); + return `${def.label || "じめん"}\n${def.description || "クリックで切り替え"}`; + } + + function updateGroundUI(worldRef = global.world) { + const uiRef = global.ui || {}; + const def = groundDef(worldRef); + const label = def.label || "土"; + if (uiRef.statGroundType) setTextIfChanged(uiRef.statGroundType, "groundType", label); + if (uiRef.groundTypeBtn) { + const desc = def.description || "クリックで切り替え"; + uiRef.groundTypeBtn.dataset.tip = desc; + uiRef.groundTypeBtn.removeAttribute("title"); + uiRef.groundTypeBtn.setAttribute("aria-label", desc ? `地面を切り替える。現在は${label}。${desc}` : `地面を切り替える。現在は${label}`); + } + } + + function bindGroundUI(worldRef = global.world) { + const btn = global.ui?.groundTypeBtn; + if (!btn) return; + if (btn.dataset.groundBound !== "1") { + btn.dataset.groundBound = "1"; + btn.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const currentWorld = global.world || worldRef; + audio.uiClick?.(); + currentWorld?.cycleGroundType?.(); + if (global.uiCache) { + global.uiCache.lastStatsGroundType = ""; + if (global.uiCache.stats) global.uiCache.stats.groundType = ""; + } + updateGroundUI(currentWorld); + global.renderStats?.(); + }); + } + global.TarinaiTooltips?.bind?.(btn, () => groundTip(worldRef)); + updateGroundUI(worldRef); + } + + global.TarinaiGroundUI = Object.freeze({ + bind: bindGroundUI, + update: updateGroundUI, + tip: groundTip, + }); + global.bindGroundUI = bindGroundUI; +})(window); diff --git a/js/ui_input_mouse.js b/js/ui_input_mouse.js new file mode 100644 index 0000000..638b928 --- /dev/null +++ b/js/ui_input_mouse.js @@ -0,0 +1,81 @@ +"use strict"; + +(function (global) { + function bindMouseInput(ctx) { + if (!ctx?.canvas) return false; + const { canvas, world, uiCache } = ctx; + + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + canvas.addEventListener("mousedown", (e) => { + if (e.button === 0 && world.tool === "water_hose") { + const p = ctx.screenToWorldEvent(e); + if (!p.inside) return; + e.preventDefault(); + ctx.beginHose(p, e.clientX, e.clientY); + return; + } + if (e.button === 0 && world.tool === "pinch") { + const p = ctx.screenToWorldEvent(e); + if (!p.inside) return; + if (!ctx.beginGrab(p, e.clientX, e.clientY, { notifyNoTarget: true })) return; + e.preventDefault(); + canvas.style.cursor = "grabbing"; + return; + } + if (e.button !== 2) return; + e.preventDefault(); + ctx.beginPan(e.clientX, e.clientY, e.button); + }); + + window.addEventListener("mouseup", (e) => { + if (ctx.endHose()) return; + if (ctx.endGrab(e.clientX, e.clientY)) return; + ctx.endPan(e.button); + }); + + window.addEventListener("mousemove", (e) => { + if (uiCache.hosing) { + const p = ctx.screenToWorldEvent(e); + ctx.moveHose(p, e.clientX, e.clientY); + return; + } + if (uiCache.grabbing) { + const p = ctx.screenToWorldEvent(e); + const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); + if (totalMove > 3) uiCache.grabMoved = true; + ctx.moveGrab(p, e.clientX, e.clientY); + return; + } + ctx.movePan(e.clientX, e.clientY); + }); + + 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 = ctx.screenToWorldEvent(e); + if (!p.inside) return; + world.handleClick(p.x, p.y); + renderStats(); + }); + + canvas.addEventListener("mousemove", (e) => ctx.updatePointerFromEvent(e)); + canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); + canvas.addEventListener("mouseleave", () => { + world.pointer.inside = false; + world.pointer.motion = 0; + }); + return true; + } + + global.TarinaiMouseInput = Object.freeze({ bindMouseInput }); +})(window); diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js new file mode 100644 index 0000000..b84f7c5 --- /dev/null +++ b/js/ui_input_shared.js @@ -0,0 +1,378 @@ +"use strict"; + +(function (global) { + function cursorForTool(tool) { + if (tool === "water_hose") return "crosshair"; + if (tool === "pinch") return "grab"; + if (tool === "observe") return "default"; + if (tool === "poke" || tool === "delete") return "pointer"; + return "crosshair"; + } + + function createInputContext({ canvas, world, ui, uiCache }) { + if (!canvas || !world || !uiCache) return null; + const inputModeManager = global.TarinaiInputMode; + + function setMobileInputMode(mode = "auto") { + mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto"); + uiCache.mobileInputMode = mode; + ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode)); + document.body.classList.toggle("mobile-mode-camera", mode === "camera"); + document.body.classList.toggle("mobile-mode-tool", mode === "tool"); + document.body.classList.toggle("mobile-mode-family", mode === "family"); + return mode; + } + + function updateTouchMobileUiClass() { + const enabled = Boolean(inputModeManager?.refresh?.().touchFirst); + document.body.classList.toggle("touch-mobile-ui", enabled); + if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled; + return enabled; + } + + function mobileInputMode() { + return inputModeManager?.currentMode || uiCache.mobileInputMode || "auto"; + } + + function bindMobileModeControls() { + updateTouchMobileUiClass(); + window.addEventListener("resize", updateTouchMobileUiClass, { passive: true }); + window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass); + setMobileInputMode(uiCache.mobileInputMode || "auto"); + ui.mobileModeControls?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-mobile-mode]"); + if (!btn) return; + audio.uiClick?.(); + setMobileInputMode(btn.dataset.mobileMode || "auto"); + showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`); + }); + } + + function touchPoint(touches, index = 0) { + const t = touches?.[index]; + if (!t) return null; + return { clientX: t.clientX, clientY: t.clientY }; + } + + function 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 }; + } + + function 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); + } + + function 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 }; + } + + function screenToWorldEvent(e) { + if (typeof screenToWorld === "function") return screenToWorld(e); + return screenToWorldClient(e.clientX, e.clientY); + } + + function clientToWorldRaw(clientX, clientY) { + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const scale = world.viewScale ? world.viewScale() : 1; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const rawX = (sx - off.x) / scale + (world.cameraX || 0); + const rawY = (sy - off.y) / scale + (world.cameraY || 0); + return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h }; + } + + function setZoomAroundClientPoint(clientX, clientY, nextZoom) { + if (!world.setFieldZoom) return false; + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy }; + if (!world.setFieldZoom(nextZoom)) return false; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const scale = world.viewScale ? world.viewScale() : 1; + world.cameraX = focus.x - (sx - off.x) / scale; + world.cameraY = focus.y - (sy - off.y) / scale; + world.clampCamera?.(); + return true; + } + + function isClientOverFrozenPanel(clientX, clientY) { + const panel = document.getElementById("frozenPanel"); + if (!panel) return false; + const r = panel.getBoundingClientRect(); + return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; + } + + function rememberGrabOrigin(target) { + if (!target || uiCache.grabKind !== "tarinai") return; + uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 }; + } + + function finishTarinaiGrab(target, clientX, clientY) { + if (!target || uiCache.grabKind !== "tarinai") return false; + if (isClientOverFrozenPanel(clientX, clientY) && global.TarinaiFreezeSystem?.freezeTarinai) { + global.TarinaiFreezeSystem.freezeTarinai(target, world); + uiCache.grabOrigin = null; + return true; + } + const p = screenToWorldClient(clientX, clientY); + if (!p.inside && uiCache.grabOrigin) { + target.x = uiCache.grabOrigin.x; + target.y = uiCache.grabOrigin.y; + target.vx = uiCache.grabOrigin.vx || 0; + target.vy = uiCache.grabOrigin.vy || 0; + target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); + showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002"); + uiCache.grabOrigin = null; + return true; + } + uiCache.grabOrigin = null; + return false; + } + + function prepareGrabTarget(target) { + if (uiCache.grabKind === "tarinai") { + const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); + if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch"); + target.sleeping = false; + if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); + } else { + if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); + target.dropTimer = 0; + target.dropMax = 0; + target.dropImpactDone = true; + } + } + + function beginGrab(p, clientX, clientY, options = {}) { + const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; + if (!target) { + if (options.notifyNoTarget) showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); + return false; + } + audio.grab?.(); + uiCache.grabbing = true; + uiCache.grabMoved = false; + uiCache.grabTarget = target; + uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; + rememberGrabOrigin(target); + uiCache.grabLastWorldX = p.x; + uiCache.grabLastWorldY = p.y; + uiCache.grabStartX = clientX || 0; + uiCache.grabStartY = clientY || 0; + prepareGrabTarget(target); + if (options.touchMode) uiCache.touchMode = options.touchMode; + renderStats(); + return true; + } + + function moveGrab(p, clientX, clientY, options = {}) { + const target = uiCache.grabTarget; + if (!target || target.dead) { + uiCache.grabbing = false; + uiCache.grabTarget = null; + if (options.touch) uiCache.touchMode = ""; + canvas.style.cursor = world.tool === "pinch" ? "grab" : "default"; + return false; + } + 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 pad = target.radius || target.r || 16; + if (uiCache.grabKind === "tarinai" && !p.inside) { + const raw = clientToWorldRaw(clientX, clientY); + target.x = raw.x; + target.y = raw.y; + } else { + target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); + target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + } + target.vx = clamp(dx * 16, -160, 160); + target.vy = clamp(dy * 16, -160, 160); + if (uiCache.grabKind === "tarinai") { + target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); + if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); + } else if (target.type === "ball") { + target.prevX = target.x - dx; + target.prevY = target.y - dy; + target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); + } + world.drawListDirty = true; + const now = performance.now(); + if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = now; + } + canvas.style.cursor = "grabbing"; + if (options.renderFrame) render(); + return true; + } + + function endGrab(clientX, clientY) { + if (!uiCache.grabbing) return false; + const target = uiCache.grabTarget; + if (target) { + if (uiCache.grabKind === "tarinai") { + target.target = null; + target.targetKey = ""; + target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); + finishTarinaiGrab(target, clientX, clientY); + } else if (target.type === "ball") { + target.prevX = target.x; + target.prevY = target.y; + } + } + audio.drop?.(); + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.grabKind = ""; + world.rebuildSpatial?.(); + uiCache.grabLastSpatialAt = 0; + canvas.style.cursor = cursorForTool(world.tool); + renderStats(); + return true; + } + + function beginHose(p, clientX, clientY, options = {}) { + audio.waterHose?.(); + uiCache.hosing = true; + uiCache.hoseMoved = false; + uiCache.hoseLastWorldX = p.x; + uiCache.hoseLastWorldY = p.y; + uiCache.hoseStartX = clientX; + uiCache.hoseStartY = clientY; + world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); + if (options.touchMode) uiCache.touchMode = options.touchMode; + render(); + return true; + } + + function moveHose(p, clientX, clientY, options = {}) { + 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(clientX - (uiCache.hoseStartX || clientX), clientY - (uiCache.hoseStartY || clientY)); + if (totalMove > 3) { + uiCache.hoseMoved = true; + if (options.touch) uiCache.touchMoved = true; + } + if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); + canvas.style.cursor = "crosshair"; + render(); + } + + function endHose() { + if (!uiCache.hosing) return false; + uiCache.hosing = false; + canvas.style.cursor = cursorForTool(world.tool); + world.rebuildSpatial?.(); + renderStats(); + return true; + } + + function beginPan(clientX, clientY, button = null) { + uiCache.panning = true; + uiCache.panMoved = false; + uiCache.panButton = button; + uiCache.panStartX = clientX; + uiCache.panStartY = clientY; + uiCache.panLastX = clientX; + uiCache.panLastY = clientY; + } + + function movePan(clientX, clientY) { + if (!uiCache.panning) return false; + const dx = clientX - uiCache.panLastX; + const dy = clientY - uiCache.panLastY; + uiCache.panLastX = clientX; + uiCache.panLastY = clientY; + const totalMove = Math.hypot(clientX - uiCache.panStartX, clientY - uiCache.panStartY); + if (totalMove > 8) { + canvas.style.cursor = "grabbing"; + world.panCamera?.(-dx, -dy); + uiCache.panMoved = true; + render(); + } + return true; + } + + function endPan(button = null) { + if (!uiCache.panning) return false; + if (uiCache.panButton !== null && button !== null && button !== uiCache.panButton) return false; + uiCache.panning = false; + uiCache.panButton = null; + canvas.style.cursor = cursorForTool(world.tool); + return true; + } + + function updatePointerFromEvent(e) { + const p = screenToWorldEvent(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(); + } + + function cancelPointerActions() { + uiCache.touchMode = ""; + uiCache.touchMoved = false; + uiCache.hosing = false; + uiCache.grabbing = false; + uiCache.grabTarget = null; + uiCache.grabKind = ""; + } + + return { + canvas, + world, + ui, + uiCache, + cursorForTool, + setMobileInputMode, + updateTouchMobileUiClass, + mobileInputMode, + bindMobileModeControls, + touchPoint, + touchCenter, + touchDistance, + screenToWorldClient, + screenToWorldEvent, + clientToWorldRaw, + setZoomAroundClientPoint, + beginGrab, + moveGrab, + endGrab, + beginHose, + moveHose, + endHose, + beginPan, + movePan, + endPan, + updatePointerFromEvent, + cancelPointerActions, + }; + } + + global.TarinaiUIInputShared = Object.freeze({ createInputContext, cursorForTool }); +})(window); diff --git a/js/ui_input_touch.js b/js/ui_input_touch.js new file mode 100644 index 0000000..aa628fd --- /dev/null +++ b/js/ui_input_touch.js @@ -0,0 +1,133 @@ +"use strict"; + +(function (global) { + function bindTouchInput(ctx) { + if (!ctx?.canvas) return false; + const { canvas, world, uiCache } = ctx; + ctx.bindMobileModeControls(); + + canvas.addEventListener("touchstart", (e) => { + if (!e.touches?.length) return; + e.preventDefault(); + uiCache.touchMoved = false; + const mode = ctx.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 = ctx.touchCenter(e.touches); + uiCache.touchMode = "pinchZoom"; + uiCache.pinchStartDistance = Math.max(1, ctx.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 = ctx.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) { + ctx.beginHose(p, t.clientX, t.clientY, { touchMode: "hose" }); + return; + } + if (world.tool === "pinch" && p.inside && ctx.beginGrab(p, t.clientX, t.clientY, { touchMode: "grab" })) 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 = ctx.touchCenter(e.touches); + const distNow = Math.max(1, ctx.touchDistance(e.touches)); + const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow); + ctx.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 = ctx.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) { + ctx.moveHose(p, t.clientX, t.clientY, { touch: true }); + return; + } + if (uiCache.touchMode === "grab" && uiCache.grabbing) { + if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } + ctx.moveGrab(p, t.clientX, t.clientY, { touch: true, renderFrame: true }); + 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) ctx.endHose(); + if (uiCache.grabbing) { + ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); + } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { + const p = ctx.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 = ctx.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", () => ctx.cancelPointerActions(), { passive: true }); + return true; + } + + global.TarinaiTouchInput = Object.freeze({ bindTouchInput }); +})(window); diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index bf1eb33..376220c 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -2,7 +2,8 @@ function resizeCanvas() { const rect = canvas.getBoundingClientRect(); - const dpr = Math.max(0.6, Math.min(window.devicePixelRatio || 1, 2)); + const perfScale = window.TarinaiPerf?.dprScale?.() || 1; + const dpr = Math.max(0.5, Math.min((window.devicePixelRatio || 1) * perfScale, 2)); 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; @@ -46,40 +47,7 @@ function hydrateLazyImages(root) { } function applyToolTips() { - const tips = window.TEXT_CATALOG?.toolTips || {}; - if (!ui.toolPalette) return; - let pop = document.getElementById("toolTipPopover"); - if (!pop) { - pop = document.createElement("div"); - pop.id = "toolTipPopover"; - pop.className = "tool-tip-popover hidden"; - document.body.appendChild(pop); - } - const showTip = (btn) => { - const baseTip = btn?.dataset?.tip || ""; - if (!baseTip) return; - const tip = baseTip; - pop.textContent = tip; - pop.classList.remove("hidden"); - const rect = btn.getBoundingClientRect(); - const popW = pop.offsetWidth || 220; - const popH = pop.offsetHeight || 36; - const left = rect.left - popW - 12; - pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`; - pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`; - }; - const hideTip = () => pop.classList.add("hidden"); - for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) { - const tip = tips[btn.dataset.tool]; - if (!tip) continue; - btn.dataset.tip = tip; - if (btn.dataset.tipBound === "1") continue; - btn.dataset.tipBound = "1"; - btn.addEventListener("mouseenter", () => showTip(btn)); - btn.addEventListener("focus", () => showTip(btn)); - btn.addEventListener("mouseleave", hideTip); - btn.addEventListener("blur", hideTip); - } + window.TarinaiTooltips?.applyToolPalette?.(); } function renderEcologyCards() { diff --git a/js/ui_selected.js b/js/ui_selected.js index 308507f..300092b 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -1,5 +1,7 @@ "use strict"; +const escapeHtml = window.TarinaiUIHelpers.htmlEscape; + function quoteNameInRecordText(text, name) { const raw = String(text || ""); const n = String(name || "").trim(); @@ -139,19 +141,6 @@ function personalityDailySnapshot(t) { }).join("|"); return `${keyPart}#${causePart}`; } - -function recentChangeCausesHtml(t) { - const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : []; - if (!rows.length) return `
  • -\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057
  • `; - return rows.map(entry => { - const timeText = world.clockStringFromTime ? world.clockStringFromTime(entry.time || 0) : formatRecordTime(entry.time || 0); - const reason = String(entry.reason || "\u5909\u5316"); - const target = String(entry.target || "\u72b6\u614b"); - const value = entry.value === "" || entry.value == null ? "" : ` ${entry.value}`; - return `
  • ${escapeHtml(timeText)}${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}
  • `; - }).join(""); -} - function selectedDataCategoryHtml(id, label, bodyHtml) { if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); const collapsed = uiCache.selectedCollapsedCategories.has(id); @@ -192,17 +181,21 @@ function selectedStateText(t) { function renderSelected() { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) { - ui.selectedCard?.classList.add("hidden"); - if (uiCache.selectedEmpty) return; + const showEmpty = Boolean(uiCache.showSelectedEmpty); + ui.selectedCard?.classList.toggle("hidden", !showEmpty); + if (uiCache.selectedEmpty && uiCache.selectedEmptyVisible === showEmpty) return; uiCache.selectedSnapshot = ""; uiCache.selectedEmpty = true; + uiCache.selectedEmptyVisible = showEmpty; ui.selectedInfo.className = "selected-info empty"; ui.selectedInfo.textContent = "\u672a\u9078\u629e"; return; } + uiCache.showSelectedEmpty = false; ui.selectedCard?.classList.remove("hidden"); uiCache.selectedEmpty = false; + uiCache.selectedEmptyVisible = false; const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id; if (activeNameInput) return; const rel = t.relationSummary ? t.relationSummary() : {}; @@ -219,7 +212,7 @@ function renderSelected() { const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior; const snapshot = [ t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), - behaviorState?.need || "", behaviorState?.id || "", behaviorState?.label || "", behaviorState?.reasonText || "", behaviorState?.id || "", behaviorState?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), + behaviorState?.need || "", behaviorState?.actionId || "", behaviorState?.label || "", behaviorState?.reason || "", behaviorState?.source || "", behaviorState?.text || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), 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), @@ -324,14 +317,16 @@ function stateLabel(s) { function reasonLabel(t) { if (!t) return "\u306a\u3057"; - const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim(); + const behavior = typeof globalThis.behaviorText === "function" + ? globalThis.behaviorText(t) + : String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.text || t.behavior?.reason || "")).trim(); if (behavior) return behavior; const state = String(t.state || ""); const live = window.TEXT_CATALOG?.reasonLabel?.(t) || ""; const liveStates = new Set(["birth_ritual", "eat", "sleep", "panic", "fight", "intimidate", "ant_attack", "ant_intimidate", "frozen"]); if (live && (liveStates.has(state) || (t.birthRitualTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04 || (t.fightTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04)) return live; const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior; - if (behaviorState?.reasonText) return behaviorState.reasonText; + if (behaviorState?.reason || behaviorState?.text) return behaviorState.reason || behaviorState.text; return live || "\u306a\u3057"; } @@ -354,26 +349,6 @@ function setTextIfChanged(el, key, value) { uiCache.stats[key] = next; el.textContent = next; } - -function setValueIfChanged(el, key, value) { - if (!el) return; - if (uiCache.stats[key] === value) return; - uiCache.stats[key] = value; - el.value = value; -} - -function escapeHtml(value) { - const helper = window.TarinaiUIHelpers?.htmlEscape; - if (typeof helper === "function" && helper !== escapeHtml) return helper(value); - return String(value ?? "").replace(/[&<>"']/g, c => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[c] || c)); -} - function escapeRegExp(value) { return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/js/ui_tools.js b/js/ui_tools.js index b64ccb7..63cc52e 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -24,11 +24,12 @@ function renderToolPalette() { body.className = "tool-grid tool-category-body"; for (const toolId of category.toolIds || []) { const def = toolDefinition(toolId); - if (!def || def.simulationOnly) continue; + if (!def || def.simulationOnly || toolId === "observe") continue; const btn = document.createElement("button"); btn.className = "tool"; if (EMOJI_ICON_TOOLS.has(toolId)) btn.classList.add("tool-emoji-icon"); btn.dataset.tool = toolId; + btn.dataset.toolCategory = category.id || ""; const label = document.createElement("span"); label.className = "tool-label"; label.textContent = def.label || toolId; diff --git a/js/ui_tooltips.js b/js/ui_tooltips.js new file mode 100644 index 0000000..e9142bc --- /dev/null +++ b/js/ui_tooltips.js @@ -0,0 +1,70 @@ +"use strict"; + +(function (global) { + function ensureTooltipPopover() { + let pop = document.getElementById("toolTipPopover"); + if (!pop) { + pop = document.createElement("div"); + pop.id = "toolTipPopover"; + pop.className = "tool-tip-popover hidden"; + document.body.appendChild(pop); + } + return pop; + } + + function resolveTip(el) { + if (!el) return ""; + if (typeof el.__tarinaiTipProvider === "function") return String(el.__tarinaiTipProvider() || ""); + return String(el.dataset?.tip || ""); + } + + function showTooltip(el) { + const tip = resolveTip(el); + if (!tip) return; + const pop = ensureTooltipPopover(); + pop.textContent = tip; + pop.classList.remove("hidden"); + const rect = el.getBoundingClientRect(); + const popW = pop.offsetWidth || 220; + const popH = pop.offsetHeight || 36; + const preferLeft = rect.left - popW - 12; + const rightSide = rect.right + 12; + const left = preferLeft >= 8 ? preferLeft : rightSide; + 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`; + } + + function hideTooltip() { + ensureTooltipPopover().classList.add("hidden"); + } + + function bindTooltip(el, tip = "") { + if (!el) return; + if (typeof tip === "function") el.__tarinaiTipProvider = tip; + else if (tip) el.dataset.tip = String(tip); + if (el.dataset.tipBound === "1") return; + el.dataset.tipBound = "1"; + el.addEventListener("mouseenter", () => showTooltip(el)); + el.addEventListener("focus", () => showTooltip(el)); + el.addEventListener("mouseleave", hideTooltip); + el.addEventListener("blur", hideTooltip); + } + + function applyToolPaletteTooltips() { + const tips = global.TEXT_CATALOG?.toolTips || {}; + const root = global.ui?.toolPalette || document; + for (const btn of root.querySelectorAll?.(".tool[data-tool]") || []) { + const tip = tips[btn.dataset.tool]; + if (tip) bindTooltip(btn, tip); + } + } + + global.TarinaiTooltips = Object.freeze({ + ensure: ensureTooltipPopover, + bind: bindTooltip, + show: showTooltip, + hide: hideTooltip, + applyToolPalette: applyToolPaletteTooltips, + }); + global.bindTooltip = bindTooltip; +})(window); diff --git a/js/version.js b/js/version.js index f403220..80322df 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "16.00.01"; - const APP_BUILD = "lag-pass-static-schedule"; + const APP_VERSION = "16.07.09"; + const APP_BUILD = "noise-cleanup"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index 91e023c..4b9e4c0 100644 --- a/js/world.js +++ b/js/world.js @@ -43,6 +43,7 @@ class World { constructor() { this.lastPhase = ""; this.weather = "sunny"; this.fieldType = "garden"; + this.groundType = "soil"; this.fieldZoom = 1; this.cameraX = 0; this.cameraY = 0; @@ -67,7 +68,7 @@ class World { constructor() { this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; - this.colonyMood = { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } }; + this.colonyMood = { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } }; this.lastColonyMoodDay = 0; this.foodSpoilagePenalty = 0; this.foodSpoilageEvents = 0; diff --git a/js/world_ants_system.js b/js/world_ants_system.js index 76f5a5f..f425d3a 100644 --- a/js/world_ants_system.js +++ b/js/world_ants_system.js @@ -115,7 +115,8 @@ 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 || []) { + const nests = this.itemsOfType ? this.itemsOfType("ant_nest") : (this.items || []); + for (const it of nests) { if (!it || it.dead || it.type !== "ant_nest") continue; nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y)); } diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 05d2e35..f6afdec 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -111,7 +111,7 @@ 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) { + for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { if (!ball || ball.dead || ball.type !== "ball") continue; let dx = ball.x - x; let dy = ball.y - y; @@ -200,7 +200,7 @@ this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, }); - for (const ball of this.items) { + for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { if (!ball || ball.dead || ball.type !== "ball") continue; let dx = ball.x - x; let dy = ball.y - y; @@ -386,33 +386,6 @@ 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; - }; - const owned = (this.nearbyItems(t.x, t.y, maxDist) || []) - .filter(bed => bed && !bed.dead && bed.ownerId === t.id && bed.roles?.sleepPlace) - .sort((a, b) => dist(t, a) - dist(t, b))[0] || null; - return owned || choose("nest_box") || choose("bed") || choose("grass_bed"); - }, - relationNotice(aId, bId, kind, cooldown = 30) { const key = [aId, bId].sort().join(":") + `:${kind}`; const last = this.relationNotices?.[key] || -Infinity; @@ -438,27 +411,6 @@ } 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"; @@ -607,17 +559,6 @@ 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(); @@ -764,7 +705,7 @@ resolveBallInteractions(dt) { if (!this.itemCounts?.ball) return; - for (const ball of this.items) { + for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : 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; diff --git a/js/world_environment.js b/js/world_environment.js index f09ab74..f06086f 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -82,11 +82,6 @@ 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 }; @@ -299,19 +294,5 @@ } 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: fertile ? 24 : 22, avoidTarinai: true })) return { x, y }; - } - return null; - } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_event_effects.js b/js/world_event_effects.js new file mode 100644 index 0000000..2cc50e9 --- /dev/null +++ b/js/world_event_effects.js @@ -0,0 +1,75 @@ +"use strict"; + +(function (global) { + const events = global.TarinaiEvents; + if (!events || events.__worldEventEffectsBound) return; + events.__worldEventEffectsBound = true; + + events.on("world:phase", () => { + global.audio?.phase?.(); + }); + + function playLogSoundForEntry(entry = {}) { + if (!entry || entry.hiddenFromObservation || entry.sound === false || entry.silentAudio) return; + const audio = global.audio; + if (!audio) 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?.(); + } + } + + function renderFreezePanel(worldRef) { + if (global.TarinaiFreezePanel?.render && global.TarinaiFreezeStore?.frozenList) { + global.TarinaiFreezePanel.render(global.TarinaiFreezeStore.frozenList(worldRef)); + return; + } + global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); + } + + + events.on("log:entry", (event) => { + const detail = event.detail || {}; + const worldRef = detail.world || global.world; + const entry = detail.entry; + if (!entry) return; + playLogSoundForEntry(entry); + if (typeof renderLog === "function") renderLog(worldRef?.logs || []); + if (typeof pushLogNotification === "function") pushLogNotification(entry); + }); + + events.on("tool:placed", (event) => { + const detail = event.detail || {}; + if (detail.dropped) global.audio?.place?.(detail.type || detail.item?.type || ""); + }); + + events.on("freeze:changed", (event) => { + const detail = event.detail || {}; + const worldRef = detail.world || global.world; + renderFreezePanel(worldRef); + if (detail.renderWorld !== false) { + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + } + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_family_social.js b/js/world_family_social.js index ddd5734..9bcbeca 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -19,6 +19,11 @@ this.items = []; this.ants = []; this.effects = []; + this.itemCounts = {}; + this.effectCounts = {}; + this.itemTypeBuckets = new Map(); + this.itemIdMap = new Map(); + this.itemBucketsDirty = false; this.logs = []; this.events = window.TarinaiEvents || null; this.eventCounters = {}; @@ -27,6 +32,8 @@ this.drawSortTimer = 0; this.drawListDirty = true; this.antUpdatePhase = 0; + this.grassGrowthTimer = 0; + this.nextGrassLimitCheckAt = 0; this.selected = null; this.deadCount = 0; this.liveIdNext = 1; @@ -51,6 +58,7 @@ this.clampCamera(); this.lastPhase = this.phaseName(); this.weather = "sunny"; + this.groundType = window.TarinaiGround?.exists?.(this.groundType || "soil") ? (this.groundType || "soil") : "soil"; this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax); seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation; const grassCount = field.grass ?? 18; @@ -65,11 +73,14 @@ } for (let i = 0; i < grassCount; i++) { const grass = new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)); + globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true }); if (this.addItem) this.addItem(grass, "reset-grass"); else this.items.push(grass); } this.addItem?.(new Item("stone", this.w * 0.68, this.h * 0.32), "reset-stone") || this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); this.updateItemCounts(); + this.enforceGrassLimit?.("reset-grass-limit"); + this.updateItemCounts(); this.updateEffectCounts(); this.markTerrainDirty?.("reset"); this.rebuildSpatial(true); @@ -78,7 +89,7 @@ }, tarinaiFamilyKey(t) { - return t?.familyKey || t?.archiveKey || t?.id || ""; + return t?.familyKey || t?.id || ""; }, assignTarinaiLiveId(t) { @@ -377,7 +388,6 @@ this.markFamilyTreeDirty("death"); } } - this.notifyDeathToRelations?.(t, finalReason); this.familyPrunePending = true; }, @@ -410,8 +420,66 @@ return t; }, + 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 }; + }, + + nestBoxTooltipLines(box) { + if (!box || box.dead || box.type !== "nest_box") return []; + const occupants = this.nestBoxOccupants(box, Infinity); + const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5); + const more = occupants.length > names.length ? `、ほか${occupants.length - names.length}匹` : ""; + return ["巣箱", names.length ? `入ってる子: ${names.join("、")}${more}` : "入ってる子: なし"]; + }, + + pointerOwnedBedInfo() { + 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 !== "grass_bed") continue; + const d = distXY(p.x, p.y, it.x, it.y); + if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; } + } + if (!best) return null; + const owner = this.liveTarinaiById?.(best.ownerId) || null; + return { bed: best, owner }; + }, + + ownedBedTooltipLines(bed) { + if (!bed || bed.dead || bed.type !== "grass_bed") return []; + const owner = this.liveTarinaiById?.(bed.ownerId) || null; + return ["かんたんベッド", `持ち主: ${owner?.name || "不明"}`]; + }, + + pointerItemTooltipInfo() { + const nest = this.pointerNestBoxInfo?.(); + if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" }; + const bedInfo = this.pointerOwnedBedInfo?.(); + if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" }; + return null; + }, + startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; + if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) 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; @@ -433,17 +501,35 @@ b.birthRitualRole = 1; a.birthRitualLeader = true; b.birthRitualLeader = false; - a.setActionState("birth_ritual", { target: b, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" }); - b.setActionState("birth_ritual", { target: a, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" }); - if (typeof setLiveActionText === "function") { + const ritualReason = "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; + const applyRitualAction = (actor, partner) => { + if (!actor) return false; + if (typeof actor.setActionState === "function") { + actor.setActionState("birth_ritual", { target: partner || null, reason: ritualReason, need: "social", subNeed: "mate", actionId: "birth_ritual", phase: "acting" }); + return true; + } + actor.state = "birth_ritual"; + actor.target = partner || null; + actor.thought = ritualReason; + actor.sleeping = false; + if (typeof setBehaviorText === "function") { + setBehaviorText(actor, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: ritualReason, reasonText: ritualReason, target: partner || null, phase: "acting", source: "state" }); + } else if (typeof patchTarinaiBehavior === "function") { + patchTarinaiBehavior(actor, { actionId: "birth_ritual", phase: "acting", target: partner || null, source: "state", need: "social", subNeed: "mate", reason: ritualReason, label: ritualReason, text: ritualReason }); + } + return true; + }; + applyRitualAction(a, b); + applyRitualAction(b, a); + if (typeof setBehaviorText === "function") { const aBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(a) : a.behavior; const bBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(b) : b.behavior; const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`; const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`; const aText = typeof buildReasonText === "function" ? buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause }) : `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`; const bText = typeof buildReasonText === "function" ? buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }) : `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`; - setLiveActionText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b }); - setLiveActionText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a }); + setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b }); + setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a }); } a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); @@ -584,7 +670,6 @@ 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; @@ -715,7 +800,7 @@ actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65)); actor.intimidateTargetId = target.id; actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); - if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { life: 0.42, size: actor.radius * 0.55, @@ -782,9 +867,9 @@ b.counterAttackFromId = b === receiver ? initiator.id : null; a.setActionState("fight", { target: b, reason: reasonA }); b.setActionState("fight", { target: a, reason: reasonB }); - if (typeof setLiveActionText === "function") { - setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" }); - setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" }); + if (typeof setBehaviorText === "function") { + setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" }); + setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" }); } a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8)); b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8)); @@ -820,7 +905,7 @@ })); 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] }); + this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false }); a.lastLog = b.lastLog = this.time; } return true; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 2f5d073..1a7bf30 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -365,10 +365,9 @@ placeItem(item, dropped = true) { if (!item) return null; - if (item.type === "grass") { - item.grassLimitExempt = true; - item.manualGrass = true; - item.placedByPlayer = true; + if (item.type === "grass" && !this.canAddGrass?.(1)) { + showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`); + 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"); @@ -379,10 +378,13 @@ item.dropTimer = item.dropMax; item.dropImpactDone = false; } - this.addItem?.(item, `place:${item.type}`, { ignoreGrassLimit: item.type === "grass", manualGrass: item.type === "grass" }) || this.items.push(item); + const placed = this.addItem?.(item, `place:${item.type}`); + if (!placed) { + if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`); + return null; + } this.emit("tool:place", { item, type: item.type, dropped }); this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); - if (dropped) audio.place?.(item.type); this.ensureSpatial?.(`place:${item.type}`); return item; }, @@ -436,32 +438,11 @@ const spot = this.edgeSpawnPoint(x, y, 42, true); const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 }); t.wanderAngle = spot.angle; - audio.birth(); this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); return; } - if (tool === "delete") { - let best = null, bestD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (it.dead) continue; - const visualHit = Math.max(22, (it.r || 12) * 2.0); - const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit; - const d = distXY(x, y, it.x, it.y); - if (d < hit && d < bestD) { best = it; bestD = d; } - } - if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } - if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; } - 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; - } + if (this.useToolAt?.(x, y)) return; const itemType = toolItemType(tool); if (itemType) { @@ -495,33 +476,6 @@ return type === "ball_poke" || type === "avoid_relationship"; }, - playLogSoundForEntry(entry = {}) { - if (!entry || entry.hiddenFromObservation || typeof audio === "undefined") return; - const kind = entry.kind || "note"; - const text = String(entry.text || ""); - if (kind === "death") return audio.death?.(); - if (kind === "birth") return audio.birth?.(); - if (kind === "fight") return /\u6c7a\u7740|\u52dd|\u8ca0|\u899a\u3048/.test(text) ? audio.fightFinish?.() : audio.fight?.(); - if (kind === "food") return audio.eat?.(); - if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 }); - if (kind === "weather") return audio.phase?.(); - if (kind === "relation") return audio.notify?.(); - if (kind === "accident") { - if (/\u914d\u7f6e|\u7f6e\u3044\u305f|\u8a2d\u7f6e/.test(text)) return audio.place?.(/\u7206\u7af9/.test(text) ? "firecracker" : ""); - if (/\u3052\u3093\u3053\u3064/.test(text)) return audio.genkotsuImpact?.(); - if (/\u7206\u7af9|\u7206\u767a/.test(text)) return audio.explode?.(); - if (/\u77f3/.test(text)) return audio.stoneImpact?.(); - if (/\u30dc\u30fc\u30eb/.test(text)) return audio.ballHit?.(); - return audio.damage?.(18); - } - if (kind === "event") { - if (/\u5973\u738b\u30a2\u30ea/.test(text)) return audio.queenAnt?.(); - if (/\u30a2\u30ea|\u5de3/.test(text)) return audio.antNest?.(); - if (/\u75c5|\u767a\u75c5/.test(text)) return audio.disease?.(); - if (/\u56de\u5fa9|\u6cbb/.test(text)) return audio.heal?.(); - } - }, - log(text, kind = null, options = {}) { const entry = { time: this.time, @@ -529,6 +483,7 @@ kind: kind || this.inferLogKind(text), eventType: options.eventType || "", hiddenFromObservation: Boolean(options.hiddenFromObservation), + sound: options.sound === false ? false : true, participants: (options.participants || []).filter(Boolean).map(t => ({ id: t.id || t.releasedLiveId || "", liveId: t.id || t.releasedLiveId || "", @@ -558,11 +513,8 @@ 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); + this.emit("log:entry", { world: this, entry }); } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index a634af0..a5d4ad8 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -4,21 +4,8 @@ const World = global.World; if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js"); - function liveCount(list) { - let count = 0; - for (const item of list || []) if (item && !item.dead) count += 1; - return count; - } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ - performanceLevel() { - return 0; - }, - - performanceQuality() { - return { level: 0, pressure: 0, effectDensity: 1, antStride: 2, effectLimit: CONFIG.effectLimit ?? 96, itemScanLimit: 16, socialScanLimit: 8, collisionInterval: 1.8, bedConflictInterval: 3.2, statsInterval: 1.2, maxDpr: 2 }; - }, - beginFramePerformanceBudgets() { this.workBudget = { ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)), @@ -54,6 +41,16 @@ return false; }, + + spatialDirtySummary() { + return { + staticItems: Boolean(this.spatialStaticItemsDirty), + dynamicItems: Boolean(this.spatialDynamicItemsDirty), + tarinai: Boolean(this.spatialTarinaiDirty), + ants: Boolean(this.spatialAntDirty), + }; + }, + runtimeDiagnostics() { const behaviorCounts = {}; let forcedBehaviors = 0; @@ -63,20 +60,22 @@ if (!t || t.dead) continue; liveTarinai += 1; const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior; - const id = behavior?.id || t.state || "none"; + const id = behavior?.actionId || t.state || "none"; behaviorCounts[id] = (behaviorCounts[id] || 0) + 1; - if (behavior?.forced) forcedBehaviors += 1; - if ((t.intentLockTimer || 0) > 0.01) lockedBehaviors += 1; + if (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest)) forcedBehaviors += 1; + if ((t.behaviorLockTimer || 0) > 0.01) lockedBehaviors += 1; } return { spatial: { version: this.spatialVersion || 0, dirty: Boolean(this.spatialDirty), + dirtyParts: this.spatialDirtySummary?.() || null, dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "", dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0, dirtyMarksTotal: this.spatialDirtyMarksTotal || 0, rebuildsThisFrame: this.spatialRebuildsThisFrame || 0, rebuildsTotal: this.spatialRebuildsTotal || 0, + partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0, lastRebuildReason: this.lastSpatialRebuildReason || "", }, render: { @@ -96,68 +95,52 @@ locked: lockedBehaviors, counts: behaviorCounts, }, - performance: { + runtime: { liveTarinai, - fps: window.__tarinaiFps || window.TarinaiPerformance?.fps || 0, - pressure: 0, - level: 0, + fps: window.__tarinaiFps || 0, dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0, - quality: null, }, work: { budget: this.workBudget || null, stats: this.workStats || null, }, + scheduler: this._itemUpdateSchedulerStats || null, + perf: window.TarinaiPerf?.snapshot?.() || null, }; }, - - grassLoadLevel() { - const grass = this.itemCounts?.grass || 0; - if (grass >= 180) return 3; - if (grass >= 120) return 2; - if (grass >= 72) return 1; - return 0; - }, - - grassUpdateFactor() { - return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0; - }, - grassLimit() { - const limit = Number(CONFIG.grassLimit ?? 360); - return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity; + return window.TarinaiGround?.grassLimit?.(this.groundType || "soil", this.fieldType || "garden") ?? (CONFIG.grassLimit ?? 99); }, - - isGrassLimitExempt(item) { - return Boolean(item && item.type === "grass" && (item.grassLimitExempt || item.manualGrass || item.placedByPlayer)); - }, - - liveGrassCount() { - let total = 0; - for (const it of this.items || []) { - if (it && !it.dead && it.type === "grass" && !this.isGrassLimitExempt?.(it)) total += 1; - } - return total; - }, - canAddGrass(extra = 1) { - const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 360); + const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99); if (!Number.isFinite(limit)) return true; - return (this.liveGrassCount ? this.liveGrassCount() : 0) + Math.max(0, Number(extra) || 0) <= limit; + const current = Math.max(0, Math.floor(Number(this.itemCounts?.grass || 0))); + return current + Math.max(0, Number(extra) || 0) <= limit; }, - sortedDrawList() { - if (!this.drawList) this.drawList = []; - const antCount = liveCount(this.ants); - const tarinaiCount = liveCount(this.tarinai); - if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) { - this.drawList.length = 0; - for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t); - for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a); - this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0)); - this.drawListDirty = false; + enforceGrassLimit(reason = "grass-limit") { + const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99); + if (!Number.isFinite(limit)) return 0; + this.updateItemCounts?.(`${reason}:count`); + let kept = 0; + let removed = 0; + for (const it of this.items || []) { + if (!it || it.dead || it.type !== "grass") continue; + if (kept < limit) { + kept += 1; + continue; + } + it.amount = 0; + it.growth = 0; + it.grassStage = 0; + removed += 1; } - return this.drawList; + if (removed > 0) { + this.markItemBucketsDirty?.(reason); + this.markSpatialDirty?.(reason); + this.markTerrainDirty?.(reason); + } + return removed; }, ensureSpatial(reason = "read") { @@ -172,7 +155,14 @@ nearbyItems(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-items"); - return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch, precise); + const out = this.spatialItemScratch || []; + out.length = 0; + if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) { + this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise); + this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise); + return out; + } + return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise); }, nearbyTarinai(x, y, radius, precise = false) { @@ -184,28 +174,58 @@ this.ensureSpatial?.("nearby-ants"); return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise); }, + nearbyFood(x, y, radius, precise = false) { + this.ensureSpatial?.("nearby-food"); + if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) { + return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise); + } + return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise); + }, nearbyObstacles(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-obstacles"); + if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) { + return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise); + } return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise); }, - nearbyFood(x, y, radius, precise = false) { - this.ensureSpatial?.("nearby-food"); - return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise); - }, - nearbyHazards(x, y, radius, precise = false) { this.ensureSpatial?.("nearby-hazards"); + if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) { + return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise); + } return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise); }, - rebuildSpatial(force = false, reason = "manual") { // Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial // so repeated rebuild requests in the same phase are collapsed to one rebuild. if (!force && this.spatialDirty === false && reason !== "manual") return false; - this.spatial.rebuild(this.items, this.tarinai, this.ants || []); + const profiler = window.TarinaiPerf; + const end = profiler?.begin?.("update.spatial") || null; + try { + const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems; + if (full) { + this.spatial.rebuild(this.items, this.tarinai, this.ants || []); + } else { + const staticDirty = Boolean(this.spatialStaticItemsDirty); + const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty); + const tarinaiDirty = Boolean(this.spatialTarinaiDirty); + const antDirty = Boolean(this.spatialAntDirty); + if (staticDirty) this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false }); + if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false }); + if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai); + if (antDirty) this.spatial.rebuildAnts(this.ants || []); + this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1; + } + } finally { + if (end) end(); + } this.spatialDirty = false; + this.spatialStaticItemsDirty = false; + this.spatialDynamicItemsDirty = false; + this.spatialTarinaiDirty = false; + this.spatialAntDirty = false; this.spatialDirtyReason = ""; this.spatialVersion = (this.spatialVersion || 0) + 1; this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1; @@ -231,13 +251,15 @@ addItem(item, reason = "add-item", opts = {}) { if (!item) return null; - const ignoreGrassLimit = Boolean(opts.ignoreGrassLimit || opts.manualGrass || item.grassLimitExempt || item.manualGrass || item.placedByPlayer); - if (item.type === "grass" && !ignoreGrassLimit && !this.canAddGrass?.(1)) return null; - if (item.type === "grass" && ignoreGrassLimit) item.grassLimitExempt = true; + if (item.type === "grass" && !this.canAddGrass?.(1)) return null; + item.world = this; this.items.push(item); this.markItemBucketsDirty?.(reason); this.markSpatialDirty?.(reason); - if (opts.terrain !== false) this.markTerrainDirty?.(reason); + if (opts.terrain !== false) { + if (item.type === "grass" || item.type === "trace" || item.type === "splat") this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason); + else this.markTerrainDirty?.(reason); + } if (opts.countNow !== false && item.type) this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1; return item; }, @@ -254,15 +276,6 @@ const bucket = this.itemTypeBuckets?.get?.(type); return bucket || []; }, - - liveItemsOfType(type, predicate = null) { - const bucket = this.itemsOfType(type); - if (typeof predicate !== "function") return bucket; - const out = []; - for (const item of bucket) if (item && !item.dead && predicate(item)) out.push(item); - return out; - }, - updateItemCounts(reason = "manual") { const counts = this.itemCounts; if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map(); @@ -287,6 +300,11 @@ this.lastItemBucketRebuildReason = reason; }, + liveItemsOfType(type, predicate = null) { + const list = this.itemsOfType ? this.itemsOfType(type) : (this.items || []).filter(it => it && !it.dead && it.type === type); + return predicate ? list.filter(predicate) : list; + }, + updateEffectCounts() { const counts = this.effectCounts; for (const key of Object.keys(counts)) counts[key] = 0; diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js new file mode 100644 index 0000000..6302660 --- /dev/null +++ b/js/world_tool_actions.js @@ -0,0 +1,46 @@ +"use strict"; + +(function (global) { + const World = global.World; + if (!World) throw new Error("World is not available for mixin: world_tool_actions.js"); + + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ + findDeleteToolTargetAt(x, y) { + let best = null; + let bestD = Infinity; + for (let i = (this.items || []).length - 1; i >= 0; i -= 1) { + const it = this.items[i]; + if (!it || 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; } + } + return best; + }, + + deleteItemAt(x, y) { + const target = this.findDeleteToolTargetAt?.(x, y); + if (!target) { showToast("消せるものがありません。"); return true; } + if (isPinType(target.type) && target.pinState === "lodged") { showToast("刺さった鋲は、つまんで抜いてください。"); return true; } + const label = toolLabel(target.type); + const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, { + reason: "delete-tool", + userReason: target.type === "grass_bed" ? "ベッドがなくなった" : "置きものがなくなった", + wake: true, + }); + if (!removed) return true; + audio.delete?.(); + this.compactItems?.(); + this.updateItemCounts?.(); + this.rebuildSpatial?.(); + this.log?.(`${label}を消した。`, "observe"); + return true; + }, + + useToolAt(x, y) { + if (this.tool === "delete") return this.deleteItemAt(x, y); + return false; + }, + })); +})(window); diff --git a/js/world_update.js b/js/world_update.js index 6d4a001..528339e 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -4,71 +4,6 @@ const World = global.World; if (!World) throw new Error("World is not available for mixin: world_update.js"); - const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]); - const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); - - function itemUpdateInterval(item) { - if (!item || item.dead) return Infinity; - if (REALTIME_ITEM_TYPES.has(item.type)) return 0; - if (PIN_ITEM_TYPES.has(item.type)) return 0; - if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; - if (item.type === "zunchi") return 2.4; - if (item.type === "grass") return 3.0 + stableUnit(item.id || item.seed || "grass", "grass-update") * 2.2; - if (item.type === "duplicator") return 2.0; - if (item.type === "water") return 1.8; - if (item.type === "trace" || item.type === "splat") return 1.2; - if (item.type === "ant_corpse") return 3.5; - if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; - return Infinity; - } - - function updateItemsScheduled(worldRef, dt) { - let ran = 0; - for (const it of worldRef.items || []) { - if (!it || it.dead) continue; - const interval = itemUpdateInterval(it); - if (!Number.isFinite(interval)) continue; - if (interval <= 0) { - it.update(dt, worldRef); - ran += 1; - continue; - } - it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt); - if (it._scheduledUpdateDt < interval) continue; - const scheduledDt = it._scheduledUpdateDt; - it._scheduledUpdateDt = 0; - it.update(scheduledDt, worldRef); - ran += 1; - } - return ran; - } - - function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) { - if (!worldRef || !Array.isArray(list) || !list.length) return false; - const thresholdSq = threshold * threshold; - let moved = false; - for (const entity of list) { - if (!entity || entity.dead) continue; - const x = Number.isFinite(entity.x) ? entity.x : 0; - const y = Number.isFinite(entity.y) ? entity.y : 0; - const oldX = entity._spatialTrackX; - const oldY = entity._spatialTrackY; - if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - continue; - } - const dx = x - oldX; - const dy = y - oldY; - if (dx * dx + dy * dy <= thresholdSq) continue; - entity._spatialTrackX = x; - entity._spatialTrackY = y; - moved = true; - } - if (moved) worldRef.markSpatialDirty?.(reason); - return moved; - } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ environmentScores(force = false) { const nowScore = this.time || 0; @@ -113,43 +48,18 @@ colonyMoodDefinition(id = "relaxed") { const defs = { - calm: { id: "calm", label: "\u304a\u3060\u3084\u304b", description: "\u793e\u4ea4\u6027\u30d7\u30e9\u30b9", effects: { personality: { sociability: 0.42 } } }, - prickly: { id: "prickly", label: "\u30d4\u30ea\u30d4\u30ea", description: "\u653b\u6483\u6027\u30d7\u30e9\u30b9", effects: { personality: { aggression: 0.46 } } }, - stinky: { id: "stinky", label: "\u304f\u3055\u3044", description: "\u958b\u653e\u6027\u30de\u30a4\u30ca\u30b9", effects: { personality: { openness: -0.44 } } }, - weaklings: { id: "weaklings", label: "\u3056\u3053\u3060\u3089\u3051", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u4ee5\u5916\u306e\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: {} } }, - zunda_party: { id: "zunda_party", label: "\u305a\u3093\u3060\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, - ball_party: { id: "ball_party", label: "\u307c\u30fc\u308b\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, - sun_party: { id: "sun_party", label: "\u304a\u3072\u3055\u307e\u3071\u30fc\u3066\u3043\u30fc", description: "\u65e5\u5149\u6d74\u983b\u5ea6\u5897\u52a0", effects: { personality: {} } }, - ant_overrun: { id: "ant_overrun", label: "\u3042\u308a\u3060\u3089\u3051", description: "\u30a2\u30ea\u3092\u898b\u308b\u3068\u30d1\u30cb\u30c3\u30af", effects: { personality: {} } }, - relaxed: { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } }, + calm: { id: "calm", label: "穏やか", description: "社交性が上がる", effects: { personality: { sociability: 0.42 } } }, + prickly: { id: "prickly", label: "緊張", description: "攻撃性が上がる", effects: { personality: { aggression: 0.46 } } }, + stinky: { id: "stinky", label: "不衛生", description: "開放性が下がる", effects: { personality: { openness: -0.44 } } }, + weaklings: { id: "weaklings", label: "弱体化", description: "ずんちどれい以外のストレスが下がる", effects: { personality: {} } }, + zunda_party: { id: "zunda_party", label: "ずんだ集中", description: "社交性と開放性が上がり、ストレスが下がる", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + ball_party: { id: "ball_party", label: "ボール集中", description: "社交性と開放性が上がり、ストレスが下がる", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + sun_party: { id: "sun_party", label: "日光浴傾向", description: "日光浴の頻度が上がる", effects: { personality: {} } }, + ant_overrun: { id: "ant_overrun", label: "アリ増加", description: "アリを見るとパニックになりやすい", effects: { personality: {} } }, + relaxed: { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } }, }; return defs[id] || defs.relaxed; }, - - evaluateColonyMood() { - const alive = (this.tarinai || []).filter(t => t && !t.dead); - const avgStress = alive.length ? alive.reduce((s, t) => s + (t.stress || 0), 0) / alive.length : 0; - const avgHunger = alive.length ? alive.reduce((s, t) => s + (t.hunger || 0), 0) / alive.length : 0; - const environment = this.environmentScores ? this.environmentScores() : { hygiene: 100 }; - const fights = alive.filter(t => t.state === "fight" || (t.fightTimer || 0) > 0.04 || t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04).length; - const corpseCount = (this.itemCounts?.ant_corpse || 0) + Math.max(0, Math.round((this.deadCount || 0) * 0.5)); - const slaveRatio = alive.length ? alive.filter(t => t.isZunchiSlave).length / alive.length : 0; - const candidates = []; - if (alive.length && avgStress <= 28 && avgHunger <= 34) candidates.push("calm"); - if (fights >= 2 || corpseCount >= 4) candidates.push("prickly"); - if ((environment.hygiene || 0) <= 42) candidates.push("stinky"); - if (alive.length && slaveRatio >= 0.45) candidates.push("weaklings"); - if ((this.itemCounts?.sweet || 0) >= 30) candidates.push("zunda_party"); - if ((this.itemCounts?.ball || 0) >= 10) candidates.push("ball_party"); - if (alive.some(t => t.state === "sunbath" || (t.sunbathTimer || 0) > 0.1)) candidates.push("sun_party"); - if ((this.itemCounts?.ant_nest || 0) >= 1) candidates.push("ant_overrun"); - const nextId = candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : "relaxed"; - this.colonyMood = this.colonyMoodDefinition(nextId); - this.lastColonyMoodDay = this.day || 1; - this.log?.(`\u4eca\u65e5\u306e\u30b3\u30ed\u30cb\u30fc\u306e\u96f0\u56f2\u6c17\u306f\u300c${this.colonyMood.label}\u300d\u3002`, "event"); - return this.colonyMood; - }, - observeHighlightKind(t) { const s = this.selected; if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; @@ -197,56 +107,6 @@ return conflicts; }, - 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 }; - }, - - - - pointerOwnedBedInfo() { - 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 !== "grass_bed") continue; - const d = distXY(p.x, p.y, it.x, it.y); - if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; } - } - if (!best) return null; - const owner = this.liveTarinaiById?.(best.ownerId) || null; - return { bed: best, owner }; - }, - - 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)) { @@ -272,146 +132,9 @@ }, update(dt) { - if (this.paused) return; - dt *= this.speed; - dt = Math.min(dt, 0.12 * this.speed); - - const prevTime = this.time; - this.time += dt; - const previousDay = this.day || 1; - this.day = Math.floor(this.time / CONFIG.dayLength) + 1; - const prevHour = this.hourOfDay ? this.hourOfDay(prevTime) : 0; - const nowHour = this.hourOfDay ? this.hourOfDay(this.time) : 0; - const crossedColonyEvalHour = (this.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); - const pendingColonyMoodEvaluation = crossedColonyEvalHour && this.lastColonyMoodDay !== this.day; - if (this.day !== previousDay) { - for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} }; - } - if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4); - this.shakeTimer = Math.max(0, (this.shakeTimer || 0) - dt); - if ((this.shakeTimer || 0) <= 0) { this.shakeDuration = 0; this.shakeStrength = 0; } - this.updateWeather(dt); - this.foodSpoilagePenalty = Math.max(0, (this.foodSpoilagePenalty || 0) - dt * 0.010); - this.updateTerrainDirtyState?.(dt); - this.spatialRebuildsThisFrame = 0; - this.spatialDirtyMarksThisFrame = 0; - this.ensureSpatial?.("update-start"); - - this.beginFramePerformanceBudgets?.(); - const itemCountBefore = this.items.length; - updateItemsScheduled(this, dt); - const itemsMoved = markSpatialDirtyIfMoved(this, this.items, "items-moved", 0.25); - if (this.itemCounts?.ball) { - this.ensureSpatial?.("ball-ball-collisions"); - this.resolveBallBallCollisions(dt); - this.markSpatialDirty?.("ball-ball-collisions"); - } - this.updateAnts?.(dt); - const antsMoved = markSpatialDirtyIfMoved(this, this.ants || [], "ants-moved", 0.25); - if (itemsMoved || antsMoved || this.spatialDirty) this.ensureSpatial?.("post-mobile-item-update"); - - for (const ef of this.effects) ef.update(dt); - - for (const t of this.tarinai) { - t.update(dt); - } - const now = this.time || 0; - const bedConflictInterval = 3.2; - if (now >= (this.nextBedConflictCheckAt || 0)) { - this.nextBedConflictCheckAt = now + bedConflictInterval; - this.workStats && (this.workStats.bedConflictRuns = (this.workStats.bedConflictRuns || 0) + 1); - this.resolveOwnedBedSleepConflicts?.(); - } else { - this.workStats && (this.workStats.bedConflictSkips = (this.workStats.bedConflictSkips || 0) + 1); - } - markSpatialDirtyIfMoved(this, this.tarinai, "tarinai-moved", 0.25); - this.ensureSpatial?.("post-tarinai-update"); - this.limitBallChasers(); - this.resolveBallInteractions(dt); - const collisionInterval = 1.8; - if (now >= (this.nextTarinaiCollisionCheckAt || 0)) { - this.nextTarinaiCollisionCheckAt = now + collisionInterval; - this.workStats && (this.workStats.collisionRuns = (this.workStats.collisionRuns || 0) + 1); - this.resolveTarinaiHighSpeedCollisions(dt); - } else { - this.workStats && (this.workStats.collisionSkips = (this.workStats.collisionSkips || 0) + 1); - } - - this.compactTimer += dt; - const compactInterval = 2.5; - let itemsCompacted = false; - if (this.compactTimer >= compactInterval) { - itemsCompacted = this.compactItems(); - this.compactEffects(); - this.compactTarinai(); - this.compactAnts?.(); - this.compactTimer = 0; - } - - this.countsTimer += dt; - const countsInterval = 2.0; - if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) { - this.updateItemCounts(); - this.updateEffectCounts(); - this.countsTimer = 0; - } - - if ((this.outOfBoundsCheckAt || -999) + 5.0 <= this.time) { - this.outOfBoundsCheckAt = this.time; - this.sanitizeOutOfBounds(); - } - - this.drawSortTimer += dt; - const sortInterval = 0.25; - 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`); - } - - - if (Math.random() < dt * 0.109) { - const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { - allowOriginal: true, - minRadius: 28, - maxRadius: 140, - attempts: 36, - }); - if (spot) { - const sprout = new Item("grass", spot.x, spot.y); - sprout.amount = rand(30, 62); - sprout.growth = rand(0.18, 0.38); - if (this.addItem) this.addItem(sprout, "grass-spawn"); - else this.items.push(sprout); - } - } - - // Rain visuals are diagonal line streaks in render.js. This branch creates - // horizontal oval water items spawned by rain; it intentionally does not - // share the rain overlay renderer. - if (window.TarinaiWeatherSystem?.shouldDropRainWater?.(this, dt)) { - const drop = window.TarinaiWeatherSystem.createRainWaterItem(this); - this.addItem?.(drop, "rain-water") || this.items.push(drop); - this.emit?.("rain:itemDropped", { item: drop, type: "water" }); - } - - this.lastRuntimeDiagnostics = this.runtimeDiagnostics?.() || null; - + const phases = window.TarinaiWorldUpdatePhases; + if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available"); + phases.update(this, dt); }, sanitizeOutOfBounds() { diff --git a/js/world_update_phases.js b/js/world_update_phases.js new file mode 100644 index 0000000..55261f7 --- /dev/null +++ b/js/world_update_phases.js @@ -0,0 +1,429 @@ +"use strict"; + +(function (global) { + const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]); + const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); + const SCHEDULED_ITEM_TYPES = [ + "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", + "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", + "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", + "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "water_bowl", + ]; + + function forScheduledItems(worldRef, visitor) { + if (!worldRef || typeof visitor !== "function") return 0; + let count = 0; + if (!worldRef.itemsOfType) { + for (const it of worldRef.items || []) { + if (!it || it.dead || it.type === "grass") continue; + visitor(it); + count += 1; + } + return count; + } + for (const type of SCHEDULED_ITEM_TYPES) { + const bucket = worldRef.itemsOfType(type); + for (const it of bucket || []) { + if (!it || it.dead) continue; + visitor(it); + count += 1; + } + } + return count; + } + + function itemUpdateInterval(item) { + if (!item || item.dead) return Infinity; + if (REALTIME_ITEM_TYPES.has(item.type)) return 0; + if (PIN_ITEM_TYPES.has(item.type)) return 0; + if (item.isStructure && item.type === "plushie") return 0; + if (item.isStructure && item.type === "grass_bed") return 3.0; + if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0; + if (item.type === "zunchi") return 2.4; + if (item.type === "grass") return Infinity; + if (item.type === "duplicator") return 2.0; + if (item.type === "water") return 1.8; + if (item.type === "trace" || item.type === "splat") return 1.2; + if (item.type === "ant_corpse") return 3.5; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0; + return Infinity; + } + + function updateItemsScheduled(worldRef, dt) { + if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt); + let ran = 0; + forScheduledItems(worldRef, (it) => { + const interval = itemUpdateInterval(it); + if (!Number.isFinite(interval)) return; + if (interval <= 0) { + it.update(dt, worldRef); + ran += 1; + return; + } + it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt); + if (it._scheduledUpdateDt < interval) return; + const scheduledDt = it._scheduledUpdateDt; + it._scheduledUpdateDt = 0; + it.update(scheduledDt, worldRef); + ran += 1; + }); + return ran; + } + + function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) { + if (!worldRef || !Array.isArray(list) || !list.length) return false; + const thresholdSq = threshold * threshold; + let moved = false; + for (const entity of list) { + if (!entity || entity.dead) continue; + const x = Number.isFinite(entity.x) ? entity.x : 0; + const y = Number.isFinite(entity.y) ? entity.y : 0; + const oldX = entity._spatialTrackX; + const oldY = entity._spatialTrackY; + if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + continue; + } + const dx = x - oldX; + const dy = y - oldY; + if (dx * dx + dy * dy <= thresholdSq) continue; + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + } + if (moved) worldRef.markSpatialDirty?.(reason); + return moved; + } + + function markScheduledItemsMoved(worldRef, reason = "items-moved", threshold = 0.35) { + if (!worldRef) return false; + const thresholdSq = threshold * threshold; + let moved = false; + const tracked = global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef); + const source = tracked && tracked.length ? tracked : null; + const visit = (entity) => { + const x = Number.isFinite(entity.x) ? entity.x : 0; + const y = Number.isFinite(entity.y) ? entity.y : 0; + const oldX = entity._spatialTrackX; + const oldY = entity._spatialTrackY; + if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) { + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + return; + } + const dx = x - oldX; + const dy = y - oldY; + if (dx * dx + dy * dy <= thresholdSq) return; + entity._spatialTrackX = x; + entity._spatialTrackY = y; + moved = true; + }; + if (source) { + for (const entity of source) if (entity && !entity.dead) visit(entity); + } else { + forScheduledItems(worldRef, visit); + } + if (moved) worldRef.markSpatialDirty?.(reason); + return moved; + } + + function updateRandomGrassGrowth(worldRef, dt) { + if (!worldRef || !global.TarinaiGrass) return false; + if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false; + worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt; + const field = worldRef.fieldType || "garden"; + const growthSpeedMul = 2.0; + const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / growthSpeedMul; + if (worldRef.grassGrowthTimer < interval) return false; + worldRef.grassGrowthTimer = 0; + const grasses = worldRef.itemsOfType ? worldRef.itemsOfType("grass") : (worldRef.items || []).filter(it => it && it.type === "grass"); + if (!grasses || !grasses.length) return false; + const tries = Math.min(12, grasses.length); + for (let i = 0; i < tries; i++) { + const it = grasses[Math.floor(Math.random() * grasses.length)]; + if (!it || it.dead || (it.amount || 0) <= 0) continue; + if (worldRef.grassBlockedAt?.(it.x, it.y, it)) { + if (global.TarinaiGrass.regress(it, 1) > 0) { + worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-blocked") || worldRef.markTerrainDirty?.("random-grass-blocked"); + worldRef.markItemBucketsDirty?.("random-grass-blocked"); + return true; + } + continue; + } + if (global.TarinaiGrass.advance(it, 1)) { + worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-grow") || worldRef.markTerrainDirty?.("random-grass-grow"); + return true; + } + } + return false; + } + + function normalizeDelta(worldRef, dt) { + if (!worldRef || worldRef.paused) return null; + let frameDt = dt * worldRef.speed; + frameDt = Math.min(frameDt, 0.12 * worldRef.speed); + return frameDt; + } + + function updateClock(worldRef, dt) { + const prevTime = worldRef.time; + worldRef.time += dt; + const previousDay = worldRef.day || 1; + worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1; + const prevHour = worldRef.hourOfDay ? worldRef.hourOfDay(prevTime) : 0; + const nowHour = worldRef.hourOfDay ? worldRef.hourOfDay(worldRef.time) : 0; + const crossedColonyEvalHour = (worldRef.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); + const pendingColonyMoodEvaluation = crossedColonyEvalHour && worldRef.lastColonyMoodDay !== worldRef.day; + if (worldRef.day !== previousDay) { + for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} }; + } + return { prevTime, previousDay, prevHour, nowHour, crossedColonyEvalHour, pendingColonyMoodEvaluation }; + } + + function updateFrameSignals(worldRef, dt) { + if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4); + worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt); + if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; } + } + + function updateEnvironment(worldRef, dt) { + worldRef.updateWeather(dt); + worldRef.foodSpoilagePenalty = Math.max(0, (worldRef.foodSpoilagePenalty || 0) - dt * 0.010); + worldRef.updateTerrainDirtyState?.(dt); + } + + function prepareSpatialFrame(worldRef) { + worldRef.spatialRebuildsThisFrame = 0; + worldRef.spatialDirtyMarksThisFrame = 0; + worldRef.ensureSpatial?.("update-start"); + worldRef.beginFramePerformanceBudgets?.(); + } + + function updateItemsAndAnts(worldRef, dt) { + const itemCountBefore = worldRef.items.length; + updateItemsScheduled(worldRef, dt); + updateRandomGrassGrowth(worldRef, dt); + const itemsMoved = markScheduledItemsMoved(worldRef, "items-moved", 0.25); + if (worldRef.itemCounts?.ball) { + worldRef.ensureSpatial?.("ball-ball-collisions"); + worldRef.resolveBallBallCollisions(dt); + worldRef.markSpatialDirty?.("ball-ball-collisions"); + } + worldRef.updateAnts?.(dt); + const antsMoved = markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25); + if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update"); + return { itemCountBefore, itemsMoved, antsMoved }; + } + + function updateEffects(worldRef, dt) { + for (const ef of worldRef.effects) ef.update(dt); + } + + + function updateVisibleWorldRect(worldRef, margin = 120) { + 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 pointInRect(x, y, rect) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + } + + function tarinaiUpdateInterval(worldRef, tarinai, visibleRect) { + if (!tarinai || tarinai.dead) return Infinity; + if (worldRef.selected === tarinai) return 0; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0; + if (pointInRect(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0; + const count = (worldRef.tarinai || []).length; + const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; + if (tier === "high" && count <= 36) return 0; + const farRect = updateVisibleWorldRect(worldRef, 520); + return pointInRect(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70; + } + + function updateCreatures(worldRef, dt) { + const end = global.TarinaiPerf?.begin?.("update.tarinai") || null; + const visibleRect = updateVisibleWorldRect(worldRef, 160); + try { + for (const t of worldRef.tarinai) { + if (!t || t.dead) continue; + const interval = tarinaiUpdateInterval(worldRef, t, visibleRect); + if (!Number.isFinite(interval)) continue; + if (interval <= 0) { + const runDt = Math.min(1.4, (t._updateAccum || 0) + dt); + t._updateAccum = 0; + t.update(runDt); + continue; + } + t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt); + t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7); + if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) { + const runDt = Math.max(dt, t._updateAccum || dt); + t._updateAccum = 0; + t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval; + t.update(runDt); + } + } + } finally { + if (end) end(); + } + const now = worldRef.time || 0; + const bedConflictInterval = 3.2; + if (now >= (worldRef.nextBedConflictCheckAt || 0)) { + worldRef.nextBedConflictCheckAt = now + bedConflictInterval; + worldRef.workStats && (worldRef.workStats.bedConflictRuns = (worldRef.workStats.bedConflictRuns || 0) + 1); + worldRef.resolveOwnedBedSleepConflicts?.(); + } else { + worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1); + } + markSpatialDirtyIfMoved(worldRef, worldRef.tarinai, "tarinai-moved", 0.25); + worldRef.ensureSpatial?.("post-tarinai-update"); + worldRef.limitBallChasers(); + worldRef.resolveBallInteractions(dt); + const collisionInterval = 1.8; + if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { + worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; + worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); + worldRef.resolveTarinaiHighSpeedCollisions(dt); + } else { + worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); + } + } + + function runMaintenance(worldRef, dt, itemCountBefore) { + worldRef.compactTimer += dt; + const compactInterval = 2.5; + let itemsCompacted = false; + if (worldRef.compactTimer >= compactInterval) { + itemsCompacted = worldRef.compactItems(); + worldRef.compactEffects(); + worldRef.compactTarinai(); + worldRef.compactAnts?.(); + worldRef.compactTimer = 0; + } + + worldRef.countsTimer += dt; + const countsInterval = 2.0; + if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) { + worldRef.updateItemCounts(); + worldRef.updateEffectCounts(); + worldRef.countsTimer = 0; + } + if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) { + worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0; + const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0; + if (removedGrass > 0) { + worldRef.compactItems(); + worldRef.updateItemCounts(); + } + } + + if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) { + worldRef.outOfBoundsCheckAt = worldRef.time; + worldRef.sanitizeOutOfBounds(); + } + + worldRef.drawSortTimer += dt; + const sortInterval = 0.25; + if (worldRef.drawSortTimer >= sortInterval) { + worldRef.drawListDirty = true; + worldRef.drawSortTimer = 0; + } + } + + function emitPhaseChange(worldRef) { + const phase = worldRef.phaseName(); + if (phase !== worldRef.lastPhase) { + worldRef.lastPhase = phase; + worldRef.emit("world:phase", { world: worldRef, phase }); + } + } + + function spawnAmbientTarinai(worldRef, dt) { + if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) { + const spot = worldRef.edgeSpawnPoint(null, null, 42, true); + const t = worldRef.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); + worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); + } + } + + function spawnAmbientGrass(worldRef, dt) { + if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return; + if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.045) { + const spot = worldRef.findGrassPlantingSpot(rand(60, worldRef.w - 60), rand(60, worldRef.h - 60), { + allowOriginal: true, + minRadius: 28, + maxRadius: 140, + attempts: 18, + }); + if (spot) { + const sprout = new Item("grass", spot.x, spot.y); + global.TarinaiGrass?.setStage?.(sprout, 0, { force: true }); + if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn"); + else worldRef.items.push(sprout); + } + } + } + + function spawnRainWater(worldRef, dt) { + // Rain visuals are diagonal line streaks in render.js. This branch creates + // horizontal oval water items spawned by rain; it intentionally does not + // share the rain overlay renderer. + if (global.TarinaiWeatherSystem?.shouldDropRainWater?.(worldRef, dt)) { + const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef); + worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop); + worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" }); + } + } + + function finalizeDiagnostics(worldRef) { + worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null; + } + + function update(worldRef, dt) { + const frameDt = normalizeDelta(worldRef, dt); + if (frameDt === null) return; + updateClock(worldRef, frameDt); + updateFrameSignals(worldRef, frameDt); + updateEnvironment(worldRef, frameDt); + prepareSpatialFrame(worldRef); + const mobile = updateItemsAndAnts(worldRef, frameDt); + updateEffects(worldRef, frameDt); + updateCreatures(worldRef, frameDt); + runMaintenance(worldRef, frameDt, mobile.itemCountBefore); + emitPhaseChange(worldRef); + spawnAmbientTarinai(worldRef, frameDt); + spawnAmbientGrass(worldRef, frameDt); + spawnRainWater(worldRef, frameDt); + finalizeDiagnostics(worldRef); + } + + global.TarinaiWorldUpdatePhases = Object.freeze({ + update, + phases: Object.freeze({ + normalizeDelta, + updateClock, + updateFrameSignals, + updateEnvironment, + prepareSpatialFrame, + updateItemsAndAnts, + updateEffects, + updateCreatures, + runMaintenance, + emitPhaseChange, + spawnAmbientTarinai, + spawnAmbientGrass, + spawnRainWater, + finalizeDiagnostics, + }), + }); +})(window); diff --git a/js/world_view.js b/js/world_view.js index 1122516..4d84fcb 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -45,19 +45,49 @@ this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1; this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1; this.lastSpatialDirtyReason = reason; + const r = String(reason || "manual"); + if (/tarinai-moved|post-tarinai|creature/i.test(r)) { + this.spatialTarinaiDirty = true; + } else if (/ants?-moved|post-ant|ant/i.test(r) && !/ant_nest|ant-nest/i.test(r)) { + this.spatialAntDirty = true; + } else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi/i.test(r)) { + this.spatialDynamicItemsDirty = true; + } else { + this.spatialStaticItemsDirty = true; + this.spatialDynamicItemsDirty = true; + this.spatialTarinaiDirty = true; + this.spatialAntDirty = true; + } }, markTerrainDirty(reason = "manual") { this.terrainDirty = true; + this.terrainDirtyGlobal = true; this.terrainDirtyReason = reason; this.terrainVersion = (this.terrainVersion || 0) + 1; this.terrainLastDirtyAt = this.time || 0; - this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion }); + this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion, global: true }); + }, + + markTerrainDirtyAt(x, y, radius = 48, reason = "manual") { + const cx = Math.floor((Number(x) || 0) / 256); + const cy = Math.floor((Number(y) || 0) / 256); + const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256)); + if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set(); + for (let yy = cy - cr; yy <= cy + cr; yy++) { + for (let xx = cx - cr; xx <= cx + cr; xx++) this.terrainDirtyChunks.add(`${xx}:${yy}`); + } + 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, global: false, x, y, radius }); + return true; }, terrainSignature() { const counts = this.itemCounts || {}; - return `${this.fieldType || "garden"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`; + return `${this.fieldType || "garden"}:${this.groundType || "soil"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`; }, updateTerrainDirtyState(dt = 0) { @@ -90,6 +120,45 @@ return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden; }, + groundDefinition() { + return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "土", stressMultiplier: 1, grassMultiplier: 1 }; + }, + + groundLabel() { + return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "土"; + }, + + groundStressMultiplier() { + return window.TarinaiGround?.stressMultiplier?.(this.groundType || "soil") || 1; + }, + + groundGrassMultiplier() { + return window.TarinaiGround?.grassMultiplier?.(this.groundType || "soil") ?? 1; + }, + + setGroundType(id = "soil", opts = {}) { + const next = window.TarinaiGround?.exists?.(id) ? id : "soil"; + const previous = this.groundType || "soil"; + this.groundType = next; + if (next !== previous || opts.force) { + this.markTerrainDirty?.("ground-type"); + this.enforceGrassLimit?.("ground-type"); + this.updateItemCounts?.("ground-type"); + this.drawListDirty = true; + if (!opts.silent) { + const label = this.groundLabel?.() || "土"; + this.log?.(`じめん: ${label}`); + if (typeof showToast === "function") showToast(`じめんを ${label} にしました。`); + } + this.emit?.("ground:changed", { previous, next, definition: this.groundDefinition?.() }); + } + return this.groundDefinition?.(); + }, + + cycleGroundType() { + return this.setGroundType(window.TarinaiGround?.nextId?.(this.groundType || "soil") || "soil"); + }, + fieldWorldScale() { const field = this.fieldDefinition(); // Field type defines the logical simulation area. Wheel zoom must not @@ -100,13 +169,6 @@ 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; }, @@ -151,8 +213,8 @@ }, 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. + // Hit tests use logical world units here, so this adapter intentionally + // returns the supplied size unchanged. return size; }, diff --git a/scripts/generate_item_icons.py b/scripts/generate_item_icons.py index a124765..881baf5 100644 --- a/scripts/generate_item_icons.py +++ b/scripts/generate_item_icons.py @@ -11,14 +11,14 @@ from PIL import Image, ImageDraw ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "assets" / "ui" -REGISTRY = ROOT / "js" / "item_visual_registry.js" +REGISTRY = ROOT / "js" / "item_registry.js" def visual_registry() -> dict: text = REGISTRY.read_text(encoding="utf-8") match = re.search(r"String\.raw`([\s\S]*?)`", text) if not match: - raise RuntimeError("Could not find JSON visual registry in js/item_visual_registry.js") + raise RuntimeError("Could not find JSON visual registry in js/item_registry.js") return json.loads(match.group(1)) diff --git a/scripts/regression_check.py b/scripts/regression_check.py index a85180a..d5c05a4 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -3,7 +3,7 @@ Checks static-file consistency, generated-load order, syntax, and the ActionSpec registry contract. This intentionally avoids save-data migration checks; save -compatibility was intentionally dropped in compact save v2. +compatibility is intentionally not retained for save format changes. """ from __future__ import annotations @@ -149,15 +149,17 @@ vm.runInContext({source!r}, context, {{ filename: 'tarinai_behavior_state.js' }} const t = new context.Tarinai(); t.world = {{ time: 12 }}; t.state = 'seek_food'; -context.setTarinaiBehavior(t, {{ id: 'eat_food', need: 'food', label: 'eat', reasonText: 'hungry', tiedNeeds: ['food', 'sleep'], forced: true, forcedId: 'f1', source: 'test', forcedRequest: {{ uid: 'f1', id: 'eat_food', source: 'test', priority: 99, reasonText: 'forced hungry', status: 'active' }} }}); +context.setTarinaiBehavior(t, {{ actionId: 'eat_food', need: 'food', label: 'eat', reason: 'hungry', text: 'hungry', tiedNeeds: ['food', 'sleep'], source: 'forced', forcedId: 'f1', forcedRequest: {{ uid: 'f1', id: 'eat_food', source: 'test', priority: 99, reasonText: 'forced hungry', status: 'active' }} }}); if (context.getTarinaiBehaviorId(t) !== 'eat_food') throw new Error('behavior id mismatch'); if (context.getTarinaiBehaviorNeed(t) !== 'food') throw new Error('behavior need mismatch'); -if (!context.isTarinaiBehaviorForced(t)) throw new Error('forced flag not visible'); +if (!context.isTarinaiBehaviorForced(t)) throw new Error('forced state not visible'); +const behavior = context.currentTarinaiBehavior(t); +if (!behavior || behavior.actionId !== 'eat_food' || behavior.reason !== 'hungry' || behavior.source !== 'forced') throw new Error('canonical behavior fields not normalized'); +if ('intent' in t || 'currentAction' in t || 'activeBehavior' in t || 'activeForcedBehavior' in t) throw new Error('legacy behavior accessors still present'); const forced = context.getTarinaiForcedBehavior(t); if (!forced || forced.uid !== 'f1' || forced.priority !== 99) throw new Error('forced request not normalized'); -if (t.intent.actionId !== 'eat_food' || t.currentAction.need !== 'food' || t.activeForcedBehavior.uid !== 'f1') throw new Error('legacy views not synced'); context.setTarinaiForcedBehavior(t, null); -if (context.currentTarinaiBehavior(t).forced || context.getTarinaiForcedBehavior(t)) throw new Error('forced flag not cleared'); +if (context.currentTarinaiBehavior(t).source === 'forced' || context.getTarinaiForcedBehavior(t)) throw new Error('forced source not cleared'); context.clearTarinaiBehavior(t); if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cleared'); """ @@ -176,25 +178,24 @@ if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cl def check_legacy_behavior_refs() -> None: - allowed = {"js/tarinai.js", "js/tarinai_behavior_state.js"} tokens = re.compile(r"\b(activeBehavior|currentAction|activeForcedBehavior|intent)\b") offenders = [] for path in (ROOT / "js").glob("*.js"): - rel = path.relative_to(ROOT).as_posix() - if rel in allowed: - continue for i, line in enumerate(read_text(path).splitlines(), start=1): - if tokens.search(line) and "intentLockTimer" not in line and "//" not in line: - offenders.append(f"{rel}:{i}") + stripped = line.strip() + if stripped.startswith("//"): + continue + if tokens.search(line): + offenders.append(f"{path.relative_to(ROOT).as_posix()}:{i}") if offenders: - fail("legacy behavior fields outside compatibility layer: " + ", ".join(offenders[:20])) - ok("legacy behavior fields isolated to compatibility layer") + fail("legacy behavior fields still present: " + ", ".join(offenders[:20])) + ok("legacy behavior runtime fields removed") -def check_basic_action_spec_migration() -> None: - source = read_text(ROOT / "js/tarinai_needs_items.js") +def check_basic_action_spec_runtime() -> None: + source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"]) required = { "eat_food": "createConsumableActionSpec", "drink_water": "createConsumableActionSpec", @@ -218,12 +219,12 @@ def check_basic_action_spec_migration() -> None: offenders = [token for token in forbidden_late_ticks if token in source] if offenders: fail("basic actions still patched in configureTarinaiNeedActions: " + ", ".join(offenders)) - ok("basic need actions migrated into ActionSpec factories") + ok("basic need actions owned by ActionSpec factories") -def check_social_action_spec_migration() -> None: - source = read_text(ROOT / "js/tarinai_needs_items.js") +def check_social_action_spec_runtime() -> None: + source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"]) required_factories = [ "createPanicEscapeActionSpec", "createFleeActionSpec", @@ -245,7 +246,7 @@ def check_social_action_spec_migration() -> None: if f"{factory}()" not in definitions: fail(f"{factory} is not registered in TARINAI_ACTION_DEFINITIONS") if "function configureTarinaiNeedActions" in source: - fail("configureTarinaiNeedActions should not be needed after ActionSpec runtime migration") + fail("configureTarinaiNeedActions should not be needed after ActionSpec runtime consolidation") forbidden_late_ticks = [ 'set("panic_escape",', 'set("flee",', @@ -261,22 +262,49 @@ def check_social_action_spec_migration() -> None: offenders = [token for token in forbidden_late_ticks if token in source] if offenders: fail("actions still patched late: " + ", ".join(offenders)) - ok("social/fear/combat/mate/birth/sunbath actions migrated into ActionSpec factories") + ok("social/fear/combat/mate/birth/sunbath actions owned by ActionSpec factories") + + +def check_action_spec_no_legacy_hooks() -> None: + source = read_text(ROOT / "js/tarinai_action_spec.js") + forbidden = ["condition", "originalRun", "originalUpdate", "Legacy hooks"] + offenders = [token for token in forbidden if token in source] + if offenders: + fail("ActionSpec legacy hook support still present: " + ", ".join(offenders)) + needs = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_consumable_behavior.js", "js/tarinai_social_action_runtime.js", "js/tarinai_building_behavior.js", "js/tarinai_needs_items.js"]) + if ".condition" in needs or "action.condition" in needs: + fail("need action runtime still falls back to action.condition") + ok("ActionSpec legacy hooks removed") def check_runtime_diagnostics_hooks() -> None: spatial_budget = read_text(ROOT / "js/world_spatial_budget.js") debug_tools = read_text(ROOT / "js/debug_tools.js") world_update = read_text(ROOT / "js/world_update.js") + world_update_phases = read_text(ROOT / "js/world_update_phases.js") if "runtimeDiagnostics()" not in spatial_budget: fail("World.runtimeDiagnostics hook missing") - if "lastRuntimeDiagnostics" not in world_update: - fail("world update does not publish lastRuntimeDiagnostics") + if "lastRuntimeDiagnostics" not in (world_update + world_update_phases): + fail("world update phases do not publish lastRuntimeDiagnostics") if "spatial rebuild" not in debug_tools or "behavior forced" not in debug_tools: fail("debug overlay is missing runtime diagnostic lines") + spatial_budget = read_text(ROOT / "js/world_spatial_budget.js") + debug_tools = read_text(ROOT / "js/debug_tools.js") + if "performance:" in spatial_budget or "diag.performance" in debug_tools or "load-check removed" in debug_tools: + fail("removed performance/load-check diagnostics residue remains") ok("runtime diagnostics hooks present") def check_social_balance_guards() -> None: - needs = read_text(ROOT / "js/tarinai_needs_items.js") + needs = "\n".join(read_text(ROOT / rel) for rel in [ + "js/tarinai_needs_core.js", + "js/tarinai_behavior_text.js", + "js/tarinai_forced_behavior.js", + "js/tarinai_item_targeting.js", + "js/tarinai_action_definitions.js", + "js/tarinai_consumable_behavior.js", + "js/tarinai_social_action_runtime.js", + "js/tarinai_building_behavior.js", + "js/tarinai_needs_items.js", + ]) world_social = read_text(ROOT / "js/world_family_social.js") social_move = read_text(ROOT / "js/tarinai_social_move_life.js") required_need_tokens = [ @@ -331,11 +359,11 @@ def check_item_bucket_polish() -> None: fail("debug overlay is missing item bucket/id-map diagnostics") routed_files = { "js/world_placement_log.js": "addItem?.(item", - "js/world_update.js": "addItem?.(drop", + "js/world_update_phases.js": "addItem?.(drop", "js/world_combat_effects.js": "addItem?.(it", "js/ants.js": "addItem?.(corpse", "js/tarinai_social_move_life.js": "addItem?.(new Item(\"trace\"", - "js/tarinai_needs_items.js": "addItem?.(structure", + "js/tarinai_building_behavior.js": "addItem?.(structure", } for rel, token in routed_files.items(): if token not in read_text(ROOT / rel): @@ -343,11 +371,14 @@ def check_item_bucket_polish() -> None: ok("runtime item bucket/id-map polish present") -def check_compact_save_v2() -> None: +def check_current_save_format() -> None: snapshot = read_text(ROOT / "js/snapshot_system.js") save = read_text(ROOT / "js/save_system.js") + save_codec = read_text(ROOT / "js/save_codec.js") + save_storage = read_text(ROOT / "js/save_storage.js") + restore_coordinator = read_text(ROOT / "js/restore_coordinator.js") required_snapshot = [ - "const SNAPSHOT_VERSION = 2", + "const SNAPSHOT_VERSION = 3", "function compactWorld", "function compactTarinai", "function compactItem", @@ -358,7 +389,7 @@ def check_compact_save_v2() -> None: ] for token in required_snapshot: if token not in snapshot: - fail(f"compact save v2 snapshot token missing: {token}") + fail(f"current save snapshot token missing: {token}") forbidden_snapshot = [ "logs: clonePlain", "effects: (worldRef.effects", @@ -368,9 +399,8 @@ def check_compact_save_v2() -> None: offenders = [token for token in forbidden_snapshot if token in snapshot] if offenders: fail("old broad snapshot fields still present: " + ", ".join(offenders)) - required_save = [ - 'STORAGE_PREFIX = "tarinai_save_slot_v2_"', - 'EXPORT_PREFIX = "TN2!"', + required_codec = [ + 'EXPORT_PREFIX = "TN3!"', "SAVE_TEXT_ALPHABET", "base91Encode", "base91Decode", @@ -379,28 +409,202 @@ def check_compact_save_v2() -> None: "async function encodeSnapshot", "async function decodeSnapshot", ] - for token in required_save: - if token not in save: - fail(f"compact save v2 encoder token missing: {token}") - if "TARINAI_SAVE_V1" in save or "tarinai_save_slot_v1" in save: - fail("old save v1 prefix still present") - ok("compact save v2 snapshot and encoder present") + for token in required_codec: + if token not in save_codec: + fail(f"current save codec token missing: {token}") + required_storage = [ + 'STORAGE_PREFIX = "tarinai_save_slot_v3_"', + "function writeSlot", + "function readSlot", + "function requireSlot", + "function deleteSlot", + ] + for token in required_storage: + if token not in save_storage: + fail(f"current save storage token missing: {token}") + required_facade = [ + "TarinaiSaveSystem", + "TarinaiSaveCodec", + "TarinaiSaveStorage", + "TarinaiRestoreCoordinator", + ] + combined = save + save_codec + save_storage + restore_coordinator + for token in required_facade: + if token not in combined: + fail(f"save/restore split facade token missing: {token}") + for token in ("TN2!", "tarinai_save_slot_v2_", "legacyForcedColumn"): + if token in combined or token in snapshot: + fail(f"save backward compatibility residue still present: {token}") + if "renderStats" in snapshot or "TarinaiFreezeSystem" in snapshot or "renderArchive" in snapshot: + fail("snapshot restore must not call UI or freeze subsystems directly") + freeze = read_text(ROOT / "js/freeze_system.js") + if "global.renderStats" in freeze or "global.render?.()" in freeze or "renderFrozenPanel(worldRef);" in freeze: + fail("freeze core still performs direct UI rendering instead of emitting freeze:changed") + effects = read_text(ROOT / "js/world_event_effects.js") + if "freeze:changed" not in effects or "tool:placed" not in effects or "function playLogSoundForEntry" not in effects: + fail("event effects do not own freeze/tool/log side effects") + placement = read_text(ROOT / "js/world_placement_log.js") + if "playLogSoundForEntry" in placement: + fail("log sound mapping still lives in world_placement_log.js") + ok("current save snapshot, codec, storage, and restore coordinator present") + +def check_unified_item_registry() -> None: + data = manifest() + js_files = data["js"] + if "js/item_registry.js" not in js_files: + fail("unified item registry missing from manifest") + removed = ["js/food_registry.js", "js/effect_registry.js", "js/item_visual_registry.js"] + stale_manifest = [rel for rel in removed if rel in js_files] + if stale_manifest: + fail("old split item registry files still listed in manifest: " + ", ".join(stale_manifest)) + stale_files = [rel for rel in removed if (ROOT / rel).exists()] + if stale_files: + fail("old split item registry files still exist: " + ", ".join(stale_files)) + source = read_text(ROOT / "js/item_registry.js") + required = [ + "global.TarinaiItemRegistry", + "const TOOL_DEFINITIONS", + "function itemDefinition", + "function itemVisualDefinition", + "class FoodRegistry", + "class EffectRegistry", + "food: FOOD_REGISTRY", + "effect: EFFECT_REGISTRY", + "visual: itemVisualDefinition", + ] + for token in required: + if token not in source: + fail(f"unified item registry token missing: {token}") + forbidden_globals = ["global.TarinaiFoodRegistry", "global.TarinaiEffectRegistry", "global.TARINAI_ITEM_VISUALS", "global.TARINAI_FOOD_DEFINITIONS", "global.TARINAI_EFFECT_DEFINITIONS"] + offenders = [token for token in forbidden_globals if token in source] + if offenders: + fail("old split item registry globals still exported: " + ", ".join(offenders)) + data_source = read_text(ROOT / "js/data.js") + forbidden_data = ["const TOOL_DEFINITIONS", "const ITEM_TRAITS", "class FoodRegistry", "class EffectRegistry", "TARINAI_ITEM_VISUALS"] + offenders = [token for token in forbidden_data if token in data_source] + if offenders: + fail("item definitions still mixed into data.js: " + ", ".join(offenders)) + ok("item definitions centralized in item_registry.js") + + +def check_docs_removed() -> None: + docs = ROOT / "docs" + if docs.exists() and any(docs.iterdir()): + fail("non-runtime docs directory still contains files") + ok("non-runtime docs removed") + +def check_phase_and_input_split() -> None: + data = manifest() + js_files = data["js"] + required = [ + "js/world_update_phases.js", + "js/ui_input_shared.js", + "js/ui_input_touch.js", + "js/ui_input_mouse.js", + ] + for rel in required: + if rel not in js_files: + fail(f"split runtime module missing from manifest: {rel}") + world_update = read_text(ROOT / "js/world_update.js") + phases = read_text(ROOT / "js/world_update_phases.js") + ui_bind = read_text(ROOT / "js/ui_bind.js") + touch = read_text(ROOT / "js/ui_input_touch.js") + mouse = read_text(ROOT / "js/ui_input_mouse.js") + shared = read_text(ROOT / "js/ui_input_shared.js") + if "TarinaiWorldUpdatePhases" not in world_update or "function update(worldRef, dt)" not in phases: + fail("World.update is not delegated to world_update_phases.js") + for token in ["updateClock", "updateEnvironment", "updateItemsAndAnts", "updateCreatures", "runMaintenance", "spawnRainWater"]: + if token not in phases: + fail(f"world update phase missing: {token}") + if "touchstart" in ui_bind or "mousedown" in ui_bind or "touchmove" in ui_bind: + fail("ui_bind.js still owns field pointer input handlers") + for token in ["TarinaiUIInputShared", "createInputContext"]: + if token not in shared: + fail(f"shared input helper missing: {token}") + if "touchstart" not in touch or "TarinaiTouchInput" not in touch: + fail("touch input module missing touch handlers") + if "mousedown" not in mouse or "TarinaiMouseInput" not in mouse: + fail("mouse input module missing mouse handlers") + if ui_bind.count("syncToolSizeBadges();") != 1: + fail("ui_bind.js should bind/sync tool size badges only once") + phase_birth_direct = "audio.birth" in phases + placement_birth_direct = "audio.birth" in read_text(ROOT / "js/world_placement_log.js") + family_birth_direct = "audio.birth" in read_text(ROOT / "js/world_family_social.js") + if phase_birth_direct or placement_birth_direct or family_birth_direct: + fail("birth log sound can double-fire; direct audio.birth remains in log-emitting birth flows") + ok("world phase split and UI input split present") + + +def check_processing_split() -> None: + data = manifest() + js_files = data["js"] + required = [ + "js/item_type_initializers.js", + "js/item_lifecycle_runtime.js", + "js/item_render_runtime.js", + "js/tarinai_item_targeting.js", + "js/tarinai_action_definitions.js", + "js/tarinai_consumable_behavior.js", + "js/tarinai_social_action_runtime.js", + "js/tarinai_building_behavior.js", + ] + for rel in required: + if rel not in js_files: + fail(f"processing split module missing from manifest: {rel}") + if not (js_files.index("js/items.js") < js_files.index("js/item_type_initializers.js") < js_files.index("js/item_lifecycle_runtime.js") < js_files.index("js/item_render_runtime.js") < js_files.index("js/ants.js")): + fail("item init/runtime/render split load order is invalid") + if not (js_files.index("js/tarinai_forced_behavior.js") < js_files.index("js/tarinai_item_targeting.js") < js_files.index("js/tarinai_consumable_behavior.js") < js_files.index("js/tarinai_social_action_runtime.js") < js_files.index("js/tarinai_building_behavior.js") < js_files.index("js/tarinai_action_definitions.js") < js_files.index("js/tarinai_needs_items.js")): + fail("tarinai behavior split load order is invalid") + items = read_text(ROOT / "js/items.js") + init = read_text(ROOT / "js/item_type_initializers.js") + lifecycle = read_text(ROOT / "js/item_lifecycle_runtime.js") + item_render = read_text(ROOT / "js/item_render_runtime.js") + if "class Item" not in items or "function initializeItemTypeState" not in init or "Object.assign(Item.prototype" not in lifecycle or "Object.assign(Item.prototype" not in item_render: + fail("item construction/init/lifecycle/render split is incomplete") + if "updateDuplicator" in items or "\ndraw(ctx" in items or "if (type === \"grass\")" in items: + fail("items.js still owns type init, runtime, or render methods") + defs = read_text(ROOT / "js/tarinai_action_definitions.js") + consumable = read_text(ROOT / "js/tarinai_consumable_behavior.js") + social = read_text(ROOT / "js/tarinai_social_action_runtime.js") + building = read_text(ROOT / "js/tarinai_building_behavior.js") + needs = read_text(ROOT / "js/tarinai_needs_items.js") + required_pairs = [ + (defs, "const TARINAI_ACTION_DEFINITIONS"), + (defs, "const TARINAI_ACTIONS"), + (consumable, "function updateConsumableBehavior"), + (social, "function updateMateBehavior"), + (social, "function updatePanicBehavior"), + (building, "function continueBuildPlan"), + (needs, "function chooseActionForNeed"), + ] + for source, token in required_pairs: + if token not in source: + fail(f"processing split token missing: {token}") + for token in ["createConsumableActionSpec", "function updateConsumableBehavior", "function continueBuildPlan"]: + if token in needs: + fail(f"tarinai_needs_items.js still owns extracted processing: {token}") + ok("item and Tarinai processing split present") def main() -> None: data = manifest() check_files_exist(data) + check_unified_item_registry() + check_docs_removed() check_versions(data) check_index_order(data) check_service_worker_order(data) node_check([*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js"]) action_spec_smoke() behavior_state_smoke() - check_basic_action_spec_migration() - check_social_action_spec_migration() + check_basic_action_spec_runtime() + check_social_action_spec_runtime() + check_action_spec_no_legacy_hooks() check_runtime_diagnostics_hooks() check_social_balance_guards() check_item_bucket_polish() - check_compact_save_v2() + check_current_save_format() + check_phase_and_input_split() + check_processing_split() check_legacy_behavior_refs() diff --git a/service-worker.js b/service-worker.js index 8f1f946..2d7c039 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "16.00.01"; +const APP_VERSION = "16.07.09"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. const coreScriptNames = [ - "version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "render", "sim_core", "structures", "items", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" + "version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "perf_profiler", "render", "sim_core", "structures", "items", "item_type_initializers", "item_lifecycle_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "world_update_phases", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "restore_coordinator", "save_codec", "save_storage", "save_system", "patch_helpers", "freeze_store", "freeze_snapshot_adapter", "freeze_panel", "freeze_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" ]; const CORE_ASSETS = [ "./",