diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c69165 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.tmp-edge-profile/ +__pycache__/ +*.pyc + +# Local browser smoke/probe artifacts. +*probe*.html +*probe*.png +*smoke*.html +*smoke*.png diff --git a/.tmp-edge-profile/Crashpad/metadata b/.tmp-edge-profile/Crashpad/metadata deleted file mode 100644 index e69de29..0000000 diff --git a/.tmp-edge-profile/Crashpad/settings.dat b/.tmp-edge-profile/Crashpad/settings.dat deleted file mode 100644 index e76f11a..0000000 Binary files a/.tmp-edge-profile/Crashpad/settings.dat and /dev/null differ diff --git a/.tmp-edge-profile/Crashpad/throttle_store.dat b/.tmp-edge-profile/Crashpad/throttle_store.dat deleted file mode 100644 index 9639e1b..0000000 --- a/.tmp-edge-profile/Crashpad/throttle_store.dat +++ /dev/null @@ -1 +0,0 @@ -level=none expiry=0 diff --git a/.tmp-edge-profile/Variations b/.tmp-edge-profile/Variations deleted file mode 100644 index a157215..0000000 --- a/.tmp-edge-profile/Variations +++ /dev/null @@ -1 +0,0 @@ -{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0} \ No newline at end of file diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md deleted file mode 100644 index c17894a..0000000 --- a/PATCH_NOTES.md +++ /dev/null @@ -1,17 +0,0 @@ -# Patch Notes 15.24.12 - -- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers. -- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs. -- Re-routed love mochi, fight mochi, and sleep drug effects through forced need actions. -- Converted eating, panic, fighting, intimidation, and birth ritual display/scheduling to behavior-backed actions. -- Split social need pressure internally into bond/family/mate/conflict/fear parts and used those parts in social action selection. -- Weighted fulfill action selection by its internal reason parts. -- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced. -- UI action text now prefers `activeBehavior` over stale thoughts or intent strings. - -## 15.24.14 -- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。 -- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。 -- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。 -- 睡眠欲求を概日圧 `circadianSleepPressure` 中心に変更。夜は大きく上昇、昼下がりは微増、それ以外は減衰。 -- 初期生成時、夜なら睡眠欲求が高めになるよう初期エネルギーと概日圧を調整。 diff --git a/README.md b/README.md deleted file mode 100644 index 7a87766..0000000 --- a/README.md +++ /dev/null @@ -1,51 +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/ants.js b/js/ants.js index fbe5b36..eaf8e79 100644 --- a/js/ants.js +++ b/js/ants.js @@ -94,7 +94,8 @@ class AntActor { } homeNest() { - return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null; + const nest = this.world?.itemById?.(this.homeId) || null; + return nest && nest.type === "ant_nest" ? nest : null; } adoptableNest() { @@ -165,7 +166,8 @@ class AntActor { // filter or rank candidates here while preserving sleep-disease hauling. let best = null; let bestScore = Infinity; - for (const t of this.world?.tarinai || []) { + const candidates = this.world?.nearbyTarinai?.(this.x, this.y, maxDist, true) || this.world?.tarinai || []; + for (const t of candidates) { if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue; const d = distXY(this.x, this.y, t.x, t.y); if (d > maxDist) continue; @@ -344,10 +346,10 @@ class AntActor { const corpse = new Item("ant_corpse", this.x, this.y); corpse.amount = 24; corpse.workerSprite = this.workerSpriteId(); - this.world.items.push(corpse); + this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse); this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" })); if (home) syncAntNestCount(this.world, home); - this.world.updateItemCounts?.(); + this.world.ensureItemBuckets?.("ant-corpse"); this.world.drawListDirty = true; } @@ -370,11 +372,9 @@ class AntActor { base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT); if (!force && this.world.placementBlocked?.(base)) return null; audio.antNest?.(); - this.world.items.push(base); - this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1; - this.world.markTerrainDirty?.("ant-nest-built"); + this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base); this.world.emit?.("ant:nest-built", { nest: base, queen: this }); - this.world.rebuildSpatial?.(true); + this.world.ensureSpatial?.("ant-nest-built"); this.world.drawListDirty = true; this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event"); return base; diff --git a/js/assets.js b/js/assets.js index 50b7510..dd44218 100644 --- a/js/assets.js +++ b/js/assets.js @@ -10,8 +10,11 @@ const stainOverlayCache = new Map(); const SPRITE_CACHE_LIMIT = 128; const STAIN_CACHE_LIMIT = 64; -const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); +const SUNBATH_IMAGE_IDS = new Set((typeof SPRITES !== "undefined" ? SPRITES : []) + .map(asset => asset?.id || "") + .filter(id => String(id).startsWith("sunbath"))); +const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]); function trimCanvasCache(cache, limit = 128) { while (cache.size > limit) { diff --git a/js/audio.js b/js/audio.js index 6abcfbc..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(); @@ -298,6 +277,7 @@ const audio = { if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 }); if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 }); if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 }); + if (s.includes("\u307d\u304b")) return this.playSample("voice_sunbath", 0.95, "voice") || this.play("sfx_heal", { category: "voice", minGap: 0.95 }); return false; }, eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); }, @@ -323,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 4374c72..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, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 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文字×3行まで\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: "食べ物や薬をセットすると、自動供給してくれる。" }, - 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\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, - { id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, - { id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", 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\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator"] }, - { id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] }, -]); - -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: "小", medium: "中", large: "大" }); -const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); -function normalizedToolSizeFor(worldRef = null, type = "") { - const def = itemDefinition(type); - if (!def?.scalable) return "medium"; - const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; - return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; -} -function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } -function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } - -function scalableToolIds() { - return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); -} - -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 }, @@ -235,13 +91,10 @@ const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c const CONFIG = { initialPopulation: 18, maxPopulation: Number.MAX_SAFE_INTEGER, - itemLimit: 260, - fenceLimit: 72, maxFenceCollisionChecks: 18, - zunchiLimit: 56, - traceLimit: 64, - splatLimit: 48, effectLimit: 78, + grassLimit: 99, + grassLimitsByField: { cage: 19, garden: 99, park: 299 }, worldPadding: 30, hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, @@ -255,7 +108,6 @@ const CONFIG = { weatherChangeMin: 46, weatherChangeMax: 96, bubbleLimit: 32, - grassLimit: 180, highPopulationMode: 70, ultraPopulationMode: 120, }; diff --git a/js/debug_tools.js b/js/debug_tools.js index 30a7b7c..fd39c72 100644 --- a/js/debug_tools.js +++ b/js/debug_tools.js @@ -19,20 +19,31 @@ 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 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 ?? "?"}`, + `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}`, - `drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`, + `spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`, + `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 6ee71de..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, reasonText: "へこ餅で繁殖できる相手を探している" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" }); - } - 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, reasonText: "けんか餅で気に入らない相手を探している" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" }); - } - 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, reasonText: "ねむり薬で眠ろうとしている" }); - else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" }); - 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 61c99c8..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:"折り畳み"; font-size:11px; opacity:.62; font-weight:700; } - .frozen-panel.collapsed .frozen-panel-toggle::after { content:"展開"; } - .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); - global.showToast?.(`${t.name}を冷凍保存しました。`); - worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] }); - global.renderStats?.(); - global.render?.(); + 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] }); + 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 === "親" || l.relation === "子") ? 1 : 0; - const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); - if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); - else { - 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 || "誰か"}がいないことに気づいた`, wake: true }); - } - t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)"); - t.recordChangeCause?.("冷凍中の\u8a03\u5831", "ストレス", { value: stress }); - t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death"); - worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] }); - } - function thawFrozen(freezeId, x, y, worldRef = global.world) { const list = frozenList(worldRef); const index = list.findIndex(e => (e.freezeId || e.id) === freezeId); if (index < 0 || !worldRef) return false; const entry = list[index]; - const data = clonePlain(entry.data || entry) || {}; let t = null; try { - t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true }); + 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?.("解凍されて少し驚いている"); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); - t.thought = "解凍されて少し驚いている"; - t.frozenAt = 0; - updateLiveIdAfterThaw(worldRef, t); - if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); worldRef.tarinai.push(t); worldRef.recordFamily?.(t); - applyPendingLossesOnThaw(t, entry, worldRef); list.splice(index, 1); - worldRef.selected = t; - worldRef.drawListDirty = true; - worldRef.familyTreeDirty = true; - worldRef.updateItemCounts?.(); - worldRef.rebuildSpatial?.(true); - saveFrozenStore(worldRef); - renderFrozenPanel(worldRef); - global.showToast?.(`${t.name}を解凍しました。`); - worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] }); - global.renderStats?.(); - global.render?.(); + 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] }); + 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); - global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。"); - }); - } - const canvas = global.canvas || document.getElementById("gameCanvas"); - if (canvas && canvas.dataset.freezeDropBound !== "1") { - canvas.dataset.freezeDropBound = "1"; - canvas.addEventListener("dragover", (e) => { - const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain"); - if (!text) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - canvas.classList.add("freeze-drop-ready"); - }); - canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready")); - canvas.addEventListener("drop", (e) => { - const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, ""); - if (!id) return; - e.preventDefault(); - canvas.classList.remove("freeze-drop-ready"); - const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => { - const rect = canvas.getBoundingClientRect(); - const sx = e.clientX - rect.left; - const sy = e.clientY - rect.top; - return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true }; - })(); - thawFrozen(id, p.x, p.y, worldRef); - }); - } + 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"); + }, + 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/health.js b/js/health.js index 75f674f..0abd436 100644 --- a/js/health.js +++ b/js/health.js @@ -134,7 +134,7 @@ const HEALTH = (() => { if (actualLoss > 0.01) { cause = rememberDamage(t, actualLoss, reason); t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason }); - const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id); + const plushie = (t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id); if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); audio.damage?.(actualLoss); 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 8fd40ea..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; } @@ -210,7 +283,7 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {}) ctx.font = `${Math.round(localR * 1.15)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText("❤", 0, 1); + ctx.fillText("\u2764", 0, 1); ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.beginPath(); ctx.ellipse(-localR * 0.28, -localR * 0.18, localR * 0.23, localR * 0.10, -0.35, 0, Math.PI * 2); @@ -336,6 +409,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (!ctx || !type) return false; const w = Math.max(1, Number(opts.width || ctx.canvas?.width || 72)); const h = Math.max(1, Number(opts.height || ctx.canvas?.height || 72)); + const watermark = !!opts.watermark; const localR = previewItemRadiusFor(type); const item = Object.create(Item.prototype); Object.assign(item, { @@ -356,7 +430,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { wear: 0, text: type === "signboard" ? "" : undefined, storedFoodType: type === "duplicator" ? "sweet" : "", - storedFoodLabel: type === "duplicator" ? "ずんだ餅" : "", + storedFoodLabel: type === "duplicator" ? "\u305a\u3093\u3060\u9905" : "", pinState: "loose", spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0, zunchiVariant: "zunchi", @@ -371,7 +445,10 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (type === "water") item.amount = 50; ctx.save(); ctx.clearRect(0, 0, w, h); - ctx.translate(w / 2, h / 2 + (type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4)); + const baseOffsetY = type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4; + const centerX = watermark ? w * 0.78 : w / 2; + const centerY = watermark ? (h * 0.80 + baseOffsetY * 0.15) : (h / 2 + baseOffsetY); + ctx.translate(centerX, centerY); const scaleMap = { genkotsu: 0.52, bed: 0.80, @@ -386,7 +463,14 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { oshibyo: 0.92, firecracker: 0.90, }; - ctx.scale(scaleMap[type] || 0.92, scaleMap[type] || 0.92); + let scale = scaleMap[type] || 0.92; + if (isConsumableSpriteType(type)) scale *= 1.95; + else if (["grass", "water", "water_bowl", "zunda_juice"].includes(type)) scale *= 1.85; + else if (type === "duplicator") scale *= 1.55; + else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "firecracker", "genkotsu"].includes(type)) scale *= 1.08; + else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35; + if (watermark) scale *= 1.42; + ctx.scale(scale, scale); try { item.draw(ctx, 0, typeof getLightingState === "function" ? getLightingState(globalThis.world) : null); } catch (err) { @@ -402,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, + }; } @@ -417,1288 +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.stage = "fresh"; - this.stageTimer = 0; - this.fertility = 0; - this.freshness = 1; - 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.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: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); - else { - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); - t.fearTimer = Math.max(t.fearTimer || 0, 0.55); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); - 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; - let best = null; - let bestD = Infinity; - const hitRange = Math.max(10, (this.r || 8) * 1.25); - for (const t of worldRef.tarinai) { - if (!t || t.dead) continue; - const d = distXY(this.x, this.y, t.x, t.y); - const hit = (t.radius || 16) * 0.86 + hitRange; - if (d <= hit && d < bestD) { best = t; bestD = d; } - } - if (!best) return false; - return this.attachPushpin(best, worldRef, bestD); - } - - attachPushpin(t, worldRef, d = null) { - if (!t || t.dead) return false; - if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - const dx = this.x - t.x; - const dy = this.y - t.y; - const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); - this.pinState = "lodged"; - this.deletable = false; - this.pinTargetId = t.id; - this.pinAttachAngle = isOshibyo ? 0.36 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); - 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 = "おしり鋲が刺さってずんちが出なくなった"; - worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] }); - worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); - return true; - } - if (t.enterPanic) { - t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); - } else { - t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); - t.fearTimer = Math.max(t.fearTimer || 0, 1.3); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); - if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); - } - if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); - if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { - this.pinLogAt = worldRef.time || 0; - worldRef.log?.(`${t.name}に画鋲が刺さった。`, "accident", { participants: [t] }); - } - worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); - return true; - } - - updateLodgedPushpin(dt, worldRef) { - const t = worldRef.tarinai?.find(o => o && !o.dead && o.id === this.pinTargetId) || null; - if (!t) { - this.detachPushpin(worldRef, "owner-lost"); - return; - } - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const isOshibyo = Boolean(behavior?.blocksZunchi); - t.stuckPushpinId = this.id; - this.deletable = false; - this.x = isOshibyo ? t.x + (t.radius || 16) * 0.66 : t.x + (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 = 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 = "おしり鋲でずんちが溜まってつらい"; - return; - } - this.pinDamageTick = (this.pinDamageTick || 0) + dt; - while (this.pinDamageTick >= 0.55) { - this.pinDamageTick -= 0.55; - if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲"); - t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - 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: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); - else { - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); - t.fearTimer = Math.max(t.fearTimer || 0, 0.65); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); - t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); - } - this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; - while (this.pinFallCheckTimer >= 1.0) { - this.pinFallCheckTimer -= 1.0; - if (Math.random() < 0.10) { - this.detachPushpin(worldRef, "fall"); - return; - } - } - } - - detachPushpin(worldRef, reason = "released") { - const t = worldRef?.tarinai?.find(o => o && o.id === this.pinTargetId) || null; - const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; - const wasOshibyo = Boolean(behavior?.blocksZunchi); - const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; - if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; - if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; - this.pinState = "loose"; - this.pinTargetId = ""; - this.pinDamageTick = 0; - this.pinFallCheckTimer = 0; - this.deletable = true; - if (reason === "pinch") { - this.vx = 0; - this.vy = 0; - this.spinVelocity = 0; - } else { - this.vx = rand(-24, 24); - this.vy = rand(-10, 18); - this.spinVelocity = rand(-1.6, 1.6); - this.spin += rand(-0.25, 0.25); - if (t) { - this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30)); - this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30)); - } - worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); - if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); - } - - if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { - worldRef.spawnBurstZunchi(t || this, storedZunchi, this); - } - } - - updateGrass(dt, worldRef) { - const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7; - const weather = worldRef.weather || "sunny"; - 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 * 0.5; - 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 grassLimit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96); - if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) { - const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2); - if (spawned > 0) this.growth = rand(0.52, 0.68); - this.seedTimer = rand(18, 34); - } - 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) * 0.5; - if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) { - this.seedTimer = rand(24, 54); - if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1); - } - } - seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) { - let made = 0; - const limit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96); - for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; 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; - worldRef.items.push(sprout); - if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1; - made += 1; - } - return made; - } - - updateZunchi(dt, worldRef) { - const rainy = worldRef.weather === "light_rain"; - const rainBoost = rainy ? 1.95 : 1; - const decayBoost = rainy ? 2.15 : 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 * 2.0 * (rainBoost + waterBoost * 1.3); - if (this.stage === "fresh") { - this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); - this.amount -= dt * 0.05 * 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 -= dt * 0.09 * 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 -= dt * 0.12 * 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 -= dt * 0.26 * 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); - 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.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); - 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, -this.r * 1.36, -this.r * 1.28, this.r * 2.72, 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(-this.r * 1.12, y); - ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, this.r * 1.12, y + Math.cos(this.seed + i) * 2); - ctx.stroke(); - } - ctx.fillStyle = "rgba(255,255,255,0.28)"; - roundedRect(ctx, -this.r * 1.12, -this.r * 1.10, this.r * 2.24, 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("…", 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 8003425..1660a01 100644 --- a/js/main.js +++ b/js/main.js @@ -3,8 +3,6 @@ let last = nowSec(); let statsTimer = 0; let fpsTimer = 0; let fpsFrames = 0; -let perfResizeTimer = 0; -let lastPerfLevel = 0; window.__tarinaiFps = 0; const LOADING_SPRITES = [ @@ -80,27 +78,24 @@ 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, world); 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 perf = world.performanceLevel ? world.performanceLevel() : 0; - perfResizeTimer += rawDt; - if (perfResizeTimer > 1.5) { - if (perf !== lastPerfLevel) { - lastPerfLevel = perf; - resizeCanvas(); - } - perfResizeTimer = 0; - } - const statsInterval = world.performanceQuality?.().statsInterval ?? (perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48); + const statsInterval = 2.5; if (statsTimer > statsInterval) { renderStats(); statsTimer = 0; @@ -139,4 +134,3 @@ if ("serviceWorker" in navigator) { }); } - 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 8c6e27b..0000000 --- a/js/performance_manager.js +++ /dev/null @@ -1,63 +0,0 @@ -"use strict"; - -class TarinaiPerformanceManager { - constructor() { - this.fps = 60; - this.level = 0; - this.lastLevel = 0; - this.samples = []; - this.sampleLimit = 20; - } - - updateFps(fps, worldRef = null) { - const n = Number(fps); - if (!Number.isFinite(n) || n <= 0) return this.level; - this.samples.push(n); - if (this.samples.length > this.sampleLimit) this.samples.shift(); - const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length; - this.fps = Math.round(avg); - const next = this.computeLevel(avg); - if (next !== this.level) { - this.lastLevel = this.level; - this.level = next; - window.TarinaiEvents?.emit("performance:level", { level: this.level, previous: this.lastLevel, fps: this.fps, world: worldRef || null }); - } - return this.level; - } - - computeLevel(fps = this.fps) { - if (!fps || !Number.isFinite(fps)) return 0; - if (fps < 22) return 3; - if (fps < 34) return 2; - if (fps < 48) return 1; - return 0; - } - - quality(worldRef = null) { - const level = this.level; - let antCount = 0; - for (const ant of worldRef?.ants || []) if (ant && !ant.dead) antCount += 1; - const itemCount = (worldRef?.items || []).length || 0; - const effectCount = (worldRef?.effects || []).length || 0; - const antStride = antCount >= 95 || level >= 3 ? 3 : (antCount >= 35 || level >= 2 ? 2 : 1); - return { - fps: this.fps, - level, - antStride, - itemCount, - antCount, - effectCount, - effectLimit: level >= 3 ? 34 : level >= 2 ? 52 : level >= 1 ? 68 : 88, - statsInterval: level >= 3 ? 1.20 : level === 2 ? 0.92 : level === 1 ? 0.68 : 0.48, - terrainRedrawInterval: level >= 3 ? 1.30 : level === 2 ? 1.05 : level === 1 ? 0.82 : 0.62, - backgroundLightStep: level >= 3 ? 64 : level === 2 ? 96 : 180, - uiUpdateInterval: level >= 3 ? 1.2 : level === 2 ? 0.9 : level === 1 ? 0.65 : 0.45, - }; - } - - debugSnapshot(worldRef = null) { - return { version: window.TARINAI_VERSION || "", fps: this.fps, level: this.level, quality: this.quality(worldRef) }; - } -} - -window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager(); diff --git a/js/relation_event_system.js b/js/relation_event_system.js deleted file mode 100644 index 4d7cd80..0000000 --- a/js/relation_event_system.js +++ /dev/null @@ -1,86 +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 "親"; - if (deadFamily && (observer.children || []).includes(deadFamily)) return "子"; - if (observerFamily && (dead.parents || []).includes(observerFamily)) return "子"; - if (observerFamily && (dead.children || []).includes(observerFamily)) return "親"; - const rel = deadId ? observer.relationships?.[deadId] : null; - const reverse = observerId ? dead.relationships?.[observerId] : null; - if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達"; - return ""; - } - - function applyLiveDeathShock(observer, dead, relation, worldRef) { - const strong = relation === "親" || relation === "子"; - if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた"); - const stress = strong ? 18 : 11; - const fear = strong ? 2.1 : 1.45; - if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); - else { - observer.sleeping = false; - observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true }); - observer.fearTimer = Math.max(observer.fearTimer || 0, fear); - 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}の死亡`, "ストレス", { value: stress }); - observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death"); - } - - function notifyDeathToRelations(dead, reason = "") { - const worldRef = this || global.world; - if (!worldRef || !dead) return; - const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); - const deadId = dead.id || dead.releasedLiveId || ""; - let liveHits = 0; - for (const other of worldRef.tarinai || []) { - if (!other || other === dead || other.dead) continue; - const relation = relationWithDead(other, dead, worldRef); - if (!relation) continue; - applyLiveDeathShock(other, dead, relation, worldRef); - liveHits += 1; - } - for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) { - const data = entry.data || entry; - const relation = relationWithDead(data, dead, worldRef); - if (!relation) continue; - if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = []; - const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId)); - if (!duplicate) { - entry.pendingLosses.unshift({ - relation, - name: dead.name || "たりない", - familyKey: deadFamily, - liveId: deadId, - reason: reason || dead.deathReason || "死亡", - time: worldRef.time || 0, - }); - if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES; - } - } - if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] }); - global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef); - global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); - } - - function patchWorldPrototype() { - const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; }; - if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return; - const World = global.World; - if (!World) return; - apply(World.prototype); - } - - global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype }; - patchWorldPrototype(); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 3612151..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) { @@ -348,6 +441,113 @@ function isEntityVisibleInRect(entity, rect, extra = 0) { return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom; } +function renderStackSignature(worldRef) { + return [ + (worldRef.items || []).length, + (worldRef.tarinai || []).length, + (worldRef.ants || []).length, + worldRef.terrainVersion || 0, + ].join(":"); +} + +function ensureWorldRenderStack(worldRef) { + if (!worldRef._renderStack) { + worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] }; + } + const stack = worldRef._renderStack; + const signature = renderStackSignature(worldRef); + if (!worldRef.drawListDirty && stack.signature === signature) return stack; + + stack.backItems.length = 0; + stack.layered.length = 0; + stack.carriedPlushies.length = 0; + stack.lodgedPins.length = 0; + + for (const it of worldRef.items || []) { + if (!it || it.dead || isCachedTerrainItem(it)) continue; + if (isPinType(it.type) && it.pinState === "lodged") { + stack.lodgedPins.push(it); + continue; + } + if (it.isStructure && it.type === "plushie" && it.carriedById) { + stack.carriedPlushies.push(it); + continue; + } + if (isBehindSpriteLayerItem(it)) stack.backItems.push(it); + else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) }); + } + for (const t of worldRef.tarinai || []) { + if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) }); + } + for (const a of worldRef.ants || []) { + if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(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))); + stack.signature = signature; + worldRef.drawListDirty = false; + 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) { @@ -435,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; @@ -454,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"); @@ -471,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; @@ -506,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); @@ -519,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); @@ -539,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); @@ -558,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); @@ -655,8 +877,7 @@ function drawAtmosphericLighting(ctx, w, h, lighting) { function drawRain(ctx, w, h, t) { if (world.weather !== "light_rain") return; ctx.save(); - const perf = world.performanceLevel ? world.performanceLevel() : 0; - const count = perf >= 2 ? 64 : 138; + const count = 96; const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites. const slant = Math.tan(angle); for (let i = 0; i < count; i++) { @@ -680,7 +901,7 @@ function drawRain(ctx, w, h, t) { ctx.moveTo(x - dx * 0.5, y - dy * 0.5); ctx.lineTo(x + dx * 0.5, y + dy * 0.5); ctx.stroke(); - if (perf < 3 && i % 4 === 0) { + if (i % 4 === 0) { ctx.globalAlpha = 0.13 * alpha; ctx.strokeStyle = "rgba(255,255,255,0.70)"; ctx.lineWidth = 0.7; @@ -746,60 +967,31 @@ function render() { } ctx.restore(); - const perf = world.performanceLevel ? world.performanceLevel() : 0; - if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting); + 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); - // Low bedding is drawn one step behind the live sprite stack. - const backItems = []; - const layered = []; - for (const it of world.items) { - if (!it || it.dead || isCachedTerrainItem(it)) continue; - if (isPinType(it.type) && it.pinState === "lodged") continue; - if (it.isStructure && it.type === "plushie" && it.carriedById) continue; - if (!isEntityVisibleInRect(it, visibleRect)) continue; - if (isBehindSpriteLayerItem(it)) backItems.push(it); - else layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) }); + // 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); } - backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0)); - for (const it of backItems) it.draw(ctx, world.time, lighting); - ctx.save(); - ctx.lineWidth = 1.15; - ctx.setLineDash([4, 4]); - ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)"; - for (const t of world.tarinai) { - if (!isEntityVisibleInRect(t, visibleRect, 80)) continue; - const p = t.parentToFollow?.(); - if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue; - ctx.beginPath(); - ctx.moveTo(t.x, t.y); - ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y); - ctx.stroke(); - } - ctx.setLineDash([]); - ctx.restore(); + // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. - for (const t of world.tarinai || []) { - if (t && !t.dead && isEntityVisibleInRect(t, visibleRect)) layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) }); + for (const entry of renderStack.layered) { + if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting); } - for (const a of world.ants || []) { - if (a && !a.dead && isEntityVisibleInRect(a, visibleRect)) layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) }); - } - layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0))); - for (const entry of layered) entry.entity.draw(ctx, world.time, lighting); - for (const it of world.items) { - if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + for (const it of renderStack.carriedPlushies) { + if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } - for (const it of world.items) { - if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + for (const it of renderStack.lodgedPins) { + if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const ef of world.effects) { if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); @@ -869,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 52b26a2..5c47ee0 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -1,75 +1,52 @@ "use strict"; (function (global) { - const STORAGE_PREFIX = "tarinai_save_slot_v1_"; - const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; - 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); - - function bytesToBase64(bytes) { - let binary = ""; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); - } - - function base64ToBytes(text) { - let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/"); - while (b64.length % 4) b64 += "="; - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; - } - - function encodeSnapshot(snapshot) { - const json = JSON.stringify(snapshot); - const bytes = new TextEncoder().encode(json); - return EXPORT_PREFIX + bytesToBase64(bytes); - } - - function decodeSnapshot(text) { - const raw = String(text || "").trim(); - if (!raw) throw new Error("empty import text"); - if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw); - const bytes = base64ToBytes(raw); - const json = new TextDecoder().decode(bytes); - return JSON.parse(json); - } - - function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } - - function saveSlot(slot) { - const snapshot = createSnapshot(global.world); - localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); - return snapshot; - } - - function loadSlot(slot) { - const raw = localStorage.getItem(slotKey(slot)); - if (!raw) throw new Error("empty slot"); - return restoreSnapshot(JSON.parse(raw), global.world); - } - - function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } - - function readSlot(slot) { - const raw = localStorage.getItem(slotKey(slot)); - if (!raw) return null; - try { return JSON.parse(raw); } catch (_) { return null; } - } - + 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; + function createSnapshot(worldRef = global.world) { + return Snapshot.createSnapshot(worldRef); + } + + function restoreSnapshot(snapshot, worldRef = global.world, options = {}) { + return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi }); + } + + async function encodeSnapshot(snapshot = createSnapshot(global.world)) { + return Codec.encodeSnapshot(snapshot); + } + + async function decodeSnapshot(text) { + return Codec.decodeSnapshot(text); + } + + async function saveSlot(slot) { + const snapshot = createSnapshot(global.world); + return Storage.writeSlot(slot, snapshot); + } + + async function loadSlot(slot) { + const snapshot = Storage.requireSlot(slot); + return restoreSnapshot(snapshot, global.world, { source: "slot" }); + } + + function deleteSlot(slot) { Storage.deleteSlot(slot); } + function readSlot(slot) { return Storage.readSlot(slot); } function formatSlotSummary(snapshot) { - if (!snapshot) return "空き"; - const d = snapshot.summary || {}; - const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明"; - return `${date} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 0}匹`; + if (!snapshot) return "\u7a7a\u304d"; + const m = snapshot.m || []; + const date = snapshot.c ? new Date(snapshot.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e"; + const day = m[0] || 1; + const population = m[1] || 0; + const fieldType = m[2] || "\u5ead"; + const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : ""; + return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339`; } function ensureDialog() { @@ -141,7 +118,9 @@ openDialog(); }); const dialog = ensureDialog(); - dialog.addEventListener("click", (e) => { + 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]"); const loadBtn = e.target.closest("[data-load-slot]"); @@ -149,61 +128,61 @@ if (saveBtn) { const slot = Number(saveBtn.dataset.saveSlot); try { - saveSlot(slot); + await saveSlot(slot); renderSlotList(); global.audio?.notify?.(); - global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}に\u4fdd\u5b58しました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`); } catch (error) { console.warn(error); - global.showToast?.("\u4fdd\u5b58に失敗しました。空き容量を確認してください。"); + global.showToast?.("\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); } return; } if (loadBtn) { const slot = Number(loadBtn.dataset.loadSlot); - if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みます。現在の状態は上書きされます。`)) return; + if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return; try { - loadSlot(slot); + await loadSlot(slot); closeDialog(); global.audio?.notify?.(); - global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`); } catch (error) { console.warn(error); - global.showToast?.("読み込みに失敗しました。"); + global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002"); } return; } if (deleteBtn) { const slot = Number(deleteBtn.dataset.deleteSlot); - if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しますか?`)) return; + if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return; deleteSlot(slot); renderSlotList(); global.audio?.delete?.(); - global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`); return; } if (e.target.closest("#saveCloseBtn")) closeDialog(); if (e.target.closest("#saveExportBtn")) { const area = dialog.querySelector("#saveHashText"); if (area) { - area.value = encodeSnapshot(createSnapshot(global.world)); + area.value = await encodeSnapshot(createSnapshot(global.world)); area.focus(); area.select(); } - global.showToast?.("現在の状態をハッシュテキストに書き出しました。"); + global.showToast?.("\u73fe\u5728\u306e\u72b6\u614b\u3092\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306b\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002"); } if (e.target.closest("#saveImportBtn")) { const area = dialog.querySelector("#saveHashText"); const text = area?.value || ""; - if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } - if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307fます。現在の状態は上書きされます。")) return; + 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(decodeSnapshot(text), global.world); + restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" }); closeDialog(); - global.showToast?.("ハッシュテキストから読み込みました。"); + global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002"); } catch (error) { console.warn(error); - global.showToast?.("読み込みに失敗しました。テキストを確認してください。"); + global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30c6\u30ad\u30b9\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); } } }); 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 68c1e10..2261f01 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -1,7 +1,8 @@ "use strict"; (function (global) { - const SNAPSHOT_VERSION = 1; + const SNAPSHOT_VERSION = 3; + const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); function isPlainObject(value) { @@ -56,6 +57,52 @@ return target; } + function q(value, scale = 1, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? Math.round(n * scale) : fallback; + } + + function u(value, scale = 1, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n / scale : fallback; + } + + function needsToArray(needs = {}) { + return NEED_KEYS.map(k => q(needs[k], 1)); + } + + function arrayToNeeds(arr = []) { + const out = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + NEED_KEYS.forEach((k, i) => { out[k] = u(arr[i], 1); }); + return out; + } + + function flagPack(t = {}) { + let flags = 0; + if (t.dead) flags |= 1 << 0; + if (t.favorite) flags |= 1 << 1; + if (t.zunchiDisease) flags |= 1 << 2; + if (t.sleepDisease) flags |= 1 << 3; + if (t.explosionDisease) flags |= 1 << 4; + if (t.fightDisease) flags |= 1 << 5; + if (t.isZunchiSlave) flags |= 1 << 6; + if (t.zunchiSlaveLocked) flags |= 1 << 7; + if (t.birthRitualLeader) flags |= 1 << 8; + return flags; + } + + function flagApply(t, flags = 0) { + t.dead = !!(flags & (1 << 0)); + t.favorite = !!(flags & (1 << 1)); + t.zunchiDisease = !!(flags & (1 << 2)); + t.sleepDisease = !!(flags & (1 << 3)); + t.explosionDisease = !!(flags & (1 << 4)); + t.fightDisease = !!(flags & (1 << 5)); + t.isZunchiSlave = !!(flags & (1 << 6)); + t.zunchiSlaveLocked = !!(flags & (1 << 7)); + t.birthRitualLeader = !!(flags & (1 << 8)); + } + function refFor(worldRef, value) { if (!value || typeof value !== "object") return null; if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" }; @@ -75,77 +122,307 @@ } function worldData(worldRef) { - const keys = [ - "w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai", - "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", - "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason", - "relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents", - "lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength" + return { + fieldType: worldRef?.fieldType || "garden", + groundType: worldRef?.groundType || "soil", + time: Number(worldRef?.time) || 0, + day: Number(worldRef?.day) || 1, + cameraX: Number(worldRef?.cameraX) || 0, + cameraY: Number(worldRef?.cameraY) || 0, + weather: worldRef?.weather || "sunny", + nextWeatherChange: Number(worldRef?.nextWeatherChange) || 0, + deadCount: Number(worldRef?.deadCount) || 0, + liveIdNext: Number(worldRef?.liveIdNext) || 1, + liveIdSerial: Number(worldRef?.liveIdSerial) || 1, + lastBirthAt: Number(worldRef?.lastBirthAt) || -999, + maxGeneration: Number(worldRef?.maxGeneration) || 1, + colonyMood: worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed", + }; + } + + function compactWorld(worldRef) { + const mood = worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed"; + return [ + worldRef?.fieldType || "garden", + q(worldRef?.time, 10), + q(worldRef?.day, 1, 1), + q(worldRef?.cameraX, 1), + q(worldRef?.cameraY, 1), + worldRef?.weather || "sunny", + q(worldRef?.nextWeatherChange, 10), + q(worldRef?.deadCount, 1), + q(worldRef?.liveIdNext, 1, 1), + q(worldRef?.liveIdSerial, 1, 1), + q(worldRef?.lastBirthAt, 10, -9990), + q(worldRef?.maxGeneration, 1, 1), + mood, + worldRef?.groundType || "soil", ]; - const out = {}; - for (const key of keys) { - const cloned = clonePlain(worldRef?.[key]); - if (cloned !== undefined) out[key] = cloned; + } + + function applyCompactWorld(worldRef, arr = []) { + worldRef.fieldType = arr[0] || "garden"; + worldRef.time = u(arr[1], 10); + worldRef.day = u(arr[2], 1, 1); + worldRef.cameraX = u(arr[3], 1); + worldRef.cameraY = u(arr[4], 1); + worldRef.weather = arr[5] || "sunny"; + worldRef.nextWeatherChange = u(arr[6], 10); + worldRef.deadCount = u(arr[7], 1); + worldRef.liveIdNext = Math.max(1, u(arr[8], 1, 1)); + worldRef.liveIdSerial = Math.max(1, u(arr[9], 1, 1)); + worldRef.lastBirthAt = u(arr[10], 10, -999); + 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 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 { + actionId: arr[0] || "", + need: arr[1] || "", + subNeed: arr[2] || "", + phase: arr[3] || "", + source: arr[4] || "need", + reason: arr[5] || "", + text: arr[6] || arr[5] || "", + label: arr[7] || "", + forcedId: arr[8] || "", + target: expandBehaviorTarget(arr[9]), + }; + } + + function compactRelationships(t, tarinaiIndex) { + const out = []; + for (const [id, rel] of Object.entries(t.relationships || {})) { + const idx = tarinaiIndex.get(id); + if (!Number.isInteger(idx) || idx < 0) continue; + const affinity = Number(rel?.affinity) || 0; + const fear = Number(rel?.fear) || 0; + const wins = Number(rel?.fightsWon) || 0; + const losses = Number(rel?.fightsLost) || 0; + if (Math.abs(affinity) < 1 && fear < 1 && wins < 1 && losses < 1) continue; + out.push([idx, q(affinity, 10), q(fear, 10), q(wins, 1), q(losses, 1)]); } return out; } - function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { - const data = copyOwnData(entity, skip); - const targetRef = refFor(worldRef, entity?.target); - const panicTargetRef = refFor(worldRef, entity?.panicTarget); - const targetRefObject = refFor(worldRef, entity?.targetRef); - if (targetRef) data.__targetRef = targetRef; - if (panicTargetRef) data.__panicTargetRef = panicTargetRef; - if (targetRefObject) data.__targetRefObject = targetRefObject; - return data; + function expandRelationships(rows = [], tarinaiList = [], worldRef = null) { + const out = {}; + for (const row of rows || []) { + if (!Array.isArray(row)) continue; + const other = tarinaiList[row[0]]; + if (!other?.id) continue; + out[other.id] = { + affinity: u(row[1], 10), + fear: u(row[2], 10), + fightsWon: u(row[3], 1), + fightsLost: u(row[4], 1), + lastEvent: (u(row[3], 1) || u(row[4], 1)) ? "fight" : "", + lastTime: worldRef?.time || 0, + }; + } + return out; + } + + function compactTarinai(t, idx, familyIndex, tarinaiIndex, itemIndex) { + const parentIdx = (t.parents || []).map(id => familyIndex.get(id)).filter(Number.isInteger); + const childIdx = (t.children || []).map(id => familyIndex.get(id)).filter(Number.isInteger); + const timers = [ + q(t.reproductionTimer, 10), q(t.fightCooldown, 10), q(t.loveMochiTimer, 10), q(t.fightMochiTimer, 10), + q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.postBirthPeaceTimer, 10), q(t.awakeLockTimer, 10), + q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100), + ]; + const modes = [t.powerItemMode || "", t.sizeItemMode || "", t.lifeItemMode || ""]; + const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1; + const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1; + return [ + t.name || "", t.type || "smile", t.birthPersonality || null, t.currentPersonality || null, t.genetics || null, + q(t.x, 1), q(t.y, 1), q(t.vx, 10), q(t.vy, 10), q(t.scale, 1000), q(t.adultScale, 1000), + q(t.age, 10), q(t.lifeSpan, 10), q(t.birthTime, 10), q(t.generation, 1, 1), t.hasPaired ? 1 : 0, + q(t.hunger, 1), q(t.loneliness, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1), needsToArray(t.needs || t.needRaw || {}), + t.state || "idle", flagPack(t), parentIdx, childIdx, compactRelationships(t, tarinaiIndex), timers, modes, + pinIdx, nestIdx, q(t.totalFightWins, 1), q(t.totalFightLosses, 1), t.deathReason || "", compactBehavior(t.behavior, tarinaiIndex, itemIndex), + ]; + } + + function itemExtra(item, tarinaiIndex = new Map()) { + const type = item?.type || ""; + if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0, item.plushieSpriteId || ""]; + if (type === "grass") return [q(item.growth, 1000), q(item.health, 1000), q(item.seedTimer, 10), q(item.eatenAmount, 10), q(item.fertilityBoost, 1000), q(item.lifeSpan, 10), q(item.wither, 1000), item.grassLimitExempt || item.manualGrass || item.placedByPlayer ? 1 : 0]; + if (type === "bed") return [q(item.comfort, 1000), q(item.wear, 1000)]; + if (type === "signboard") return [item.text || ""]; + if (type === "duplicator") return [item.storedFoodType || "", item.storedFoodLabel || ""]; + if (type === "ball") return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000)]; + if (type === "zunchi") return [item.stage || "fresh", q(item.stageTimer, 10), q(item.fertility, 1000), q(item.freshness, 1000), item.zunchiVariant || ""]; + if (type === "ant_nest") return [q(item.antCount, 1), Array.isArray(item.antWorkers) ? item.antWorkers.map(v => q(v, 10)) : [], q(item.queenSpawnAt, 10)]; + if (type === "ant_corpse") return [item.workerSprite || "ant_worker", q(item.decayTimer, 10)]; + if (type === "firecracker") return [q(item.fuseTimer, 10), q(item.fuseMax, 10)]; + if (typeof isPinType === "function" && isPinType(type)) return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000), item.pinState || "loose", tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)]; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return [item.toolSize || "medium", q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)]; + return null; + } + + function compactItem(item, index, tarinaiIndex = new Map()) { + return [ + item?.type || "", q(item?.x, 1), q(item?.y, 1), q(item?.r, 10), q(item?.amount, 10), q(item?.age, 10), q(item?.seed, 10), item?.dead ? 1 : 0, itemExtra(item, tarinaiIndex), + ]; + } + + function applyItemExtra(item, extra, tarinaiList = []) { + if (!Array.isArray(extra)) return; + const type = item.type || ""; + if (extra[0] === "S") { + item.isStructure = true; + item.hp = u(extra[1], 10); + item.maxHp = u(extra[2], 10); + item.attachment = u(extra[3], 10); + item.usedCount = u(extra[4], 1); + item.ownerId = tarinaiList[extra[5]]?.id || ""; + item.carriedById = tarinaiList[extra[6]]?.id || ""; + item.onHead = !!extra[7]; + item.plushieSpriteId = extra[8] || item.plushieSpriteId || ""; + const def = global.StructureRegistry?.get?.(type); + if (def) { + item.label = def.label; + item.roles = clonePlain(def.roles) || item.roles || {}; + item.needEffects = clonePlain(def.needEffects) || item.needEffects || {}; + } + 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 = 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") { + item.text = String(extra[0] || ""); + } else if (type === "duplicator") { + item.storedFoodType = extra[0] || ""; item.storedFoodLabel = extra[1] || ""; + item.roles.food = Boolean(item.storedFoodType); + } else if (type === "ball") { + item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000); + } else if (type === "zunchi") { + item.stage = extra[0] || "fresh"; item.stageTimer = u(extra[1], 10); item.fertility = u(extra[2], 1000); item.freshness = u(extra[3], 1000); item.zunchiVariant = extra[4] || item.zunchiVariant || "zunchi"; + } else if (type === "ant_nest") { + item.antCount = u(extra[0], 1); item.antWorkers = Array.isArray(extra[1]) ? extra[1].map(v => u(v, 10)) : []; item.queenSpawnAt = u(extra[2], 10); + } else if (type === "ant_corpse") { + item.workerSprite = extra[0] || "ant_worker"; item.decayTimer = u(extra[1], 10); + } else if (type === "firecracker") { + item.fuseTimer = u(extra[0], 10); item.fuseMax = u(extra[1], 10); + } else if (typeof isPinType === "function" && isPinType(type)) { + item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000); item.pinState = extra[4] || "loose"; item.pinTargetId = tarinaiList[extra[5]]?.id || ""; item.pinAttachAngle = u(extra[6], 1000); item.pinAttachDistance = u(extra[7], 10); item.pinOffsetY = u(extra[8], 10); + } else if (typeof isServingFoodType === "function" && isServingFoodType(type)) { + item.toolSize = extra[0] || "medium"; item.foodServingsRemaining = u(extra[1], 10); item.foodServingsMax = u(extra[2], 10); item.amount = item.foodServingsRemaining; + } + } + + function compactAnt(ant, itemIndex = new Map(), tarinaiIndex = new Map()) { + 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 createSnapshot(worldRef = global.world) { if (!worldRef) throw new Error("world is not ready"); - const selectedRef = refFor(worldRef, worldRef.selected); + const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead); + const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead); + const tarinaiIndex = new Map(); + const familyIndex = new Map(); + tarinaiLive.forEach((t, i) => { + if (t.id) tarinaiIndex.set(t.id, i); + if (t.familyKey) familyIndex.set(t.familyKey, i); + }); + const itemIndex = new Map(); + itemsLive.forEach((it, i) => { if (it.id) itemIndex.set(it.id, i); }); + const now = Date.now(); return { - version: SNAPSHOT_VERSION, - app: "tarinai_colony_game", - createdAt: Date.now(), - summary: { - day: worldRef.day || 1, - time: worldRef.clockString ? worldRef.clockString() : "", - fieldType: worldRef.fieldType || "garden", - population: (worldRef.tarinai || []).filter(t => t && !t.dead).length, - items: (worldRef.items || []).filter(it => it && !it.dead).length, - }, - world: worldData(worldRef), - tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)), - items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)), - ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)), - effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)), - logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [], - selectedRef, + 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, 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)), + n: (worldRef.ants || []).filter(a => a && !a.dead).map(a => compactAnt(a, itemIndex, tarinaiIndex)), + s: null, }; } - function restoreTarinaiData(worldRef, data, opts = {}) { + function expandTarinai(row, index, worldRef) { 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 = ""; - } + const opts = { + familyKey: `sf${index}`, + id: `st${index}`, + liveToken: 1, + name: row[0] || undefined, + type: row[1] || "smile", + birthPersonality: row[2] || undefined, + currentPersonality: row[3] || undefined, + genetics: row[4] || undefined, + x: u(row[5], 1), y: u(row[6], 1), vx: u(row[7], 10), vy: u(row[8], 10), + scale: u(row[9], 1000, 0.28), adultScale: u(row[10], 1000, 0.28), + age: u(row[11], 10), lifeSpan: u(row[12], 10, 1600), birthTime: u(row[13], 10), generation: Math.max(1, u(row[14], 1, 1)), + hasPaired: !!row[15], hunger: u(row[16], 1), loneliness: u(row[17], 1), energy: u(row[18], 1), circadianSleepPressure: u(row[19], 1), + needs: arrayToNeeds(row[20]), state: row[21] || "idle", behavior: expandBehavior(row[33]), + }; + const t = new TarinaiClass(worldRef, opts); + t.familyKey = `sf${index}`; + t.id = `st${index}`; + t.liveToken = 1; + t.needRaw = { ...t.needs }; + t.needDisplay = { ...t.needs }; + t.previousNeeds = { ...t.needs }; + flagApply(t, Number(row[22]) || 0); + t.__savedParentIdx = Array.isArray(row[23]) ? row[23] : []; + t.__savedChildIdx = Array.isArray(row[24]) ? row[24] : []; + t.__savedRelationships = Array.isArray(row[25]) ? row[25] : []; + const timers = Array.isArray(row[26]) ? row[26] : []; + t.reproductionTimer = u(timers[0], 10); t.fightCooldown = u(timers[1], 10); t.loveMochiTimer = u(timers[2], 10); t.fightMochiTimer = u(timers[3], 10); + t.zunchiDiseaseSeverity = u(timers[4], 10); t.zunchiStain = u(timers[5], 10); t.postBirthPeaceTimer = u(timers[6], 10); t.awakeLockTimer = u(timers[7], 10); + t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[8], 10), ammo: u(timers[9], 10) }; + t.mercuryLifeMultiplier = u(timers[10], 100, t.mercuryLifeMultiplier || 1); + const modes = Array.isArray(row[27]) ? row[27] : []; + t.powerItemMode = modes[0] || ""; t.sizeItemMode = modes[1] || ""; t.lifeItemMode = modes[2] || ""; + t.__savedPinIdx = row[28]; t.__savedNestIdx = row[29]; + t.totalFightWins = u(row[30], 1); t.totalFightLosses = u(row[31], 1); t.deathReason = row[32] || ""; if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); return t; } function restoreSnapshot(snapshot, worldRef = global.world) { - if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data"); + if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data"); if (!worldRef) throw new Error("world is not ready"); - const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot)); - const fieldType = data.world?.fieldType || "garden"; + const fieldType = snapshot.w?.[0] || "garden"; if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType); @@ -153,92 +430,92 @@ worldRef.items = []; worldRef.ants = []; worldRef.effects = []; - worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : []; + worldRef.logs = []; worldRef.selected = null; - - applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"])); worldRef.events = global.TarinaiEvents || null; worldRef.liveTarinai = new Map(); worldRef.liveIdFree = []; + applyCompactWorld(worldRef, snapshot.w || []); - const items = Array.isArray(data.items) ? data.items : []; - for (const saved of items) { - if (!saved || !saved.type) continue; - const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0); - applyOwnData(item, saved, new Set(["target", "targetRef", "world"])); + const tarRows = Array.isArray(snapshot.t) ? snapshot.t : []; + for (let idx = 0; idx < tarRows.length; idx++) { + const t = expandTarinai(tarRows[idx] || [], idx, worldRef); + worldRef.tarinai.push(t); + if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 1, target: t }); + } + for (const [idx, t] of worldRef.tarinai.entries()) { + t.parents = (t.__savedParentIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean); + t.children = (t.__savedChildIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean); + delete t.__savedParentIdx; delete t.__savedChildIdx; + } + + const itemRows = Array.isArray(snapshot.i) ? snapshot.i : []; + for (let idx = 0; idx < itemRows.length; idx++) { + const row = itemRows[idx] || []; + const type = row[0] || ""; + if (!type) continue; + let item = null; + if (Array.isArray(row[8]) && row[8][0] === "S" && global.StructureRegistry?.get?.(type)) { + const owner = worldRef.tarinai[row[8][5]] || null; + item = global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef); + } else { + 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); + item.seed = u(row[6], 10, item.seed || 0); + item.dead = !!row[7]; + applyItemExtra(item, row[8], worldRef.tarinai); worldRef.items.push(item); } - const tarinaiList = Array.isArray(data.tarinai) ? data.tarinai : []; - for (const saved of tarinaiList) { - if (!saved) continue; - const t = restoreTarinaiData(worldRef, saved, { clearTransient: false }); - worldRef.tarinai.push(t); - if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t }); + for (const t of worldRef.tarinai) { + if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null; + if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null; + t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef); + delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships; } - const ants = Array.isArray(data.ants) ? data.ants : []; - for (const saved of ants) { - if (!saved) continue; - const ant = new AntActor(worldRef, saved); - applyOwnData(ant, saved, new Set(["world", "targetRef"])); - ant.world = worldRef; + const antRows = Array.isArray(snapshot.n) ? snapshot.n : []; + for (let idx = 0; idx < antRows.length; idx++) { + const row = antRows[idx] || []; + const ant = new AntActor(worldRef, { + id: `sa${idx}`, + kind: row[0] || "worker", + homeId: worldRef.items[row[1]]?.id || "", + targetId: worldRef.tarinai[row[2]]?.id || "", + targetToken: worldRef.tarinai[row[2]]?.liveToken || 0, + x: u(row[3], 1), y: u(row[4], 1), vx: u(row[5], 10), vy: u(row[6], 10), hp: u(row[7], 10), maxHp: u(row[8], 10), state: row[9] || "search", age: u(row[10], 10), seed: u(row[11], 10), returnTimer: u(row[12], 10), foundingX: u(row[13], 1), foundingY: u(row[14], 1), foundingDelayUntil: u(row[15], 10), foundingAttempts: u(row[16], 1), dead: !!row[17], + }); + ant.targetRef = ant.targetId ? worldRef.liveTarinaiById?.(ant.targetId, ant.targetToken) || null : null; worldRef.ants.push(ant); } - const effects = Array.isArray(data.effects) ? data.effects : []; - for (const saved of effects) { - if (!saved || !saved.type) continue; - const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved); - applyOwnData(ef, saved, new Set(["world", "target", "targetRef"])); - worldRef.effects.push(ef); - } - - const itemById = new Map(worldRef.items.map(it => [it.id, it])); - const antById = new Map(worldRef.ants.map(a => [a.id, a])); - const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t])); - const resolve = (ref) => { - if (!ref) return null; - if (ref.kind === "item") return itemById.get(ref.id) || null; - if (ref.kind === "ant") return antById.get(ref.id) || null; - if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null; - return resolveRef(worldRef, ref); - }; - for (const [i, saved] of tarinaiList.entries()) { - const t = worldRef.tarinai[i]; - if (!t) continue; - t.target = resolve(saved.__targetRef); - t.panicTarget = resolve(saved.__panicTargetRef); - } - for (const [i, saved] of ants.entries()) { - const ant = worldRef.ants[i]; - if (!ant) continue; - ant.targetRef = resolve(saved.__targetRefObject); - } - - worldRef.selected = resolve(data.selectedRef); - worldRef.family = clonePlain(data.world?.family) || worldRef.family || {}; - worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {}; - worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {}; - worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {}; - worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } }; + worldRef.family = {}; + worldRef.familyVersion = (worldRef.familyVersion || 0) + 1; + for (const t of worldRef.tarinai) worldRef.recordFamily?.(t); + worldRef.familyCleanVersion = null; worldRef.familyTreeDirty = true; worldRef.drawListDirty = true; worldRef.terrainDirty = true; + worldRef.relationNotices = {}; + 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"); 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 = { @@ -249,9 +526,7 @@ refFor, resolveRef, worldData, - snapshotEntity, createSnapshot, restoreSnapshot, - restoreTarinaiData, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sound_pack.js b/js/sound_pack.js index 40682ea..e67e8ab 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -3,7 +3,7 @@ (function (global) { // External audio replacement point. Add files and edit paths here; audio.js consumes this pack at startup. global.TARINAI_SOUND_PACK = Object.freeze({ - version: "1.1", + version: "1.2", categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, idGain: { sfx_notify: 2.60, @@ -30,6 +30,7 @@ sfx_ant_die: 0.14, sfx_notify: 0.10, sfx_log_push: 0.10, + voice_sunbath: 0.95, }, samplePaths: { voice_hau: "assets/sounds/voice_001_hau.wav", @@ -38,6 +39,7 @@ sfx_eat: "assets/sounds/voice_004_eat.wav", voice_stress: "assets/sounds/voice_005_stress.wav", sfx_sleep: "assets/sounds/voice_006_sleep.wav", + voice_sunbath: "assets/sounds/voice_007_sunbath_pokapoka.wav", sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3" }, categories: { @@ -46,7 +48,7 @@ ops: "\u64cd\u4f5c" }, soundMap: { - voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice", + voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice", voice_sunbath: "voice", sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice", sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice", sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice", 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 7e6203e..805bc65 100644 --- a/js/structures.js +++ b/js/structures.js @@ -22,13 +22,17 @@ this.usedCount = 0; this.x = x; this.y = y; - this.r = definition.type === "plushie" ? 13 : (definition.type === "grass_bed" ? 18 : 24); + this.r = definition.type === "plushie" ? 6.5 : (definition.type === "grass_bed" ? 18 : 24); this.amount = this.hp; this.seed = Math.random() * 1000; this.roles = clonePlain(definition.roles); this.needEffects = clonePlain(definition.needEffects); this.carriedById = definition.type === "plushie" ? this.ownerId : ""; this.onHead = definition.type === "plushie"; + if (definition.type === "plushie") { + const sprites = typeof spawnableSprites === "function" ? spawnableSprites().map(s => s.id).filter(Boolean) : []; + this.plushieSpriteId = sprites.length ? sprites[Math.floor(Math.random() * sprites.length)] : (owner?.type || "smile"); + } this.dead = false; this.world = world || null; } @@ -50,15 +54,27 @@ return; } this.x = owner.x; - this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78); + this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70); if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8); } + if (this.type === "grass_bed") { + const owner = this.owner(worldRef); + if (!owner) { + this.unownedDecayTimer = (this.unownedDecayTimer || 0) + dt; + const rainBoost = worldRef?.weather === "light_rain" ? 1.65 : 1; + const ageBoost = 1 + Math.min(1.8, this.unownedDecayTimer / 360); + this.damage(dt * 0.070 * rainBoost * ageBoost, worldRef, null); + if (this.dead) return; + } else { + this.unownedDecayTimer = 0; + } + } 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) { @@ -78,7 +94,7 @@ ctx.translate(this.x, this.y); const night = (lighting?.light || 1) < 0.42; if (this.type === "grass_bed") { - // かんたんベッド: 緑色の小さい干草寝床。通常の干草寝床より一回り小さく、草束感を強める。 + // \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9: \u7dd1\u8272\u306e\u5c0f\u3055\u3044\u5e72\u8349\u5bdd\u5e8a\u3002\u901a\u5e38\u306e\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u56de\u308a\u5c0f\u3055\u304f\u3001\u8349\u675f\u611f\u3092\u5f37\u3081\u308b\u3002 const grassFill = night ? "rgba(78,132,67,0.90)" : "rgba(84,177,72,0.93)"; const grassStroke = night ? "rgba(39,78,43,0.76)" : "rgba(45,112,48,0.78)"; const strawStroke = night ? "rgba(168,207,119,0.42)" : "rgba(205,234,128,0.66)"; @@ -106,13 +122,14 @@ ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "plushie") { - const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile"); + const spriteId = this.plushieSpriteId || "smile"; + const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, "smile") : (global.images?.get?.(spriteId) || global.images?.get?.("smile")); ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05); if (img) { - const w = this.r * 2.2; - const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null; - const h = w * (metrics?.ratio || 0.92); - ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h); + const metrics = typeof getImageMetrics === "function" ? getImageMetrics(spriteId) || getImageMetrics("smile") : null; + const w = this.r * 3.0; + const h = w * (metrics?.ratio || 0.74); + ctx.drawImage(img, -w * 0.58, -h * 0.60, w, h); } else { ctx.fillStyle = "#f7dfc0"; ctx.strokeStyle = "rgba(86,62,48,0.70)"; @@ -174,19 +191,33 @@ 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) { + onUse(user, structure, worldRef, options = {}) { + if (user?.id && !structure.ownerId) { + structure.ownerId = user.id; + structure.ownerName = user.name || ""; + structure.attachment = Math.max(structure.attachment || 0, 24); + worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted"); + worldRef?.markTerrainDirty?.("grass-bed-owner-adopted"); + worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user }); + } 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 (user) { - user.needShock = user.needShock || {}; - user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8)); - user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4)); - user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6)); + 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; }, diff --git a/js/tarinai.js b/js/tarinai.js index a71b69c..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); @@ -38,6 +37,11 @@ class Tarinai { constructor(world, opts = {}) { const growthAtConstruct = generationOpt > 1 ? clamp(((this.world?.time || 0) - this.birthTime) / Math.max(1, CONFIG.childGrowTime || 120), 0, 1) : 1; this.x = opts.x ?? rand(100, world.w - 100); this.y = opts.y ?? rand(100, world.h - 100); + this._lastValidX = Number.isFinite(this.x) ? this.x : null; + this._lastValidY = Number.isFinite(this.y) ? this.y : null; + this._lastSunbathDrawX = Number.isFinite(opts._lastSunbathDrawX) ? opts._lastSunbathDrawX : null; + this._lastSunbathDrawY = Number.isFinite(opts._lastSunbathDrawY) ? opts._lastSunbathDrawY : null; + this._activeSunbathSpriteId = typeof opts._activeSunbathSpriteId === "string" ? opts._activeSunbathSpriteId : ""; this.vx = opts.vx ?? rand(-12, 12); this.vy = opts.vy ?? rand(-12, 12); this.impulseVx = opts.impulseVx ?? 0; @@ -67,11 +71,13 @@ class Tarinai { constructor(world, opts = {}) { this.lastSatisfactionSource = opts.lastSatisfactionSource || ""; this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needRaw || this.needs) : 0; this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...defaultNeeds, ...opts.previousNeeds } : { ...this.needs }; - this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null; - this.currentAction = opts.currentAction && typeof opts.currentAction === "object" ? { ...opts.currentAction } : null; - this.activeBehavior = opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null; - this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : []; + this.need = ""; + this.state = opts.state || "idle"; this.behaviorSerial = opts.behaviorSerial ?? 0; + this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function" + ? globalThis.normalizeTarinaiBehaviorState(opts, this) + : (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 } : {}; this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null; @@ -79,9 +85,8 @@ class Tarinai { constructor(world, opts = {}) { this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); - this.need = ""; - this.state = opts.state || "idle"; 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; @@ -123,6 +128,8 @@ class Tarinai { constructor(world, opts = {}) { this.sunbathCooldown = opts.sunbathCooldown ?? 0; this.sunbathFrameTimer = opts.sunbathFrameTimer ?? 0; this.sunbathSpriteVariant = opts.sunbathSpriteVariant ?? 1; + this.sunbathAnchorX = Number.isFinite(opts.sunbathAnchorX) ? opts.sunbathAnchorX : null; + this.sunbathAnchorY = Number.isFinite(opts.sunbathAnchorY) ? opts.sunbathAnchorY : null; this.intimidateTimer = opts.intimidateTimer ?? 0; this.intimidateTargetId = opts.intimidateTargetId || null; this.intimidatedTimer = opts.intimidatedTimer ?? 0; @@ -174,7 +181,10 @@ class Tarinai { constructor(world, opts = {}) { this.fightTargetId = opts.fightTargetId || null; this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []); this.nextHeadbutt = opts.nextHeadbutt ?? 0; - this.digest = opts.digest ?? rand(0, 2.4); + const savedDigest = Number(opts.digest); + this.digest = Number.isFinite(savedDigest) + ? (savedDigest > 3 ? Math.min(2.99, savedDigest / 12.4 * 3) : Math.max(0, savedDigest)) + : 0; this.poopCount = opts.poopCount ?? 0; this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0; this.stretchTimer = opts.stretchTimer ?? 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 new file mode 100644 index 0000000..98d604e --- /dev/null +++ b/js/tarinai_action_spec.js @@ -0,0 +1,193 @@ +"use strict"; + +(function (global) { + const registry = new Map(); + + function isObject(value) { + return Boolean(value && typeof value === "object"); + } + + function finiteOr(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + function toList(value) { + if (Array.isArray(value)) return value.filter(Boolean).map(String); + if (value == null || value === "") return []; + return [String(value)]; + } + + function hasOwnFn(obj, key) { + return typeof obj?.[key] === "function"; + } + + function resolveActionSpec(action) { + if (!action) return null; + return typeof action === "string" ? getTarinaiActionSpec(action) : action; + } + + function buildActionContext(tarinai, world, ctx = {}, spec = null) { + const base = isObject(ctx) ? { ...ctx } : {}; + if (base.target === undefined && spec && hasOwnFn(spec, "selectTarget")) { + base.target = spec.selectTarget(tarinai, world, base); + } + return base; + } + + function normalizeTarinaiActionSpec(input = {}) { + if (!isObject(input)) throw new TypeError("ActionSpec must be an object"); + const id = String(input.id || input.actionId || "").trim(); + if (!id) throw new TypeError("ActionSpec.id is required"); + + // Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text. + const spec = { + ...input, + id, + kind: String(input.kind || "need_action"), + need: String(input.need || "fulfill"), + subNeed: String(input.subNeed || ""), + label: String(input.label || input.phrase || id), + phrase: String(input.phrase || input.label || id), + weight: finiteOr(input.weight, 1), + priority: finiteOr(input.priority, 0), + tags: toList(input.tags), + timerBacked: Boolean(input.timerBacked), + interruptible: input.interruptible !== false, + }; + + const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null; + const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null; + const originalStart = hasOwnFn(input, "start") ? input.start : null; + const originalTick = hasOwnFn(input, "tick") ? input.tick : 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; + + spec.selectTarget = function selectTarget(tarinai, world, ctx = {}) { + if (originalSelectTarget) return originalSelectTarget.call(spec, tarinai, world, ctx); + return ctx.target ?? tarinai?.target ?? null; + }; + + spec.canStart = function canStart(tarinai, world, ctx = {}) { + if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx)); + return true; + }; + + spec.start = function start(tarinai, world, ctx = {}) { + if (originalStart) return originalStart.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); + return undefined; + }; + + spec.finish = function finish(tarinai, world, ctx = {}) { + return originalFinish ? originalFinish.call(spec, tarinai, world, ctx) : true; + }; + + spec.fail = function fail(tarinai, world, ctx = {}) { + return originalFail ? originalFail.call(spec, tarinai, world, ctx) : false; + }; + + spec.text = function text(tarinai, world, ctx = {}) { + if (originalText) return String(originalText.call(spec, tarinai, world, ctx) || ""); + if (ctx.phase === "finished") return spec.phrase; + return spec.label; + }; + + Object.defineProperty(spec, "__actionSpec", { + value: true, + enumerable: false, + configurable: false, + }); + return spec; + } + + function registerTarinaiActionSpecs(definitions = []) { + const specs = []; + for (const definition of definitions || []) { + const spec = normalizeTarinaiActionSpec(definition); + registry.set(spec.id, spec); + specs.push(spec); + } + return specs; + } + + function registerTarinaiActionSpec(definition = {}) { + const spec = normalizeTarinaiActionSpec(definition); + registry.set(spec.id, spec); + return spec; + } + + function getTarinaiActionSpec(id = "") { + return registry.get(String(id || "")) || null; + } + + function listTarinaiActionSpecs() { + return Array.from(registry.values()); + } + + function selectTarinaiActionTarget(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + if (!spec) return null; + return hasOwnFn(spec, "selectTarget") ? spec.selectTarget(tarinai, world, ctx) : (ctx.target ?? tarinai?.target ?? null); + } + + function canStartTarinaiAction(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + if (!spec) return false; + return hasOwnFn(spec, "canStart") ? Boolean(spec.canStart(tarinai, world, isObject(ctx) ? ctx : {})) : true; + } + + function startTarinaiAction(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + if (!spec) return false; + const canCtx = isObject(ctx) ? ctx : {}; + if (hasOwnFn(spec, "canStart") && !spec.canStart(tarinai, world, canCtx)) return false; + const nextCtx = buildActionContext(tarinai, world, canCtx, spec); + return hasOwnFn(spec, "start") ? spec.start(tarinai, world, nextCtx) : spec.run?.(tarinai, world, nextCtx); + } + + function tickTarinaiAction(action, tarinai, world, dt = 0, needs = null, ctx = {}) { + const spec = resolveActionSpec(action); + if (!spec) return undefined; + const nextCtx = buildActionContext(tarinai, world, ctx, spec); + return hasOwnFn(spec, "tick") ? spec.tick(tarinai, world, dt, needs, nextCtx) : spec.update?.(tarinai, world, dt, needs, nextCtx); + } + + function finishTarinaiAction(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + return hasOwnFn(spec, "finish") ? spec.finish(tarinai, world, ctx) : true; + } + + function failTarinaiAction(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + return hasOwnFn(spec, "fail") ? spec.fail(tarinai, world, ctx) : false; + } + + function textTarinaiAction(action, tarinai, world, ctx = {}) { + const spec = resolveActionSpec(action); + if (!spec) return ""; + return hasOwnFn(spec, "text") ? String(spec.text(tarinai, world, isObject(ctx) ? ctx : {}) || "") : String(spec.label || spec.phrase || spec.id || ""); + } + + Object.assign(global, { + normalizeTarinaiActionSpec, + createTarinaiActionSpec: normalizeTarinaiActionSpec, + registerTarinaiActionSpec, + registerTarinaiActionSpecs, + getTarinaiActionSpec, + listTarinaiActionSpecs, + selectTarinaiActionTarget, + canStartTarinaiAction, + startTarinaiAction, + tickTarinaiAction, + finishTarinaiAction, + failTarinaiAction, + textTarinaiAction, + TarinaiActionSpecRegistry: registry, + }); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index 8e66433..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,17 +23,74 @@ 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; }, beginEating(item = null, reason = "") { - const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている"); + const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b"); const ok = this.setActionState("eat", { target: item || null, reason: text, sleeping: false, }); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "食べている", 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 || "待っている"); - return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", 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,15 +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", }; } - this.intent = this.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: reason || "眠っている", reasonText: "ねむいので、眠っている。", startedAt: now }; - this.currentAction = this.currentAction || { id: this.intent.actionId || "sleep", need: "sleep", startedAt: this.sleepSession.startedAt, minDuration: this.sleepSession.minDuration, lockSeconds: this.sleepSession.minDuration }; - const text = reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている"); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "眠っている", reasonText: text, target, phase: "perform", source: "behavior" }); + 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 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 }); }, @@ -81,8 +163,8 @@ }, currentBehaviorText() { - if (typeof activeBehaviorText === "function") return activeBehaviorText(this); - return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim(); + if (typeof behaviorText === "function") return behaviorText(this); + return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim(); }, enterPanic(opts = {}) { @@ -100,18 +182,23 @@ if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock); } let handled = false; - if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている"); + if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"); if (!handled) { let target = null; if ("target" in opts) target = opts.target; else if (opts.destination) target = opts.destination; else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); + const panicReason = opts.reason || this.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; this.setActionState("panic", { target, - reason: opts.reason || this.thought || "パニックになっている", + reason: panicReason, wake: opts.wake !== false, sleeping: false, }); + if (typeof setBehaviorText === "function") { + const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044"); + 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); } if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); @@ -124,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 new file mode 100644 index 0000000..1729b37 --- /dev/null +++ b/js/tarinai_behavior_state.js @@ -0,0 +1,246 @@ +"use strict"; + +(function (global) { + 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" && !Array.isArray(value)); + } + + function finiteOr(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + function nowFor(tarinai = null) { + return finiteOr(tarinai?.world?.time, 0); + } + + function normalizePhase(value, fallback = "acting") { + const phase = String(value || fallback || "acting"); + return PHASES.has(phase) ? phase : phase; + } + + 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) { + const src = isObject(value) ? value : {}; + const prev = isObject(fallback) ? fallback : {}; + 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; + 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.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)), + startedAt: finiteOr(src.startedAt, finiteOr(src.activeSince, finiteOr(prev.startedAt, finiteOr(prev.activeSince, 0)))), + activeSince: finiteOr(src.activeSince, finiteOr(src.startedAt, finiteOr(prev.activeSince, finiteOr(prev.startedAt, 0)))), + expiresAt: Number.isFinite(Number(src.expiresAt)) ? Number(src.expiresAt) : (Number.isFinite(Number(prev.expiresAt)) ? Number(prev.expiresAt) : undefined), + minDuration: finiteOr(src.minDuration, finiteOr(prev.minDuration, 0)), + duration: finiteOr(src.duration, finiteOr(prev.duration, 0)), + attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)), + failReason: String(src.failReason || prev.failReason || ""), + }; + } + + 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 now = nowFor(tarinai); + 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, + (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 + ); + 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 behavior; + } + + function normalizeBehaviorState(opts = {}, tarinai = null) { + const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null; + return normalizeBehaviorValue(opts.behavior || null, tarinai, previous); + } + + function ensureBehavior(tarinai) { + if (!tarinai || !isObject(tarinai.behavior)) return null; + const b = tarinai.behavior; + return b; + } + + function assignBehavior(tarinai, value) { + if (!tarinai) return null; + const next = normalizeBehaviorValue(value, tarinai, tarinai.behavior || null); + tarinai.behavior = next; + return next; + } + + function patchTarinaiBehavior(tarinai, fields = {}) { + if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai); + const previous = ensureBehavior(tarinai) || {}; + const next = normalizeBehaviorValue({ ...previous, ...fields }, tarinai, previous); + tarinai.behavior = next; + return next; + } + + function clearTarinaiBehavior(tarinai, opts = {}) { + if (!tarinai) return null; + tarinai.behavior = null; + if (opts.clearTarget) tarinai.target = null; + 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) { + if (!tarinai) return null; + const b = ensureBehavior(tarinai); + if (!b) return null; + if (!value) { + 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.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 (!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 || "" }; + } + + 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, + }); +})(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 fa06865..aba1840 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -70,22 +70,13 @@ } } }, - - hasWaterCurableDisease() { - return Boolean(this.zunchiDisease || this.explosionDisease); - }, - - hasZundaCurableDisease() { - return Boolean(this.explosionDisease || this.fightDisease); - }, - infectSleepDisease(source = null) { if (this.dead || this.sleepDisease) return false; this.sleepDisease = true; this.sleepDiseaseCooldown = 8; this.sleepDiseaseAnchorX = this.x; this.sleepDiseaseAnchorY = this.y; - this.startSleeping(null, "ねむり病で眠り続けている"); + this.startSleeping(null, "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"); this.fightTimer = 0; this.intimidateTimer = 0; this.fightTargetIds = []; @@ -104,7 +95,7 @@ this.sleepDiseaseCooldown = 14; this.sleepDiseaseAnchorX = null; this.sleepDiseaseAnchorY = null; - this.goIdle(reason || "ねむり病が治った"); + this.goIdle(reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); this.sleeping = false; this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); audio.heal?.(); @@ -181,7 +172,7 @@ const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy); if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; - this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている"); + this.startSleeping(null, beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"); this.vx = 0; this.vy = 0; if (beingDraggedByAnt) { @@ -204,15 +195,10 @@ if (!id) return null; return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; }, - - isInsideNestBox() { - return Boolean(this.insideNestBoxId); - }, - wantsNestBox(box = null) { if (!box || box.dead || box.type !== "nest_box") return false; - if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true; - if (!(this.state === "sleep" || this.state === "seek_bed")) return false; + if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true; + if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false; return this.target === box || this.insideNestBoxId === box.id; }, @@ -228,7 +214,7 @@ } this.insideNestBoxId = box.id; this.target = box; - if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている"); + if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b"); if (this.state === "sleep") this.sleeping = true; const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); @@ -278,8 +264,8 @@ } if (currentBox) { - if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { - this.startSleeping(box, "巣箱の中で眠っている"); + if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) { + this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b"); this.sleeping = true; this.target = currentBox; } 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 c50f81f..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; @@ -166,14 +151,14 @@ rel.fightsWon = rel.fightsWon || 0; rel.fightsLost = rel.fightsLost || 0; if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; } + this.relationCache = null; }, strongestRelation(kind = "friend", liveOnly = true) { let bestId = null; let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; - const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null; for (const [id, rel] of Object.entries(this.relationships || {})) { - if (liveIds && !liveIds.has(id)) continue; + if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue; const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); if (score > bestScore) { bestId = id; bestScore = score; } } @@ -187,13 +172,16 @@ }, currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) { + const now = this.world?.time || 0; + const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`; + if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount; let count = 0; - const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null; for (const [id, rel] of Object.entries(this.relationships || {})) { if ((rel.affinity || 0) <= minScore) continue; - if (liveIds && !liveIds.has(id)) continue; + if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue; count += 1; } + this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 }; return count; }, @@ -203,16 +191,23 @@ }, bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) { + const now = this.world?.time || 0; + const cacheKey = `${minScore}:${Number.isFinite(maxDist) ? Math.round(maxDist) : "inf"}`; + if (this.relationCache?.bestFriendKey === cacheKey && now < (this.relationCache.bestFriendUntil || 0)) { + const cached = this.world?.liveTarinaiById?.(this.relationCache.bestFriendId); + if (cached && (!Number.isFinite(maxDist) || dist(this, cached) <= maxDist)) return cached; + } let best = null, bestScore = minScore; for (const [id, rel] of Object.entries(this.relationships || {})) { const score = rel.affinity || 0; if (score <= bestScore) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); + const t = this.world.liveTarinaiById?.(id) || null; if (!t) continue; if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; best = t; bestScore = score; } + this.relationCache = { ...(this.relationCache || {}), bestFriendKey: cacheKey, bestFriendId: best?.id || "", bestFriendUntil: now + 1.0 }; return best; }, @@ -223,7 +218,7 @@ const when = rel.lastTime || -Infinity; if (this.world.time - when > maxAge) continue; if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue; - const t = this.world.tarinai.find(o => o.id === id && !o.dead); + const t = this.world.liveTarinaiById?.(id) || null; if (!t) continue; if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue; if (when > bestT) { best = t; bestT = when; } @@ -262,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); }, @@ -308,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; }, @@ -371,7 +347,7 @@ this.targetGiveupKey = key; this.targetGiveupUntil = this.world.time + 8; this.target = null; - this.goIdle?.("柵に阻まれて行き先を変えている"); + this.goIdle?.("\u67f5\u306b\u963b\u307e\u308c\u3066\u884c\u304d\u5148\u3092\u5909\u3048\u3066\u3044\u308b"); this.wanderAngle += rand(-1.4, 1.4); return; } @@ -379,7 +355,7 @@ this.targetGiveupKey = key; this.targetGiveupUntil = this.world.time + 12; this.target = null; - this.goIdle?.("辿り着けず、あきらめた"); + this.goIdle?.("\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f"); this.wanderAngle += rand(-2.2, 2.2); } }, diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index 69364d6..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?.() || [ @@ -218,14 +218,22 @@ applyFoodHungerRelief(amount = 0) { const raw = Math.max(0, Number(amount) || 0); const actual = this.hasItemEffect("laxative") ? raw * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0) : raw; - if (actual > 0.2) this.lastAteAt = this.world?.time || 0; - if (actual >= 6) { + const now = this.world?.time || 0; + if (actual > 0.2) this.lastAteAt = now; + if (actual >= 3.5) { const day = Number(this.world?.config?.dayLength || CONFIG?.dayLength || 120) || 120; - const base = day / 4; + const base = day / 3.65; const jitter = typeof stableUnit === "function" ? (0.82 + stableUnit(this.familyKey || this.id, "meal-cooldown") * 0.36) : 1; - this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, (this.world?.time || 0) + base * jitter); + this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, now + base * jitter); + this.needSatisfaction = this.needSatisfaction || (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : {}); + this.needSatisfaction.food = Math.max(this.needSatisfaction.food || 0, Math.min(88, actual * 1.85)); + this.actionCooldowns = this.actionCooldowns || {}; + 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 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; }, @@ -267,7 +275,7 @@ if (!this.world || this.dead) return; if (this.hasLodgedPinEffect?.("blocksZunchi")) { this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); - this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b"; return; } const side = this.facingDir ? this.facingDir() : (this.facing || 1); @@ -277,10 +285,8 @@ this.poopCount = (this.poopCount || 0) + 1; this.digest = Math.max(0, (this.digest || 0) - 3.0); const now = this.world?.time || 0; - if (now >= (this.nextPoopBubbleAt || -999)) { - this.nextPoopBubbleAt = now + 2.2; - this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative); - } + this.nextPoopBubbleAt = now + 2.2; + this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative, { force: true }); if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) { this.nextLaxativeLogAt = (this.world.time || 0) + 9; this.world.log?.(`${this.name}\u306f\u4e0b\u5264\u306e\u52b9\u679c\u3067\u305a\u3093\u3061\u3092\u653e\u51fa\u3057\u305f\u3002`, "accident", { participants: [this] }); 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 5836476..98c5b0f 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,824 +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: "なにか満たされない", -}; - -const TARINAI_NEED_THRESHOLDS = { - food: { start: 54, continue: 34 }, - 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: 1.25, - sleep: 0.75, - health: 0.65, - safety: 1.10, - social: 1.05, - fulfill: 1.15, -}; - -const TARINAI_ACTION_LOCK_SECONDS = { - eat_food: 3.4, - drink_water: 4.2, - sleep_in_bed: 18.0, - sleep_anywhere: 12.0, - use_medicine: 5.4, - rest_to_recover: 6.0, - panic_escape: 4.2, - flee: 3.4, - hide_at_owned_structure: 6.4, - approach_friend: 6.4, - approach_parent_or_child: 7.0, - approach_mate: 5.6, - fight_rival: 5.2, - intimidate_enemy: 2.0, - 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: 100, - intimidate_enemy: 96, - panic_escape: 94, - flee: 92, - hide_at_owned_structure: 90, - use_medicine: 86, - rest_to_recover: 84, - sleep_in_bed: 80, - sleep_anywhere: 78, - birth_ritual: 72, - approach_mate: 70, - approach_parent_or_child: 62, - approach_friend: 60, - 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) { - return clamp( - needPressure(needs.food) * 0.10 + - needPressure(needs.sleep) * 0.12 + - needPressure(needs.health) * 0.25 + - needPressure(needs.safety) * 0.30 + - needPressure(needs.social) * 0.08 + - needPressure(needs.fulfill) * 0.15, - 0, - 130 - ); -} - - -function 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 = "パニックになっている") { - 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("。") ? 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 = "パニックになっている") { - 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") score += 3; - if (tarinai?.intent?.need === need || tarinai?.currentAction?.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 actionId = action?.id || tarinai?.intent?.actionId || tarinai?.currentAction?.id || ""; - if (actionId === "build_grass_bed") return "寝床がほしい"; - if (actionId === "build_plushie") return "ぬいぐるみがほしい"; - if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "自分の場所が気になる"; - if (actionId === "use_plushie") return "ぬいぐるみを抱えたい"; - if (actionId === "play") return "退屈している"; - if (actionId === "wander_lightly") return "少し退屈している"; - - const parts = tarinai?.fulfillReasonParts || {}; - const candidates = [ - ["bed", "寝床がほしい", parts.bed], - ["plushie", "ぬいぐるみがほしい", parts.plushie], - ["boredom", "退屈している", parts.boredom], - ["material", "草でなにか作れそう", parts.material], - ["openness", "新しいことが気になる", 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 || "なにか満たされない"; -} - -function needPhraseForReason(need, tarinai = null, action = null) { - if (need === "fulfill") return fulfillPrimaryReason(tarinai, action); - if (need === "social" && action?.subNeed === "mate") return "繁殖できる相手が気になる"; - if (need === "social" && action?.id === "birth_ritual") return "相手と繁殖している"; - if (need === "social" && action?.subNeed === "conflict") return "気に入らない相手がいる"; - if (need === "social" && action?.subNeed === "family") return "家族が気になる"; - if (need === "social" && action?.subNeed === "bond") return "仲間が気になる"; - if (need === "safety" && action?.id === "panic_escape") return "怖い"; - return TARINAI_NEED_PHRASES[need] || need; -} - -function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null) { - const chosenPhrase = action?.label || action?.phrase || "待っている"; - const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; - if (tied.length) { - const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; - return `${needPhraseForReason(ignored, tarinai, action)}けど、${chosenPhrase}。`; - } - return `${needPhraseForReason(chosenNeed, tarinai, action)}ので、${chosenPhrase}。`; -} - - -function refreshIntentReasonText(tarinai, action = null) { - if (!tarinai?.intent?.need) return ""; - const resolvedAction = action || TARINAI_ACTIONS.find(a => a.id === tarinai.intent.actionId) || null; - const tiedNeeds = Array.isArray(tarinai.intent.tiedNeeds) && tarinai.intent.tiedNeeds.length ? tarinai.intent.tiedNeeds : [tarinai.intent.need]; - const next = buildReasonText(tarinai.intent.need, tiedNeeds, resolvedAction, tarinai, tarinai.world); - const prev = tarinai.intent.reasonText || ""; - if (next && next !== prev) tarinai.intent.reasonText = next; - if (next && (!tarinai.thought || tarinai.thought === prev)) tarinai.thought = next; - return next || tarinai.intent.reasonText || ""; -} - - - -function actionById(id = "") { - const key = String(id || ""); - return 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 || tarinai.activeBehavior || 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?.tarinai || []).find(o => o && !o.dead && o.id === 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.tarinai || []).find(o => o && !o.dead && o.id === 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 clearForcedBehaviorQueue(tarinai, predicate = null) { - if (!tarinai || !Array.isArray(tarinai.forcedBehaviorQueue)) return 0; - const before = tarinai.forcedBehaviorQueue.length; - if (typeof predicate !== "function") tarinai.forcedBehaviorQueue = []; - else tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.filter(entry => !predicate(entry)); - return before - tarinai.forcedBehaviorQueue.length; -} - -function activeBehaviorText(tarinai) { - const b = tarinai?.activeBehavior; - 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 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 = tarinai.activeBehavior || {}; - const actionId = fields.id || fields.actionId || previous.id || tarinai.intent?.actionId || tarinai.state || "idle"; - const action = fields.action || actionById(actionId) || null; - const need = fields.need || action?.need || previous.need || tarinai.intent?.need || "fulfill"; - const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している"; - 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", - forced: Boolean(fields.forced ?? previous.forced ?? false), - priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0, - }; - tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1; - next.serial = tarinai.behaviorSerial; - tarinai.activeBehavior = next; - tarinai.intent = { - ...(tarinai.intent || {}), - need, - tiedNeeds: Array.isArray(fields.tiedNeeds) && fields.tiedNeeds.length ? [...fields.tiedNeeds] : (Array.isArray(tarinai.intent?.tiedNeeds) && tarinai.intent.tiedNeeds.length ? [...tarinai.intent.tiedNeeds] : [need]), - actionId, - actionLabel: label, - reasonText, - startedAt, - }; - tarinai.currentAction = { - ...(tarinai.currentAction || {}), - id: actionId, - need, - subNeed: next.subNeed, - startedAt, - minDuration, - lockSeconds, - phase: next.phase, - }; - 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 || "行動している", - 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), - priority: options.priority ?? actionPriority(action), - lockSeconds: options.lockSeconds, - minDuration: options.minDuration, - phase: options.phase || "start", - }); -} - -function clearActiveBehavior(tarinai, reason = "") { - if (!tarinai) return; - tarinai.activeBehavior = null; - tarinai.currentAction = null; - if (reason) tarinai.thought = reason; -} - -function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) { - if (!tarinai || tarinai.dead || !actionId) return false; - const now = tarinai.world?.time || 0; - tarinai.forcedBehaviorQueue = Array.isArray(tarinai.forcedBehaviorQueue) ? tarinai.forcedBehaviorQueue : []; - const source = opts.source || "external"; - const basePriority = forcedBehaviorSourcePriority(source); - const entry = { - id: String(actionId), - targetId: opts.targetId ?? behaviorTargetId(opts.target), - reasonText: opts.reasonText || opts.reason || "", - 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, - lastTriedAt: -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, - }; - if (entry.replaceSameSource) { - tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.filter(e => !(e && e.id === entry.id && e.source === entry.source)); - } - tarinai.forcedBehaviorQueue.push(entry); - tarinai.forcedBehaviorQueue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); - tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.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 processForcedBehaviorQueue(tarinai, world, needs) { - if (!tarinai || tarinai.dead) return false; - const now = world?.time || 0; - let queue = Array.isArray(tarinai.forcedBehaviorQueue) - ? tarinai.forcedBehaviorQueue.filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now)) - : []; - if (!queue.length) { tarinai.forcedBehaviorQueue = []; return false; } - queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); - tarinai.forcedBehaviorQueue = queue; - - const activePriority = Number(tarinai.activeBehavior?.priority ?? actionPriority(tarinai.activeBehavior?.id || tarinai.intent?.actionId)) || 0; - const activeForced = Boolean(tarinai.activeBehavior?.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; } - const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0; - if (tarinai.activeBehavior && (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; - - const target = resolveForcedBehaviorTarget(tarinai, world, entry, action); - if (target) tarinai.target = 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 reasonText = entry.reasonText || buildReasonText(need, choice.tiedNeeds, action, tarinai, world); - if (tarinai.activeBehavior && entryPriority > activePriority) { - 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, - priority: entryPriority, - source: entry.source, - target, - lockSeconds: entry.duration || undefined, - minDuration: entry.minDuration || undefined, - }); - if (started) { - queue.splice(i, 1); - tarinai.forcedBehaviorQueue = queue; - return true; - } - } - tarinai.forcedBehaviorQueue = queue; - return false; -} - -function setLiveActionText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null } = {}) { - if (!tarinai) return; - const now = tarinai.world?.time || 0; - const nextNeed = need || tarinai.intent?.need || tarinai.currentAction?.need || tarinai.activeBehavior?.need || "social"; - const nextActionId = actionId || tarinai.intent?.actionId || tarinai.currentAction?.id || tarinai.activeBehavior?.id || tarinai.state || "idle"; - const nextActionLabel = actionLabel || reasonText || tarinai.intent?.actionLabel || tarinai.activeBehavior?.label || tarinai.state || "行動している"; - const nextReasonText = reasonText || nextActionLabel; - const action = actionById(nextActionId); - tarinai.intent = { - ...(tarinai.intent || {}), - need: nextNeed, - tiedNeeds: Array.isArray(tarinai.intent?.tiedNeeds) && tarinai.intent.tiedNeeds.length ? [...tarinai.intent.tiedNeeds] : [nextNeed], - actionId: nextActionId, - actionLabel: nextActionLabel, - reasonText: nextReasonText, - startedAt: tarinai.intent?.startedAt || now, - }; - tarinai.currentAction = { - ...(tarinai.currentAction || {}), - id: nextActionId, - need: nextNeed, - subNeed: subNeed || action?.subNeed || tarinai.currentAction?.subNeed || "", - startedAt: tarinai.currentAction?.startedAt || tarinai.intent.startedAt || now, - minDuration: tarinai.currentAction?.minDuration || 0.8, - lockSeconds: tarinai.currentAction?.lockSeconds || 1.0, - phase, - }; - if (target !== undefined) tarinai.target = target; - setActiveBehavior(tarinai, { - id: nextActionId, - action, - need: nextNeed, - subNeed: subNeed || action?.subNeed || "", - state: tarinai.state || action?.state || "idle", - label: nextActionLabel, - presentText: nextReasonText, - reasonText: nextReasonText, - target: target !== undefined ? target : tarinai.target, - tiedNeeds: tarinai.intent.tiedNeeds, - source: source || tarinai.activeBehavior?.source || "state", - forced: forced ?? Boolean(tarinai.activeBehavior?.forced), - priority: tarinai.activeBehavior?.priority ?? actionPriority(action), - phase, - }); -} - - -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}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている"; - setLiveActionText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", 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: "食べている", reasonText: catalogText || "食べている" }); - return; - } - if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: catalogText || "喧嘩している" }); - return; - } - if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "逃げている", reasonText: catalogText || "怖くて逃げている" }); - return; - } - if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: catalogText || "威嚇している" }); - return; - } - if (state === "sleep" || tarinai.sleeping) { - setLiveActionText(tarinai, { need: "sleep", actionId: tarinai.intent?.actionId || "sleep_anywhere", actionLabel: "眠っている", reasonText: catalogText || "眠っている" }); - } -} -function roleMatches(item, role) { - if (!item || item.dead) return false; - if ((role === "food" || role === "medicine") && item.type === "duplicator") return Boolean(item.storedFoodType); - if (item.amount != null && item.amount <= 0) return false; - if (item.roles?.[role]) return true; - if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(item.type) || (typeof isServingFoodType === "function" && isServingFoodType(item.type)); - if (role === "drink") return item.type === "water" || item.type === "water_bowl" || item.type === "zunda_juice"; - if (role === "medicine") return item.type === "sweet" || item.type === "water" || item.type === "water_bowl" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(item.type)); - 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 effectiveFoodTypeForItem(item) { - if (!item) return ""; - return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); -} - -function foodPriorityRank(tarinai, item) { - const type = effectiveFoodTypeForItem(item); - if (!type) return -1; - if (type === "sweet") return 4000; - if (type === "grass") return 2000; - 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; - if (typeof isParamEffectItemType === "function" && isParamEffectItemType(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 (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 88) rank -= 900; - if (item?.type === "duplicator") rank += 45; - return rank - Math.max(0, Number(distance) || 0) * 0.65; -} - -function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { - let best = null, bestScore = Infinity; - const hunger = Number(tarinai?.hunger || 0) || 0; - for (const item of world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []) { - if (!roleMatches(item, role)) continue; - if (tarinai?.shouldAvoidTarget?.(item)) continue; - const d = dist(tarinai, item); - if (d > maxDist) continue; - let score = d; - if (role === "food") { - const priority = foodPriorityScore(tarinai, item, d); - score = -priority; - } - if (score < bestScore) { best = item; bestScore = score; } - } - return best; -} - -function findNearestSleepPlace(world, tarinai, maxDist = 520) { - const own = findOwnedStructure(world, tarinai, "grass_bed", maxDist); - return own || world?.bestBedFor?.(tarinai, maxDist) || findNearestItemWithRole(world, tarinai, "sleepPlace", maxDist); -} - -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) * 28; - const stressPressure = clamp(((tarinai.stress || 0) - 38) / 42, 0, 1) * 26; - const angerPressure = (tarinai.type === "angry" ? 22 : 0) + ((other.type === "angry" || otherBattleDrug) ? 10 : 0); - const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 14) / 80, 0, 1) * 18; - const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 22; - const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 18 : 0; - const drugBonus = battleDrug ? 56 : 0; - const zunchiBonus = mixedZunchi ? 22 : 0; - const personalityBonus = clamp(aggression, -1, 1) * 14; - 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 ? "近くの相手が落ち着かない" : battleDrug ? "きずつき餅で気が立っている" : "気に入らない相手が近い", - }; - } - } - return best && best.score > 0 ? 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?.tarinai || []).find(o => o && !o.dead && o.id === 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: "気に入らない相手がいる" } : 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 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, refreshIntentReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setLiveActionText, setActiveBehavior, clearActiveBehavior, activeBehaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, actionPriority, relationFearSafe, isLiveTarinaiEntity }); + 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) { @@ -853,16 +39,16 @@ function circadianSleepPhase(world) { function updateCircadianSleepPressure(tarinai, world, dt = 0) { const phase = circadianSleepPhase(world); if (!Number.isFinite(tarinai.circadianSleepPressure)) { - tarinai.circadianSleepPressure = phase.night ? rand(58, 78) : (phase.afternoon ? rand(8, 18) : rand(0, 5)); + tarinai.circadianSleepPressure = phase.night ? rand(68, 88) : (phase.afternoon ? rand(8, 18) : rand(0, 5)); } const step = Math.max(0, Number(dt) || 0); if (phase.night) { - tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.18, 0, 78); + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.85, 0, 94); } else if (phase.afternoon) { tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.16, 0, 26); } else { const decay = (tarinai.state === "sleep" || tarinai.sleeping) ? 1.85 : 0.42; - tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 78); + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 94); } return { ...phase, pressure: tarinai.circadianSleepPressure }; } @@ -881,7 +67,8 @@ function updateNeeds(tarinai, world, dt = 0) { if (tarinai.sleepDisease) raw.sleep += 34; raw.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) raw.health += 42; - if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.stuckPushpinId || "")) raw.health += 36; + const lodgedBehavior = tarinai.currentLodgedPinBehavior?.() || null; + if ((tarinai.hurtTimer || 0) > 0.04 || ((tarinai.stuckPushpinId || "") && (lodgedBehavior?.panicOnAttach || (lodgedBehavior?.damagePerTick || 0) > 0 || (lodgedBehavior?.damageOnAttach || 0) > 0))) raw.health += 36; const danger = findNearbyDanger(world, tarinai, 170); if (danger) raw.safety += danger.kind ? 78 : 48; if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) raw.safety += 34; @@ -893,22 +80,22 @@ function updateNeeds(tarinai, world, dt = 0) { const enemy = tarinai.strongestRelation?.("fear"); if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } const conflict = evaluateConflictUrge(tarinai, world, 112); - if (conflict?.target) { + if (conflict?.target && (conflict.forced || conflict.score >= 18)) { tarinai.conflictTargetId = conflict.target.id; tarinai.conflictUrge = Math.max(Number(tarinai.conflictUrge || 0) || 0, conflict.score); - const conflictPart = conflict.score * 0.58; + const conflictPart = conflict.score * 0.36; socialParts.conflict += conflictPart; raw.social += conflictPart; if (conflict.defensive) raw.safety += conflict.score * 0.24; } else if (tarinai.conflictUrge) { tarinai.conflictUrge = Math.max(0, (Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, Number(dt) || 0) * 12); - const carriedConflict = Math.min(32, tarinai.conflictUrge * 0.34); + const carriedConflict = Math.min(18, tarinai.conflictUrge * 0.22); socialParts.conflict += carriedConflict; raw.social += carriedConflict; } if ((tarinai.fightMochiTimer || 0) > 0.04) { - socialParts.conflict += 36; - raw.social += 36; + socialParts.conflict += 34; + raw.social += 34; raw.safety += 8; } tarinai.socialReasonParts = socialParts; @@ -966,212 +153,55 @@ 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; } -const TARINAI_ACTIONS = [ - { id: "eat_food", need: "food", label: "食べ物を探している", phrase: "食べ物を食べている", weight: 62, condition(t, world) { return !!findNearestItemWithRole(world, t, "food", 760); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "food", 760), "seek_food", this.label); } }, - { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 24, condition(t, world) { return !!findNearestItemWithRole(world, t, "drink", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "drink", 560), "seek_water", this.label); } }, - { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, condition(t, world) { return !!findNearestSleepPlace(world, t, 560); }, run(t, world) { const bed = findNearestSleepPlace(world, t, 560); if (bed && dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); bed?.use?.(t, world); t.startSleeping?.(bed, this.label); return true; } }, - { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 18, condition() { return true; }, run(t) { t.startSleeping?.(null, this.label); return true; } }, - { id: "use_medicine", need: "health", label: "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u56de\u5fa9\u3057\u305f", weight: 34, condition(t, world) { return !!findNearestItemWithRole(world, t, "medicine", 620); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "medicine", 620), "seek_food", this.label); } }, - { id: "rest_to_recover", need: "health", label: "\u56de\u5fa9\u306e\u305f\u3081\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 24, condition() { return true; }, run(t) { t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); t.energy = clamp((t.energy || 0) + 1.6, 0, 100); return true; } }, - { id: "panic_escape", need: "safety", label: "怖くて逃げている", phrase: "逃げた", weight: 62, condition(t, world) { return (Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) >= 58) || !!findNearbyDanger(world, t, 240) || (t?.fearTimer || 0) > 0.18 || (t?.defeatedTimer || 0) > 0.04; }, run(t, world) { const threat = t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.tarinai.find(o => o.id === t.defeatedById && !o.dead) : null); 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; } }, - { id: "flee", need: "safety", label: "\u5371\u306a\u3044\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b", phrase: "\u96e2\u308c\u305f", weight: 40, condition(t, world) { return !!findNearbyDanger(world, t, 220); }, run(t, world) { const danger = findNearbyDanger(world, t, 220); t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); return true; } }, - { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u96a0\u308c\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 30, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, - { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, condition(t, world) { return !!(t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420)); }, run(t, world) { const other = t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420); return moveToOrUse(t, other, "seek_friend", this.label); } }, - { id: "approach_parent_or_child", need: "social", label: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u305f", weight: 28, condition(t) { return !!t.parentToFollow?.(); }, run(t) { return moveToOrUse(t, t.parentToFollow?.(), "follow_parent", this.label); } }, - { id: "approach_mate", need: "social", label: "繁殖できる相手に近づいている", phrase: "繁殖できる相手に近づいている", weight: 34, condition(t, world) { return (t.reproductionTimer || 0) <= 8 && !!world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); }, run(t, world) { const other = world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); 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); } }, - { id: "fight_rival", need: "social", label: "気に入らない相手に向かっている", phrase: "喧嘩を始めた", weight: 36, condition(t, world) { return !!conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 ? 18 : 34) || ((t?.fightMochiTimer || 0) > 0.04 && !!nearestForcedFightTarget(t, world, 760)); }, run(t, world) { const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || t.activeBehavior?.forced ? 18 : 34); const current = currentBehaviorTarget(t); const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, t.activeBehavior?.forced ? 760 : 360)); 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 || t.activeBehavior?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); world.startConflict?.(t, rival); return true; } }, - { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 12, condition(t) { return !!t.recentFightRival?.(80, 180); }, run(t) { const rival = t.recentFightRival?.(80, 180); t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 1.6); return true; } }, - { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 24, condition(t, world) { return !!world.nearest?.(t, ["ball"], 520); }, run(t, world) { return moveToOrUse(t, world.nearest?.(t, ["ball"], 520), "play_ball", this.label); } }, - { id: "build_grass_bed", need: "fulfill", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, condition(t, world) { return !findOwnedStructure(world, t, "grass_bed") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); } }, - { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 28, condition(t, world) { return !findOwnedStructure(world, t, "plushie") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); } }, - { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 22, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, - { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f", weight: 18, condition(t, world) { return !!findOwnedStructure(world, t, "plushie"); }, run(t, world) { const plushie = findOwnedStructure(world, t, "plushie"); plushie?.use?.(t, world); t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); return true; } }, - { id: "wander_lightly", need: "fulfill", label: "\u5c11\u3057\u6b69\u3044\u3066\u3044\u308b", phrase: "\u5c11\u3057\u6b69\u3044\u305f", weight: 14, condition() { return true; }, run(t, world) { t.wanderAngle += rand(-1.2, 1.2); const radius = rand(80, 180); const target = { 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 }; t.wanderTarget = target; t.setActionState?.("wander", { target, reason: this.label, sleeping: false }); return true; } }, -]; - -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 tarinaiNeedCalcInterval(tarinai, world) { + const jitter = typeof stableUnit === "function" ? stableUnit(tarinai?.familyKey || tarinai?.id || "tarinai", "need-calc-interval") * 0.35 : Math.random() * 0.30; + 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; + return urgent ? (0.55 + jitter * 0.25) : (1.0 + jitter); } -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" ? "水を探している" : (role === "medicine" ? "回復できるものを探している" : "食べ物を探している"); - const touchPad = target.type === "duplicator" ? 34 : 14; - if (dist(t, target) > (t.radius || 20) + (target.r || 12) + touchPad) { - moveToOrUse(t, target, state, label); - return true; +function shouldForceNeedRecalc(tarinai, world) { + if (!tarinai?.needRaw) return true; + if (forcedBehaviorQueueOf(tarinai).length > 0) return true; + if ((tarinai?.lastNeedShockBreaker || null)) return true; + if (tarinai?.stuckPushpinId) { + const behavior = tarinai.currentLodgedPinBehavior?.() || null; + if (behavior?.panicOnAttach || (behavior?.damagePerTick || 0) > 0 || (behavior?.damageOnAttach || 0) > 0) return true; } - t.target = target; - t.setActionState?.(role === "drink" ? "seek_water" : "eat", { target, reason: role === "drink" ? "水を飲んでいる" : `${typeof toolLabel === "function" ? toolLabel(target.type === "duplicator" ? (target.storedFoodType || target.type) : target.type) : "食べ物"}を食べている`, sleeping: false }); - const beforeHunger = Number(t.hunger || 0) || 0; - t.interactWithItems?.(Math.max(0.10, dt || 0.12)); - if ((t.eatTimer || 0) > 0.04 || beforeHunger - (Number(t.hunger || 0) || 0) > 0.1) return true; - if (role === "drink") return true; - return t.foodItemHasServingLeft?.(target) ? true : false; + if ((tarinai?.hurtTimer || 0) > 0.04 || (tarinai?.defeatedTimer || 0) > 0.04 || (tarinai?.fightTimer || 0) > 0.04) return true; + return 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 updateNeedsCached(tarinai, world, dt = 0, options = {}) { + if (!tarinai) return createDefaultNeeds(); + const now = Number(world?.time || 0) || 0; + 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; + tarinai.nextNeedCalcAt = now + offset; + } + const force = Boolean(options.force || shouldForceNeedRecalc(tarinai, world)); + if (force || now >= (tarinai.nextNeedCalcAt || 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); + return result; + } + return tarinai.needRaw || tarinai.needs || createDefaultNeeds(); } -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?.("はう", 2.2, "rgba(92,132,78,0.78)"); - t.setActionState?.("idle", { target: other, reason: `${other.name || "仲間"}と一緒にいる`, sleeping: false }); - if (typeof setLiveActionText === "function") setLiveActionText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "仲間と一緒にいる", reasonText: `${other.name || "仲間"}と一緒にいる`, 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" ? "家族に近づいている" : "仲間に近づいている"); - const conflict = conflictTargetFor(t, world, 30); - if (conflict?.target === other || (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04) { - return updateFightBehavior(t, world, dt) || true; - } - const mate = mateTargetFor(t, world, 120); - if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10) { - 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 = other.activeBehavior?.id === "approach_mate" || other.intent?.actionId === "approach_mate" || (other.loveMochiTimer || 0) > 0.04; - const selfForced = t.activeBehavior?.forced || (t.loveMochiTimer || 0) > 0.04; - if ((otherWantsMate || selfForced) && world.startBirthRitual?.(t, other)) return "finished"; - moveToOrUse(t, other, "seek_friend", "繁殖できる相手に近づいている"); - if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t); - return true; - } - return moveToOrUse(t, other, "seek_friend", "繁殖できる相手に近づいている"); -} - -function updateFightBehavior(t, world, dt) { - if (!t || !world) return false; - if ((t.fightTimer || 0) > 0.04) { - const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.tarinai.find(o => o && !o.dead && o.id === t.fightTargetId) : null); - t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}と喧嘩している` : "喧嘩している", sleeping: false }); - return true; - } - const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || t.activeBehavior?.forced ? 24 : 42); - const current = currentBehaviorTarget(t); - const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, t.activeBehavior?.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", "気に入らない相手に向かっている"); - if (candidate?.forced || t.activeBehavior?.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: "怖くて逃げている", 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; -} - -function configureTarinaiNeedActions() { - const set = (id, props) => { const action = actionById(id); if (action) Object.assign(action, props); return action; }; - set("eat_food", { subNeed: "meal", state: "seek_food", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "food"); } }); - set("drink_water", { subNeed: "water", state: "seek_water", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "drink"); } }); - set("use_medicine", { subNeed: "medicine", state: "seek_food", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "medicine"); } }); - set("panic_escape", { subNeed: "panic", state: "panic", update: updatePanicBehavior }); - set("flee", { subNeed: "avoid", state: "flee", update: updatePanicBehavior }); - set("approach_friend", { subNeed: "bond", state: "seek_friend", update(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); } }); - set("approach_parent_or_child", { subNeed: "family", state: "follow_parent", update(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); } }); - set("approach_mate", { subNeed: "mate", state: "seek_friend", update: updateMateBehavior }); - set("fight_rival", { subNeed: "conflict", state: "seek_enemy", update: updateFightBehavior }); - set("intimidate_enemy", { subNeed: "conflict", state: "intimidate", update(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; } }); - TARINAI_ACTIONS.push({ id: "birth_ritual", need: "social", subNeed: "mate", label: "繁殖の前ぶれをしている", phrase: "繁殖の前ぶれをしている", weight: 0, state: "birth_ritual", condition(t) { return (t.birthRitualTimer || 0) > 0.04; }, run() { return true; }, update(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; } }); -} -configureTarinaiNeedActions(); - -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?.items || []).find(item => item && !item.dead && item.id === plan.materialId && roleMatches(item, "grassMaterial")); - if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); - if (!material) { - t.buildPlan = null; - return false; - } - 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 structure = globalThis.StructureRegistry.create(plan.type, t, t.x + rand(-18, 18), t.y + rand(16, 28), world); - if (!structure) { - t.buildPlan = null; - return false; - } - world.items.push(structure); - world.drawListDirty = true; - structure.use?.(t, world); - t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); - applyNeedRelief(t, { fulfill: -(plan.type === "plushie" ? 50 : 36), safety: plan.type === "plushie" ? -4 : -8 }); - t.buildPlan = null; - return true; -} function weightedPickAction(actions) { const total = actions.reduce((sum, action) => sum + Math.max(0.01, Number(action.weight) || 1), 0); @@ -1183,36 +213,97 @@ function weightedPickAction(actions) { return actions[0] || null; } -function chooseActionForNeed(need, tarinai, world) { - const actions = TARINAI_ACTIONS.filter(action => action.need === need && (!action.condition || action.condition(tarinai, world))); - const weighted = actions.map(action => { - let bonus = 0; - if (need === "social") { - const p = tarinai?.socialReasonParts || {}; - if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 1.8; - else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.6; - else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.4; - else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 0.55; - } 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"); return picked ? TARINAI_ACTIONS.find(action => action.id === picked.id) || picked : null; } + +function socialSubNeedStartThreshold(subNeed = "") { + return ({ conflict: 34, mate: 24, family: 12, bond: 14, fearSocial: 18 })[subNeed] ?? 16; +} + +function shouldStartActionBySubNeed(tarinai, action, needs) { + const need = action?.need || "fulfill"; + if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs); + const parts = tarinai?.socialReasonParts || {}; + const subNeed = action.subNeed; + const subValue = Number(parts[subNeed] || 0) || 0; + const total = Number(needs?.social || 0) || 0; + const prof = tarinai?.personalityProfile?.() || {}; + const cur = tarinai?.currentPersonality || {}; + const modifier = subNeed === "conflict" ? Math.max(0, Number(cur.aggression ?? prof.fight ?? 0) || 0) * 8 + : subNeed === "bond" || subNeed === "family" ? Math.max(0, Number(cur.sociability ?? prof.social ?? 0) || 0) * 7 + : 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?.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); + if (subNeed === "conflict") { + if ((tarinai?.fightMochiTimer || 0) > 0.04) return total >= needThreshold("social", "start") && subValue >= threshold * 0.60; + const socialPull = Math.max(Number(parts.bond || 0), Number(parts.mate || 0), Number(parts.family || 0)); + return total >= needThreshold("social", "start") && subValue >= threshold && subValue >= socialPull * 0.82; + } + return total >= needThreshold("social", "start") && subValue >= threshold * 0.36; +} + +function conflictNeedsForChoice(selectedAction, candidates, needs) { + const chosenNeed = selectedAction?.need || "fulfill"; + const chosenPriority = actionPriority(selectedAction); + const chosenNeedValue = Number(needs?.[chosenNeed] || 0) || 0; + const result = []; + const add = key => { if (key && key !== chosenNeed && !result.includes(key)) result.push(key); }; + for (const c of candidates || []) { + const need = c.action?.need || "fulfill"; + const value = Number(needs?.[need] || 0) || 0; + if (need === chosenNeed || value <= 0.5) continue; + if (c.priority > chosenPriority && value >= needThreshold(need, "start")) add(need); + else if (value >= 82 && value >= chosenNeedValue - 8) add(need); + } + return result; +} + function eligiblePriorityActions(tarinai, world, needs) { const result = []; for (const action of TARINAI_ACTIONS) { if (!action || action.id === "birth_ritual") continue; + if ((Number(tarinai?.actionIdCooldowns?.[action.id] || 0) || 0) > 0.01) continue; const need = action.need || "fulfill"; - if (!shouldStartNeedAction(tarinai, need, needs)) continue; - if (action.condition && !action.condition(tarinai, world)) continue; + if (!shouldStartActionBySubNeed(tarinai, action, needs)) continue; + if (!canStartTarinaiAction(action, tarinai, world)) continue; result.push(action); } return result; @@ -1223,60 +314,93 @@ 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 || {}; - if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 1.8; - else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.6; - else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.4; - else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 0.55; - } 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 priority = actionPriority(action); + const bonus = actionSelectionBonus(action, need, tarinai); + let priority = actionPriority(action); const needValue = Number(needs?.[need] || 0) || 0; - return { action, priority, score: priority * 1000 + needValue * 3 + (Number(action.weight || 1) + bonus) }; + if (action.id === "eat_food") { + const hunger = Number(tarinai?.hunger || 0) || 0; + if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120); + else if (hunger >= 88 || needValue >= 78) priority = Math.max(priority, 98); + else if (hunger >= 78 || needValue >= 70) priority = Math.max(priority, 82); + } + const urgency = Math.max(0, needValue - needThreshold(need, "start")); + return { action, priority, need, needValue, urgency, bonus, score: priority * 1000 + needValue * 3 + (Number(action.weight || 1) + bonus) }; }); weighted.sort((a, b) => (b.priority - a.priority) || (b.score - a.score)); - const topPriority = weighted[0].priority; - const top = weighted.filter(e => e.priority === topPriority).map(e => ({ ...e.action, weight: Math.max(0.01, (Number(e.action.weight) || 1) + (e.score - e.priority * 1000) * 0.02) })); - const picked = weightedPickAction(top) || weighted[0].action; - const action = TARINAI_ACTIONS.find(a => a.id === picked.id) || picked; + let selectedEntry = weighted[0]; + + // \u539f\u5247\u306f\u884c\u52d5\u512a\u5148\u9806\u4f4d\u3002\u305f\u3060\u3057\u3001\u4f4e\u4f4d\u6b32\u6c42\u304c\u5371\u6a5f\u7684\u306b\u9ad8\u3044\u5834\u5408\u3060\u3051\u3001\u305d\u306e\u6b32\u6c42\u884c\u52d5\u3092\u9078\u3073\u3001 + // \u8868\u793a\u6587\u306f\u300c\u7720\u305f\u3044\u3051\u3069\u3001\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b\u3002\u300d\u306e\u3088\u3046\u306b\u5bfe\u7acb\u3092\u660e\u793a\u3059\u308b\u3002 + const crisis = weighted + .filter(e => e.needValue >= 88 && e.urgency >= 24 && ((selectedEntry.priority - e.priority) <= 36 || e.action?.id === "eat_food")) + .sort((a, b) => (b.needValue - a.needValue) || (b.urgency - a.urgency) || (b.score - a.score))[0] || null; + if (crisis && crisis.needValue >= selectedEntry.needValue + 16) selectedEntry = crisis; + + const samePriority = weighted.filter(e => e.priority === selectedEntry.priority); + let action = selectedEntry.action; + if (samePriority.length > 1 && samePriority.includes(selectedEntry)) { + const relationSameBand = samePriority.some(e => e.need === "social") + ? samePriority.filter(e => e.need === "social") + : samePriority; + const pool = relationSameBand.length > 1 ? relationSameBand : samePriority; + const top = pool.map(e => ({ + ...e.action, + weight: Math.max(0.01, (Number(e.action.weight) || 1) + (e.score - e.priority * 1000) * 0.02), + })); + const picked = weightedPickAction(top) || selectedEntry.action; + action = TARINAI_ACTIONS.find(a => a.id === picked.id) || picked; + selectedEntry = weighted.find(e => e.action.id === action.id) || selectedEntry; + } const need = action.need || "fulfill"; - return { action, choice: { need, tiedNeeds: [need], max: Number(needs?.[need] || 0) || 0, priority: actionPriority(action) } }; + const conflictNeeds = conflictNeedsForChoice(action, weighted, needs); + return { action, choice: { need, tiedNeeds: [need, ...conflictNeeds], max: Number(needs?.[need] || 0) || 0, priority: actionPriority(action) } }; } function shouldStartNeedAction(tarinai, need, needs) { const value = Number(needs?.[need]) || 0; + if (value <= 0.5) return false; + if (need === "food") { + 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 && !(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; - if ((tarinai?.intent?.need === need || tarinai?.currentAction?.need === need) && value >= needThreshold(need, "continue")) return true; + if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need && value >= needThreshold(need, "continue")) return true; return false; } 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); - const ran = action?.run?.(tarinai, world, options); + 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) { - clearActiveBehavior(tarinai, tarinai.intent?.actionLabel || action?.label || "待っている"); - tarinai.goIdle?.(action?.label || "待っている"); + if (typeof failTarinaiAction === "function") failTarinaiAction(action, tarinai, world, { phase: "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); + 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", lockSeconds, - minDuration: tarinai.currentAction?.minDuration || Math.max(0.45, lockSeconds * 0.72), + minDuration: (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.minDuration || Math.max(0.45, lockSeconds * 0.72), phase: "active", }); } @@ -1285,61 +409,87 @@ function startNeedAction(tarinai, world, choice, action, reasonText, options = { } function shouldInterruptCurrentAction(tarinai, needs) { - // 欲求値の自然変動だけでは実行中行動を中断しない。 - // 薬・餅などの外部強制行動は forcedBehaviorQueue 側の優先順位で処理する。 + // \u6b32\u6c42\u5024\u306e\u81ea\u7136\u5909\u52d5\u3060\u3051\u3067\u306f\u5b9f\u884c\u4e2d\u884c\u52d5\u3092\u4e2d\u65ad\u3057\u306a\u3044\u3002 + // \u85ac\u30fb\u9905\u306a\u3069\u306e\u5916\u90e8\u5f37\u5236\u884c\u52d5\u306f forcedBehaviorQueue \u5074\u306e\u512a\u5148\u9806\u4f4d\u3067\u51e6\u7406\u3059\u308b\u3002 return false; } -function continueCurrentIntent(tarinai, world, needs, dt) { - const actionId = tarinai?.activeBehavior?.id || tarinai?.intent?.actionId; +function continueCurrentBehavior(tarinai, world, needs, dt) { + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const actionId = behavior?.actionId; if (!actionId) return false; - if (shouldInterruptCurrentAction(tarinai, needs) && !tarinai?.activeBehavior?.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 = ["eat_food", "drink_water", "use_medicine", "fight_rival", "intimidate_enemy", "approach_mate", "birth_ritual", "panic_escape"].includes(action.id); - if (!timerBacked && action.condition && !action.condition(tarinai, world)) return false; - const need = action.need || tarinai.intent?.need || tarinai.activeBehavior?.need; + 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 && !canStartTarinaiAction(action, tarinai, world)) return false; + const need = action.need || behavior?.need; const value = Number(needs?.[need]) || 0; const now = world?.time || 0; - const startedAt = Number(tarinai.activeBehavior?.startedAt ?? tarinai.currentAction?.startedAt ?? tarinai.intent?.startedAt ?? now) || now; - const minDuration = Number(tarinai.currentAction?.minDuration || tarinai.activeBehavior?.minDuration || actionLockSeconds(action, tarinai) * 0.72) || 0.8; + const startedAt = Number(behavior?.startedAt ?? now) || now; + const minDuration = Number(behavior?.minDuration || actionLockSeconds(action, tarinai) * 0.72) || 0.8; const elapsed = Math.max(0, now - startedAt); const mustContinueByTimer = (action.id === "fight_rival" && (tarinai.fightTimer || 0) > 0.04) || (action.id === "birth_ritual" && (tarinai.birthRitualTimer || 0) > 0.04) || (action.id === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic") || (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04) - || ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04); - if (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || tarinai.activeBehavior?.forced) { - tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed))); - const refreshedReason = refreshIntentReasonText(tarinai, action); - const ranUpdate = action.update?.(tarinai, world, dt, needs); + || (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" && !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 (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) return false; - setActiveBehaviorFromAction(tarinai, action, { need, tiedNeeds: tarinai.intent?.tiedNeeds || [need] }, refreshedReason || tarinai.intent?.reasonText || action.label, { + if (ranUpdate === false) { + if (typeof failTarinaiAction === "function") failTarinaiAction(action, tarinai, world, { phase: "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); + clearBehavior(tarinai); + return false; + } + setBehaviorFromAction(tarinai, action, { need, tiedNeeds: behavior?.tiedNeeds || [need] }, refreshedReason || behavior?.reason || action.label, { target: tarinai.target, state: tarinai.state || action.state || "idle", - source: tarinai.activeBehavior?.source || "need", - forced: Boolean(tarinai.activeBehavior?.forced), - priority: tarinai.activeBehavior?.priority || 0, + source: behavior?.source || "need", + forced: behaviorIsForced(behavior), + priority: behavior?.priority || 0, + causeText: behavior?.causeText || "", + sourceReasonText: behavior?.sourceReasonText || "", + forcedReasonText: behavior?.sourceReasonText || "", + forcedRequest: behavior?.forcedRequest || null, startedAt, - lockSeconds: tarinai.activeBehavior?.lockSeconds, + lockSeconds: behavior?.lockSeconds, minDuration, phase: mustContinueByTimer ? "perform" : "active", }); - tarinai.thought = activeBehaviorText(tarinai) || refreshedReason || tarinai.intent?.actionLabel || 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)) action.run?.(tarinai, world); + if (needsRefresh && elapsed >= Math.min(0.7, minDuration)) { + if (typeof startTarinaiAction === "function") startTarinaiAction(action, tarinai, world, { phase: "refresh" }); + else action.run?.(tarinai, world); + } return true; } tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 2.8); - clearActiveBehavior(tarinai); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.35); + clearBehavior(tarinai); return false; } @@ -1348,6 +498,9 @@ function decayActionCooldowns(tarinai, dt = 0) { const step = Math.max(0, Number(dt) || 0); if (step <= 0) return; for (const key of Object.keys(tarinai.actionCooldowns)) tarinai.actionCooldowns[key] = Math.max(0, (Number(tarinai.actionCooldowns[key]) || 0) - step); + if (tarinai.actionIdCooldowns) { + for (const key of Object.keys(tarinai.actionIdCooldowns)) tarinai.actionIdCooldowns[key] = Math.max(0, (Number(tarinai.actionIdCooldowns[key]) || 0) - step); + } } function shouldWakeFromSleep(tarinai, world, needs) { @@ -1358,7 +511,7 @@ function shouldWakeFromSleep(tarinai, world, needs) { const elapsed = Math.max(0, now - (Number(session.startedAt) || now)); if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true; if (findNearbyDanger(world, tarinai, 160)) return true; - // 欲求値の自然変動だけでは睡眠を中断しない。直接の危険・ダメージのみ起床要因にする。 + // \u6b32\u6c42\u5024\u306e\u81ea\u7136\u5909\u52d5\u3060\u3051\u3067\u306f\u7761\u7720\u3092\u4e2d\u65ad\u3057\u306a\u3044\u3002\u76f4\u63a5\u306e\u5371\u967a\u30fb\u30c0\u30e1\u30fc\u30b8\u306e\u307f\u8d77\u5e8a\u8981\u56e0\u306b\u3059\u308b\u3002 if (elapsed < (Number(session.minDuration) || 12)) return false; if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 84)) return true; if (elapsed >= (Number(session.maxDuration) || 42)) return true; @@ -1370,21 +523,27 @@ 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 || "眠っている"; - tarinai.intent = tarinai.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: "sleep", actionLabel: "眠っている", reasonText: "ねむいので、眠っている。" }; - tarinai.currentAction = tarinai.currentAction || { id: "sleep", need: "sleep", startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration }; - tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, 0.8); + tarinai.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b"; + 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); + world?.spawnBubble?.(tarinai.x, tarinai.y - (tarinai.radius || 20) * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); + } 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; - tarinai.currentAction = null; + clearBehavior(tarinai); if (sleptLongEnough) applyNeedSatisfaction(tarinai, { sleep: 28, safety: 6 }, "sleep"); - tarinai.goIdle?.("目が覚めた"); + tarinai.goIdle?.("\u76ee\u304c\u899a\u3081\u305f"); return false; } @@ -1392,21 +551,14 @@ 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 || ""); - return Array.isArray(this.forcedBehaviorQueue) && this.forcedBehaviorQueue.some(entry => entry && (!key || entry.id === key)); + const active = typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(this) : null; + if (active && (!key || active.id === key)) return true; + return forcedBehaviorQueueOf(this).some(entry => entry && (!key || entry.id === key)); }, hasLoveMochiEffect() { @@ -1487,8 +639,20 @@ function protectSleepSession(tarinai, world, needs) { }, spriteId() { - const next = this.rawSpriteId(); const now = this.world?.time || 0; + const sunbathActive = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04; + if (sunbathActive) { + this.preloadSunbathSprites?.(); + const sunbathId = this.sunbathSpriteId ? this.sunbathSpriteId() : (String(this.sunbathSpriteVariant || "").startsWith("sunbath") ? this.sunbathSpriteVariant : "sunbath_1"); + this.visibleSpriteId = sunbathId; + this.spriteLockUntil = 0; + return sunbathId; + } + if (String(this.visibleSpriteId || "").startsWith("sunbath")) { + this.visibleSpriteId = ""; + this.spriteLockUntil = 0; + } + const next = this.rawSpriteId(); const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04; const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04; const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate"); @@ -1512,25 +676,102 @@ function protectSleepSession(tarinai, world, needs) { return (this.needs?.social || 0) < 70 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); }, + sunbathSpriteCandidates() { + const sprites = typeof SPRITES !== "undefined" ? SPRITES : (typeof globalThis !== "undefined" ? globalThis.SPRITES : []); + const ids = Array.isArray(sprites) + ? sprites.map(s => s?.id).filter(id => typeof id === "string" && id.startsWith("sunbath")) + : []; + const ordered = ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]; + return ordered.slice().sort((a, b) => { + const an = Number(String(a).match(/(\d+)$/)?.[1] || 0); + const bn = Number(String(b).match(/(\d+)$/)?.[1] || 0); + return an - bn || String(a).localeCompare(String(b)); + }); + }, + + + preloadSunbathSprites() { + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + for (const id of ids) { + if (typeof ensureImage === "function") ensureImage(id); + else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id); + } + }, + + normalizeSunbathSpriteId(value = "") { + if (typeof value === "number" && Number.isFinite(value)) return `sunbath_${Math.max(1, Math.floor(value))}`; + const str = String(value || ""); + if (/^sunbath_\d+$/.test(str) || str.startsWith("sunbath")) return str; + return ""; + }, + + isSunbathSpriteReady(id = "") { + if (!id) return false; + if (typeof isImageReady === "function") return isImageReady(id); + const img = typeof images !== "undefined" ? images.get(id) : globalThis.TarinaiAssets?.images?.get?.(id); + return !!(img && img.complete && (img.naturalWidth || img.width)); + }, + + randomSunbathSpriteId(exclude = "", options = {}) { + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + if (!ids.length) return "sunbath_1"; + const normalizedExclude = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(exclude) : String(exclude || ""); + let candidates = ids; + if (options.readyOnly) { + const ready = ids.filter(id => this.isSunbathSpriteReady ? this.isSunbathSpriteReady(id) : true); + if (ready.length) candidates = ready; + else return (this._lastRenderableSunbathSpriteId && ids.includes(this._lastRenderableSunbathSpriteId)) + ? this._lastRenderableSunbathSpriteId + : ((normalizedExclude && ids.includes(normalizedExclude)) ? normalizedExclude : (ids[0] || "sunbath_1")); + } + const choices = candidates.length > 1 ? candidates.filter(id => id !== normalizedExclude) : candidates; + return choices[Math.floor(Math.random() * choices.length)] || candidates[0] || ids[0] || "sunbath_1"; + }, + sunbathSpriteId() { - if (!this.sunbathSpriteVariant) this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); - return `sunbath_${this.sunbathSpriteVariant}`; + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + let current = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || ""); + if (!current || !ids.includes(current)) { + this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId("", { readyOnly: true }) : "sunbath_1"; + current = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || "sunbath_1"); + } + if (!this.isSunbathSpriteReady?.(current)) { + const last = this._lastRenderableSunbathSpriteId; + if (last && ids.includes(last) && this.isSunbathSpriteReady?.(last)) { + this.sunbathSpriteVariant = last; + current = last; + } else { + const ready = ids.find(id => this.isSunbathSpriteReady?.(id)); + if (ready) { + this.sunbathSpriteVariant = ready; + current = ready; + } + } + } + return current || "sunbath_1"; }, isSunbathTime() { - const weatherOk = (this.world?.weather || "") === "sunny"; + const weather = this.world?.weather || ""; + const weatherOk = weather === "sunny" || weather === "cloudy"; const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24); - return weatherOk && hour >= 6 && hour <= 15.5; + return weatherOk && hour >= 6 && hour <= 13.5; }, - canStartSunbath() { - if (this.dead || this.sleepDisease || this.sleeping) return false; + canStartSunbath(options = {}) { + if (this.dead || this.sleeping) return false; + if (!Number.isFinite(this.x) || !Number.isFinite(this.y)) return false; + if (!Number.isFinite(this.world?.w) || !Number.isFinite(this.world?.h)) return false; + if ((this.entryTimer || 0) > 0.04) return false; if (!this.isSunbathTime()) return false; if ((this.sunbathCooldown || 0) > 0) return false; - if (this.hasLodgedPinEffect?.("blocksZunchi")) return false; - if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; - if (this.hunger > 68 || this.energy < 18) return false; - return true; + if (this.stuckPushpinId || this.currentLodgedPin?.()) return false; + if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_water", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; + if (this.hunger > (options.leisure ? 62 : 72) || this.energy < (options.leisure ? 24 : 16)) return false; + const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; + const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; + if (sick && healthNeed >= 34) return true; + return options.leisure ? healthNeed < needThreshold("health", "start") : healthNeed >= Math.max(38, needThreshold("health", "start") - 12); }, tryRecoverBySunbath() { @@ -1547,15 +788,49 @@ function protectSleepSession(tarinai, world, needs) { return healed; }, - startSunbath() { - if (!this.canStartSunbath()) return false; - this.setActionState?.("sunbath", { target: null, reason: "\u65e5\u306a\u305f\u307c\u3063\u3053", sleeping: false }); - this.sunbathTimer = rand(6, 14); - this.sunbathCooldown = rand(16, 34); - this.sunbathFrameTimer = 0; - this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); - this.sunbathRecoveryChecked = false; + finishSunbath() { + if (this.sunbathFinishedAt === this.world?.time) return false; + this.sunbathFinishedAt = this.world?.time || 0; + applyNeedSatisfaction(this, { health: 56, safety: 6, fulfill: 8 }, "sunbath"); + this.zunchiStain = 0; this.tryRecoverBySunbath(); + this.sunbathCooldown = Math.max(this.sunbathCooldown || 0, rand(20, 38)); + if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble("\u307d\u304b\u3063", 3.4, "rgba(112,86,36,0.78)"); + return true; + }, + + startSunbath(options = {}) { + if (!this.canStartSunbath(options)) return false; + const pad = Math.max(28, CONFIG.worldPadding || 30); + const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000); + const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720); + const startX = Number.isFinite(this.x) ? this.x : (Number.isFinite(this._lastValidX) ? this._lastValidX : pad); + const startY = Number.isFinite(this.y) ? this.y : (Number.isFinite(this._lastValidY) ? this._lastValidY : pad); + this.x = clamp(startX, pad, safeW - pad); + this.y = clamp(startY, pad, safeH - pad); + this.sunbathAnchorX = this.x; + this.sunbathAnchorY = this.y; + this._lastSunbathDrawX = this.x; + this._lastSunbathDrawY = this.y; + this._lastValidX = this.x; + this._lastValidY = this.y; + this.entryTimer = 0; + const reason = options.leisure ? "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b" : "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b"; + this.setActionState?.("sunbath", { target: null, reason, sleeping: false }); + this.preloadSunbathSprites?.(); + this.sunbathTimer = Math.max(6, (CONFIG.dayLength || 120) / 12); + this.sunbathCooldown = 0; + this.sunbathFrameTimer = 0.5; + this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId("", { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`; + this.sunbathSpriteFrameKey = Math.floor((this.world?.time || 0) / 0.5); + this._activeSunbathSpriteId = this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant; + this._lastRenderableSunbathSpriteId = this._activeSunbathSpriteId; + this.visibleSpriteId = this._activeSunbathSpriteId; + this.spriteLockUntil = 0; + this.sunbathRecoveryChecked = false; + this.vx = 0; + this.vy = 0; + 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; }, @@ -1564,15 +839,33 @@ function protectSleepSession(tarinai, world, needs) { if ((this.sunbathTimer || 0) <= 0) return; this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt); this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt); + this.vx = 0; + this.vy = 0; if ((this.sunbathFrameTimer || 0) <= 0) { - this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); - this.sunbathFrameTimer = rand(0.55, 1.15); + const prev = this._activeSunbathSpriteId || (this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant); + const next = this.randomSunbathSpriteId ? this.randomSunbathSpriteId(prev, { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`; + if (next && next !== prev && (!this.isSunbathSpriteReady || this.isSunbathSpriteReady(next))) { + this._activeSunbathSpriteId = next; + this._lastRenderableSunbathSpriteId = next; + this.sunbathSpriteVariant = next; + this.visibleSpriteId = next; + this.spriteLockUntil = 0; + } else { + this.sunbathSpriteVariant = prev; + this.visibleSpriteId = prev; + } + this.sunbathFrameTimer = 0.5; } - applyNeedRelief(this, { safety: -dt * 6, fulfill: -dt * 4 }); - this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); - if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0; + this.energy = clamp((this.energy || 0) + dt * 0.03, 0, 100); + if (!this.isSunbathTime() || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { - this.goIdle(""); + this.finishSunbath?.(); + this._activeSunbathSpriteId = ""; + this.visibleSpriteId = ""; + this.spriteLockUntil = 0; + this.sunbathAnchorX = null; + this.sunbathAnchorY = null; + this.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f"); } }, @@ -1609,6 +902,9 @@ function protectSleepSession(tarinai, world, needs) { this.reproductionTimer -= dt; this.blink += dt; this.eatTimer = Math.max(0, this.eatTimer - dt); + if (this.state === "eat" && this.eatTimer <= 0.02) { + this.setActionState?.("idle", { target: null, reason: "\u98df\u3079\u7d42\u3048\u305f", sleeping: false, clearTarget: true }); + } this.eatCooldown = Math.max(0, this.eatCooldown - dt); this.foodReactTimer = Math.max(0, this.foodReactTimer - dt); this.surpriseTimer = Math.max(0, this.surpriseTimer - dt); @@ -1634,7 +930,7 @@ function protectSleepSession(tarinai, world, needs) { const beforeBirthTimer = this.birthRitualTimer; this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt); if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + const partner = this.world.liveTarinaiById?.(this.birthPartnerId); if (partner) this.world.finishBirthRitual(this, partner); this.birthRitualLeader = false; } @@ -1664,7 +960,7 @@ function protectSleepSession(tarinai, world, needs) { this.fightCooldown = Math.max(0, this.fightCooldown - dt); this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { - const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null; + const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); this.hurtTimer = Math.max(this.hurtTimer, 1.8); @@ -1724,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); @@ -1734,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 popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; - const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.familyKey || this.id, "ai-interval") * 0.09 + popPerf * 0.055); - if (this.aiTimer >= aiInterval) { - const aiDt = Math.min(this.aiTimer, 0.48); + 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 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(); } @@ -1756,8 +1058,8 @@ function protectSleepSession(tarinai, world, needs) { this.updateFacing(); } this.envTimer += dt; - const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48; - if (this.envTimer >= envInterval) { + const envInterval = 3.0 + stableUnit(this.familyKey || this.id, "env-phase") * 1.4; + if (this.envTimer >= envInterval && this.world.spendScheduledWork?.("env", urgentAi || this.world.selected === this) !== false) { const envDt = Math.min(this.envTimer, 1.2); this.envTimer = 0; this.applyEnvironment(envDt); @@ -1808,10 +1110,11 @@ function protectSleepSession(tarinai, world, needs) { } }, - bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") { - if (this.world.time < this.nextBubbleAt) return; + bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) { + if (!options.force && this.world.time < this.nextBubbleAt) return false; this.nextBubbleAt = this.world.time + cooldown; this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color); + return true; }, mouthPosition(target = null) { @@ -1825,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)); @@ -1861,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 }); @@ -1876,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; }, @@ -1939,55 +1236,56 @@ function protectSleepSession(tarinai, world, needs) { resolveNeeds(dt) { decayActionCooldowns(this, dt); const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); - const needs = updateNeeds(this, this.world, dt); + const needs = updateNeedsCached(this, this.world, dt); synchronizeActionTextFromState(this); if (processForcedBehaviorQueue(this, this.world, needs)) return; if (protectSleepSession(this, this.world, needs)) return; if (continueBuildPlan(this, this.world, dt)) return; const lockedTargetInvalid = this.target && this.target.dead; - if ((this.intentLockTimer || 0) > 0 && this.intent && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - if (continueCurrentIntent(this, this.world, needs, dt)) return; - this.thought = this.intent.reasonText || this.intent.actionLabel || this.thought; + const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior; + 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; } if (this.birthRitualTimer > 0.04) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; - this.setActionState?.("birth_ritual", { target: partner, reason: "繁殖の前ぶれをしている" }); + const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null; + this.setActionState?.("birth_ritual", { target: partner, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" }); synchronizeActionTextFromState(this); return; } if (this.intimidateTimer > 0.04) { - const target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; - this.setActionState?.("intimidate", { target, reason: "相手を威嚇している" }); - if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: target?.name ? `${target.name}を威嚇している` : "相手を威嚇している", target, phase: "perform", source: "behavior" }); + const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null; + this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); + 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; } if (this.fightTimer > 0.04) { - this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); + this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id))); this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; let best = null, bestD = Infinity; for (const id of this.fightTargetIds) { - const t = this.world.tarinai.find(o => o.id === id && !o.dead); + const t = this.world.liveTarinaiById?.(id); if (!t) continue; const d = dist(this, t); if (d < bestD) { best = t; bestD = d; } } - const reasonText = best ? `${best.name || "相手"}が気に入らないので、喧嘩している。` : "気に入らない相手がいるので、喧嘩している。"; + 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: "喧嘩している", 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; } if (this.defeatedTimer > 0.04) { - const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); + const opponent = this.world.liveTarinaiById?.(this.defeatedById); applyNeedShock(this, { safety: 54, health: 16 }, opponent || null); - startNeedDrivenEmergencyReaction(this, opponent || null, "喧嘩に負けて逃げている。"); + startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002"); if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; return; } else if (this.defeatedById && this.fightTimer <= 0.04) { @@ -2005,10 +1303,14 @@ function protectSleepSession(tarinai, world, needs) { this.lastNeedShockBreaker = pin; this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); - this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); - this.thought = "画鋲が刺さってパニックになっている"; + const now = this.world?.time || 0; + if (now >= (this.nextPinPanicBubbleAt || 0)) { + this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); + this.nextPinPanicBubbleAt = now + 1.6; + } + this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; } - this.stuckPushpinId = null; + if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null; } const breaker = this.lastNeedShockBreaker || null; @@ -2017,21 +1319,48 @@ function protectSleepSession(tarinai, world, needs) { const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0); const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - if (startNeedDrivenEmergencyReaction(this, breaker, "パニックになっている。")) return; - this.currentAction = null; + if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return; + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; this.lastNeedShockBreaker = null; 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.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; + } + + const foodNeedForLeisure = Number(needs?.food || 0) || 0; + const safetyNeedForLeisure = Number(needs?.safety || 0) || 0; + const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0; + const sunbathActionForLeisure = actionById("sunbath"); + if (sunbathActionForLeisure + && this.canStartSunbath?.({ leisure: true }) + && foodNeedForLeisure < 58 + && safetyNeedForLeisure < 50 + && sleepNeedForLeisure < 68 + && Math.random() < Math.max(0.010, dt * 0.055)) { + const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) }; + startNeedAction(this, this.world, choice, sunbathActionForLeisure, "余裕があるので、日光浴している。", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 }); + return; + } const picked = choosePriorityNeedAction(needs, this, this.world); if (!picked?.action) { + const sunbathAction = actionById("sunbath"); + if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) { + const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) }; + startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 }); + return; + } const choice = chooseTopNeedRandom(needs, this, this.world); - this.intent = { need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], actionId: "idle", actionLabel: "待っている", reasonText: "少し落ち着いている。", startedAt: this.world?.time || 0 }; - clearActiveBehavior(this); - this.intentLockTimer = Math.max(this.intentLockTimer || 0, 3.0); - this.goIdle?.("少し落ち着いている"); + 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; } const action = picked.action; @@ -2042,47 +1371,61 @@ function protectSleepSession(tarinai, world, needs) { }, makePoop(force = 1) { - this.digest += force; - if (this.digest < 12.4) return; + const add = Math.max(0, Number(force) || 0); + if (add <= 0) return false; + const threshold = NORMAL_POOP_MEAL_THRESHOLD || 3; + this.digest = Math.max(0, Number(this.digest) || 0) + add; + if (this.digest + 1e-6 < threshold) return false; + this.digest = Math.max(0, this.digest - threshold); if (this.hasLodgedPinEffect?.("blocksZunchi")) { - this.digest = rand(0.4, 1.6); this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b"; if ((this.oshiriByoZunchiStock || 0) >= 6) applyNeedShock(this, { health: 8 }); - return; + return false; } - this.digest = rand(0.4, 1.6); - this.poopCount += 1; + this.poopCount = (this.poopCount || 0) + 1; const side = this.facingDir(); const backX = this.x - side * this.radius * rand(0.72, 0.96); const backY = this.y + this.radius * rand(0.25, 0.48); - this.world.spawnZunchi(backX, backY); + this.world.spawnZunchi(backX, backY, this); const now = this.world?.time || 0; - if (now >= (this.nextPoopBubbleAt || -999)) { - this.nextPoopBubbleAt = now + 2.8; - this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)"); - } + this.nextPoopBubbleAt = now + 2.8; + this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true }); if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] }); + return true; }, interactWithItems(dt) { const foodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; - const foodActionActive = this.intent?.need === "food" || this.currentAction?.need === "food" || ["seek_food", "eat", "seek_water"].includes(this.state); - const healthActionActive = this.intent?.need === "health" || this.currentAction?.need === "health" || this.state === "seek_food"; - const mealReady = (this.mealCooldownUntil || 0) <= (this.world?.time || 0) || this.hunger > 92 || foodNeed > 84 || foodActionActive; + const currentNeed = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need; + const foodActionActive = currentNeed === "food" || ["seek_food", "eat", "seek_water"].includes(this.state); + const healthActionActive = currentNeed === "health" || this.state === "seek_food"; + const mealReady = (this.mealCooldownUntil || 0) <= (this.world?.time || 0) || this.hunger > 92 || foodNeed > 84; const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || this.hunger > 70; 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 > 88 || foodNeed > 82)); - const foodCandidates = this.world.nearbyItems(this.x, this.y, this.radius + 74).slice(); - if (this.target && !this.target.dead && !foodCandidates.includes(this.target) && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.unshift(this.target); - foodCandidates.sort((a, b) => foodPriorityScore(this, b, dist(this, b)) - foodPriorityScore(this, a, dist(this, a))); + 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; + } + if (foodCandidates.length > 1 && foodCandidates.length <= scanLimit) { + foodCandidates.sort((a, b) => foodPriorityScore(this, b, b?.type === "duplicator" ? foodInteractionDistance(this, b) : dist(this, b)) - foodPriorityScore(this, a, a?.type === "duplicator" ? foodInteractionDistance(this, a) : dist(this, a))); + } for (const it of foodCandidates) { - const d = dist(this, it); - const contactPad = it?.type === "duplicator" ? 28 : (it === this.target && foodActionActive ? 14 : 8); - if (d > this.radius + it.r + contactPad) continue; + const d = it?.type === "duplicator" ? foodInteractionDistance(this, it) : dist(this, it); + const contactReach = foodInteractionReach(this, it, "food") + (it === this.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0); + if (d > contactReach) continue; const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type; const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi"; @@ -2112,7 +1455,9 @@ function protectSleepSession(tarinai, world, needs) { this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7"); } this.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7"; - this.makePoop(eating * (foodType === "sweet" ? 0.18 : 0.22)); + const fromDuplicator = isDuplicatorFoodSource(it); + this.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); + if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); @@ -2157,7 +1502,9 @@ function protectSleepSession(tarinai, world, needs) { const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); } - this.makePoop(eating * 0.20); + const fromDuplicator = isDuplicatorFoodSource(it); + this.makePoop(normalPoopUnitsFor(foodType || it.type, eating)); + if (fromDuplicator) this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, this.world.time + 1.2); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { this.world.log(`${this.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); @@ -2187,7 +1534,7 @@ function protectSleepSession(tarinai, world, needs) { this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39"); } if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; } - this.makePoop(bite * 0.10); + this.makePoop(normalPoopUnitsFor("grass", bite)); if (this.type === "jito") this.affection += dt * 0.25; break; } @@ -2208,7 +1555,7 @@ function protectSleepSession(tarinai, world, needs) { const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); this.lastMealColor = "#4a3f36"; - this.makePoop(bite * 0.08); + this.makePoop(normalPoopUnitsFor("ant_corpse", bite)); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; } break; } @@ -2221,7 +1568,9 @@ 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); - if (canZunchiBite) { + 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); this.beginEating(it, this.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"); @@ -2236,7 +1585,7 @@ function protectSleepSession(tarinai, world, needs) { this.lastMealColor = "#6b8d3f"; this.eatTimer = Math.max(this.eatTimer, 1.0); this.eatCooldown = rand(1.0, 1.22); - this.bubble(this.isZunchiSlave ? "\u3082\u3050" : "...", 2.2, "rgba(74,91,50,0.78)"); + this.bubble("\u3082\u3050", 2.2, "rgba(74,91,50,0.78)"); const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; } @@ -2265,17 +1614,18 @@ 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; this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); } this.energy += dt * (1.9 + comfort * 1.4); - if (occ > 5) applyNeedShock(this, { safety: dt * 3 }); else applyNeedRelief(this, { sleep: -dt * 6 * comfort, safety: -dt * 4 * comfort }); + if (occ > 5) applyNeedShock(this, { safety: dt * 3 }); it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); } } @@ -2305,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 23626ca..f82189d 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -3,13 +3,194 @@ (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; + const active = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04; + if (!active) return false; + + this.preloadSunbathSprites?.(); + const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"]; + const candidates = (Array.isArray(ids) && ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]).filter(id => String(id || "").startsWith("sunbath")); + const ready = (id) => typeof isImageReady === "function" + ? isImageReady(id) + : !!(typeof images !== "undefined" && images?.get?.(id)?.complete && (images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width)); + const imageFor = (id) => { + if (!id) return null; + if (typeof getRenderableImage === "function") return getRenderableImage(id, ""); + return (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null; + }; + + let requested = this.sunbathSpriteId ? this.sunbathSpriteId() : this.normalizeSunbathSpriteId?.(this.sunbathSpriteVariant); + if (!requested || !candidates.includes(requested)) requested = candidates[0] || "sunbath_1"; + + const locked = this._activeSunbathSpriteId && candidates.includes(this._activeSunbathSpriteId) && ready(this._activeSunbathSpriteId) + ? this._activeSunbathSpriteId + : ""; + const ordered = [locked, requested, this._lastRenderableSunbathSpriteId, ...candidates].filter(Boolean); + let renderSprite = ordered.find(id => candidates.includes(id) && ready(id)); + let img = renderSprite ? imageFor(renderSprite) : null; + + if (img && ready(renderSprite)) { + this._activeSunbathSpriteId = renderSprite; + this._lastRenderableSunbathSpriteId = renderSprite; + this.sunbathSpriteVariant = renderSprite; + this.visibleSpriteId = renderSprite; + } else { + const fallbackId = this._lastNonSunbathSpriteId || this.goodMode || this.type || "smile"; + renderSprite = fallbackId; + img = typeof getRenderableImage === "function" + ? getRenderableImage(fallbackId, "smile") + : (typeof images !== "undefined" ? (images.get?.(fallbackId) || images.get?.("smile")) : null); + if (!img) { + renderSprite = "smile"; + img = typeof getRenderableImage === "function" + ? getRenderableImage("smile", "smile") + : (typeof images !== "undefined" ? images.get?.("smile") : null); + } + } + + const worldW = Math.max(1, Number(this.world?.w || 1000) || 1000); + const worldH = Math.max(1, Number(this.world?.h || 720) || 720); + const pad = Math.max(28, CONFIG.worldPadding || 30); + const lastX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : NaN); + const lastY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : NaN); + if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = Number.isFinite(lastX) ? lastX : (Number.isFinite(this.x) ? this.x : pad); + if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = Number.isFinite(lastY) ? lastY : (Number.isFinite(this.y) ? this.y : pad); + let drawX = this.sunbathAnchorX; + let drawY = this.sunbathAnchorY; + if (!Number.isFinite(drawX)) drawX = Number.isFinite(lastX) ? lastX : pad; + if (!Number.isFinite(drawY)) drawY = Number.isFinite(lastY) ? lastY : pad; + drawX = clamp(drawX, pad, Math.max(pad, worldW - pad)); + drawY = clamp(drawY, pad, Math.max(pad, worldH - pad)); + this.sunbathAnchorX = drawX; + this.sunbathAnchorY = drawY; + this._lastSunbathDrawX = drawX; + this._lastSunbathDrawY = drawY; + this._lastValidX = drawX; + this._lastValidY = drawY; + if (!Number.isFinite(this.x) || Math.abs(this.x - drawX) > 1.5) this.x = drawX; + if (!Number.isFinite(this.y) || Math.abs(this.y - drawY) > 1.5) this.y = drawY; + + const metrics = getImageMetrics(renderSprite) || getImageMetrics(img) || getImageMetrics("smile") || {}; + const w = metrics?.w || img?.naturalWidth || img?.width || 512; + const h = metrics?.h || img?.naturalHeight || img?.height || 512; + const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale; + const drawW = w * visualScale * 0.30; + const drawH = h * visualScale * 0.30; + const breathing = Math.sin(t * 1.25 + this.age * 0.18); + const bob = breathing * 0.55; + const faceRight = this.facingDir ? this.facingDir() > 0 : (this.facing || 1) > 0; + const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1; + if (nestHideAlpha <= 0.01) return true; + + const lightState = lighting || getLightingState(this.world); + 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 }); + } + + ctx.save(); + ctx.globalAlpha = nestHideAlpha; + ctx.translate(drawX, drawY + bob); + ctx.rotate((faceRight ? 1 : -1) * Math.PI / 28); + if (faceRight) ctx.scale(-1, 1); + if (img) { + ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH); + } else { + ctx.fillStyle = "#f4ead6"; + ctx.strokeStyle = "rgba(92,71,49,0.48)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 0, drawW * 0.42, drawH * 0.36, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + + if (showDecor) { + ctx.save(); + ctx.globalAlpha = 0.18 * nestHideAlpha; + ctx.fillStyle = lightState.goldenStrength > 0.25 ? "rgba(255, 223, 137, 0.95)" : "rgba(255, 244, 172, 0.82)"; + ctx.beginPath(); + ctx.arc(drawX - drawW * 0.28, drawY - drawH * 0.35, Math.max(3, drawW * 0.06), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + return true; + }, + draw(ctx, t, lighting = null) { if (this.dead) return; + const sunbathActive = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04; + if (!sunbathActive && Number.isFinite(this.x) && Number.isFinite(this.y)) { + this._lastValidX = this.x; + this._lastValidY = this.y; + } const lightState = lighting || getLightingState(this.world); - const sprite = this.spriteId(); - const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type)); - const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); + if (sunbathActive && this.drawSunbathPose?.(ctx, t, lightState)) return; + if (!sunbathActive && String(this.visibleSpriteId || "").startsWith("sunbath")) { + this.visibleSpriteId = ""; + this.spriteLockUntil = 0; + } + const showDecor = shouldShowTarinaiDecor(this); + if (sunbathActive) this.preloadSunbathSprites?.(); + let sprite = this.spriteId(); + if (!sunbathActive && String(sprite || "").startsWith("sunbath")) { + this.visibleSpriteId = ""; + this.spriteLockUntil = 0; + sprite = this.rawSpriteId ? this.rawSpriteId() : (this.goodMode || this.type || "smile"); + } + if (!String(sprite || "").startsWith("sunbath")) this._lastNonSunbathSpriteId = sprite; + const sunbathSprite = String(sprite || "").startsWith("sunbath"); + const sunbathCandidates = sunbathSprite && this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : []; + if (sunbathSprite) { + for (const id of sunbathCandidates) { + if (typeof ensureImage === "function") ensureImage(id); + else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id); + } + } + const spriteFallback = "smile"; + let renderSprite = sprite; + let img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, spriteFallback) : (images.get(sprite) || images.get(spriteFallback) || images.get(this.type)); + if (sunbathSprite) { + const ready = (id) => typeof isImageReady === "function" ? isImageReady(id) : !!(images?.get?.(id)?.complete || images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width); + const imageFor = (id) => (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null; + if (ready(sprite)) { + this._lastRenderableSunbathSpriteId = sprite; + } else { + const orderedFallbacks = [this._lastRenderableSunbathSpriteId, ...sunbathCandidates].filter(Boolean); + const fallbackSunbath = orderedFallbacks.find(id => ready(id)); + if (fallbackSunbath) { + renderSprite = fallbackSunbath; + img = imageFor(fallbackSunbath); + this._lastRenderableSunbathSpriteId = fallbackSunbath; + this.sunbathSpriteVariant = fallbackSunbath; + } + } + if (!img && this._lastRenderableSunbathSpriteId && ready(this._lastRenderableSunbathSpriteId)) { + renderSprite = this._lastRenderableSunbathSpriteId; + img = imageFor(this._lastRenderableSunbathSpriteId); + } + if (!img) img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : (images.get("smile") || images.get(this.type)); + } + const metrics = getImageMetrics(renderSprite) || getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile"); const w = metrics?.w || 512; const h = metrics?.h || 512; const moveSpeed = Math.hypot(this.vx, this.vy); @@ -63,18 +244,18 @@ const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8); const shadowContactY = this.y + bob + (drawH / squish) * 0.30; - if (img) { + if (showDecor && img) { drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, { faceRight, alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha, widthScale: this.state === "sleep" ? 1.12 : 1, heightScale: this.state === "sleep" ? 0.88 : 1, }); - } else { + } else if (showDecor) { drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha }); } - drawCreatureGlow(ctx, this, drawW, drawH, lightState); - if (this.powerItemMode === "protein" || this.powerItemMode === "niteropu") { + if (showDecor) drawCreatureGlow(ctx, this, drawW, drawH, lightState); + if (showDecor && (this.powerItemMode === "protein" || this.powerItemMode === "niteropu")) { ctx.save(); const up = this.powerItemMode === "protein"; const baseColor = up ? "rgba(224,54,40,0.92)" : "rgba(116,80,190,0.88)"; @@ -106,7 +287,7 @@ ctx.restore(); } if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + const partner = this.world.liveTarinaiById?.(this.birthPartnerId); const hx = partner ? (this.x + partner.x) / 2 : this.x; const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60; const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10; @@ -194,7 +375,7 @@ ctx.textBaseline = "middle"; ctx.fillText(focusLabel, 0, ly); ctx.restore(); - } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { + } else if (showDecor && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { ctx.save(); ctx.globalAlpha = 0.28; ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)"; @@ -231,7 +412,7 @@ ctx.restore(); } - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { + if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") { ctx.save(); const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0; ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34; @@ -262,7 +443,19 @@ const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8); const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8); if (img) { - const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img; + let spriteCacheKey = renderSprite || sprite; + try { + if (typeof images !== "undefined" && images.get?.(renderSprite || sprite) !== img) { + let fallbackId = "fallback"; + for (const [imageId, imageObj] of images) { + if (imageObj === img) { fallbackId = imageId; break; } + } + spriteCacheKey = `${renderSprite || sprite}:fallback:${fallbackId}`; + } + } catch (err) { + spriteCacheKey = renderSprite || sprite; + } + const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(spriteCacheKey, img, cacheW, cacheH) : img; ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh); } else { ctx.save(); @@ -276,7 +469,7 @@ ctx.restore(); } if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") { - const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); + const stainCanvas = getCachedStainOverlayCanvas(renderSprite || sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5); if (stainCanvas) { ctx.save(); ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88); @@ -300,16 +493,6 @@ drawDawnRimLight(ctx, dw, dh, lightState); if (faceRight) ctx.scale(-1, 1); - if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) { - ctx.save(); - ctx.globalAlpha = 0.80; - ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`; - ctx.textAlign = "center"; - ctx.fillStyle = "rgba(42,36,29,0.72)"; - const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?"); - ctx.fillText(mark, 0, -drawH * 0.72); - ctx.restore(); - } ctx.restore(); @@ -322,8 +505,10 @@ ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.shadowColor = "rgba(255, 198, 28, 0.82)"; - ctx.shadowBlur = 14; + if (showDecor) { + ctx.shadowColor = "rgba(255, 198, 28, 0.82)"; + ctx.shadowBlur = 14; + } ctx.lineWidth = Math.max(2, starSize * 0.12); ctx.strokeStyle = "rgba(92, 62, 0, 0.86)"; ctx.fillStyle = "rgba(255, 218, 46, 1)"; @@ -334,7 +519,7 @@ const hpActive = (this.hpBarTimer || 0) > 0; const stressActive = (this.stressBarTimer || 0) > 0; - if (hpActive || stressActive) { + if ((hpActive || stressActive) && (showDecor || this.world.selected === this || this.hurtTimer > 0.04)) { const bw = Math.max(28, this.radius * 2.35); const bh = 5; const x = this.x - bw / 2; 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 7ff70e1..652541c 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -6,8 +6,12 @@ Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ interactWithOthers(dt) { let closeCount = 0; + const scanLimit = 8; + let scanned = 0; for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) { if (o === this || o.dead) continue; + if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break; + scanned += 1; const dx = o.x - this.x, dy = o.y - this.y; const d = Math.hypot(dx, dy); if (d < 0.01 || d > 74) continue; @@ -22,8 +26,8 @@ if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 36, safety: dt * 5 }, o); if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * 30, safety: dt * 5 }, this); if (typeof queueForcedTarinaiBehavior === "function") { - queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${o.name || "相手"}と喧嘩しようとしている` }); - queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${this.name || "相手"}と喧嘩しようとしている` }); + queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); + queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" }); } } @@ -136,14 +140,14 @@ const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; - if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { + if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) { this.conflictTargetId = o.id; o.conflictTargetId = this.id; - const urge = 48 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 12 : 0); + const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0); this.conflictUrge = Math.max(this.conflictUrge || 0, urge); o.conflictUrge = Math.max(o.conflictUrge || 0, urge * 0.82); - if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * 0.32, safety: dt * (battleDrug ? 4 : 1.6) }, o); - if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * 0.24, safety: dt * (battleDrug ? 4 : 1.6) }, this); + if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * (battleDrug ? 0.32 : 0.18), safety: dt * (battleDrug ? 4 : 0.9) }, o); + if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * (battleDrug ? 0.24 : 0.14), safety: dt * (battleDrug ? 4 : 0.9) }, this); } const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave; @@ -156,10 +160,12 @@ applyNeedShock(o, { social: dt * (loveDrug ? 34 : 14) }, this); } if (loveDrug && typeof queueForcedTarinaiBehavior === "function") { - queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${o.name || "相手"}と繁殖したくて近づいている` }); - queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${this.name || "相手"}と繁殖したくて近づいている` }); + 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 = this.activeBehavior?.id === "approach_mate" || o.activeBehavior?.id === "approach_mate" || this.intent?.actionId === "approach_mate" || o.intent?.actionId === "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); } @@ -186,10 +192,23 @@ if (this.state === "sunbath") { this.target = null; - this.vx *= Math.pow(0.90, dt * 60); - this.vy *= Math.pow(0.90, dt * 60); - this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); - this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + this.vx = 0; + this.vy = 0; + const pad = Math.max(28, CONFIG.worldPadding || 30); + const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000); + const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720); + const fallbackX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : (Number.isFinite(this.x) ? this.x : pad)); + const fallbackY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : (Number.isFinite(this.y) ? this.y : pad)); + if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = fallbackX; + if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = fallbackY; + this.sunbathAnchorX = clamp(this.sunbathAnchorX, pad, safeW - pad); + this.sunbathAnchorY = clamp(this.sunbathAnchorY, pad, safeH - pad); + this.x = this.sunbathAnchorX; + this.y = this.sunbathAnchorY; + this._lastSunbathDrawX = this.x; + this._lastSunbathDrawY = this.y; + this._lastValidX = this.x; + this._lastValidY = this.y; return; } if (this.state === "sleep") { @@ -197,7 +216,7 @@ const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const crowdedSleep = bedCrowd > 5; this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); - if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort }); + if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); if (this.target && !this.target.dead && this.target.type === "nest_box") { this.updateNestBoxPresence(dt); @@ -226,7 +245,7 @@ } if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { - const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead); + const partner = this.world.liveTarinaiById?.(this.birthPartnerId); if (partner) { const midX = (this.x + partner.x) / 2; const midY = (this.y + partner.y) / 2; @@ -276,9 +295,9 @@ const d = Math.hypot(dx, dy) || 1; 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"); - this.currentAction = null; - this.intentLockTimer = 0; - this.goIdle?.("少し歩いた"); + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; + this.behaviorLockTimer = 0; + this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f"); return; } if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) { @@ -289,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; } } @@ -406,11 +427,15 @@ this.dead = true; this.deathReason = finalReason; this.world.deadCount += 1; - this.world.items.push(new Item("trace", this.x, this.y)); - this.world.items.push(new Item("splat", this.x, this.y)); + this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y)); + this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y)); if (Math.random() < 0.55) { const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); - if (spot) this.world.items.push(new Item("grass", spot.x, spot.y)); + if (spot) { + const deathGrass = new Item("grass", spot.x, spot.y); + if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass"); + else this.world.items.push(deathGrass); + } } this.world.markDead(this, finalReason); this.releasedLiveToken = this.liveToken || 0; diff --git a/js/text_catalog.js b/js/text_catalog.js index 90d3086..79a8679 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -53,36 +53,68 @@ const TEXT_CATALOG = { TEXT_CATALOG.stateLabels = Object.freeze({ - idle: "待機", - seek_food: "食べ物へ向かう", - seek_bed: "寝床へ向かう", - play_ball: "ボール遊び", - seek_water: "水を探す", - zunchi_sick: "ずんち病", - fight_sick: "きずつき病", - system: "ずんち病", - seek_friend: "仲間を探す", - follow_parent: "親についていく", - cursor_friend: "カーソルについていく", - cursor_enemy: "カーソルを避ける", - fight: "喧嘩中", - sleep: "睡眠中", - eat: "食事中", - panic: "パニック", - intimidate: "威嚇中", - birth_ritual: "誕生前", - sunbath: "日光浴中", - ant_attack: "アリ攻撃中", - ant_intimidate: "アリ威嚇中", - frozen: "冷凍中", + idle: "\u5f85\u6a5f", + seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046", + seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046", + play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", + seek_water: "\u6c34\u3092\u63a2\u3059", + 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", + seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", + follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", + cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f", + cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b", + fight: "\u55a7\u5629\u4e2d", + sleep: "\u7761\u7720\u4e2d", + eat: "\u98df\u4e8b\u4e2d", + panic: "\u30d1\u30cb\u30c3\u30af", + intimidate: "\u5a01\u5687\u4e2d", + birth_ritual: "\u8a95\u751f\u524d", + sunbath: "\u65e5\u5149\u6d74\u4e2d", + ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d", + ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d", + frozen: "\u51b7\u51cd\u4e2d", }); -TEXT_CATALOG.targetLabelOverrides = Object.freeze({ - system: "ずんち病", - zunchi_sick: "ずんち病", - fight_sick: "きずつき病", + +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) { @@ -90,53 +122,57 @@ TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) { }; TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) { - if (!target) return "なし"; + if (!target) return "\u306a\u3057"; if (target.name) return target.name; - if (target.kind === "queen") return "女王アリ"; - if (target.kind === "worker") return "働きアリ"; + if (target.kind === "queen") return "\u5973\u738b\u30a2\u30ea"; + if (target.kind === "worker") return "\u50cd\u304d\u30a2\u30ea"; if (target.type) { const type = String(target.type || ""); if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type]; const label = typeof toolLabel === "function" ? toolLabel(type) : ""; return label || type; } - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置"; - return "不明"; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u4f4d\u7f6e"; + return "\u4e0d\u660e"; }; TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { - if (!t) return "なし"; - if ((t.pinchThoughtTimer || 0) > 0) return "つままれている"; - const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim(); - if (behaviorText) return behaviorText; + if (!t) return "\u306a\u3057"; + if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; + 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 !== "なし" ? `${target}を食べている` : "食べている"; - if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している"; - if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている"; - if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している"; - if (t.state === "ant_intimidate") return "アリを威嚇している"; - if (t.state === "ant_attack") return "アリを攻撃している"; - if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている"; - if (t.state === "sunbath") return "晴れた日なたでストレスを下げている"; - if (t.state === "frozen") return "フィールド外で冷凍保存されている"; - if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている"; - if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している"; - if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している"; - if (t.state === "play_ball") return "ボールが気になっている"; - if (t.state === "seek_friend" && t.intent?.actionId === "approach_mate") return t.target?.name ? `${t.target.name}に繁殖のため近づいている` : "繁殖できる相手を探している"; - if (t.state === "seek_friend") return "仲間を探している"; - if (t.state === "follow_parent") return "親についていく"; - if (t.state === "cursor_friend") return "カーソルについていく"; - if (t.state === "cursor_enemy") return "カーソルを避けている"; - if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している"; - if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている"; - if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い"; - if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している"; - if ((t.fearTimer || 0) > 0.08) return "怖がっている"; - if ((t.grassEatTimer || 0) > 0.04) return "草を食べている"; + 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"; + if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b"; + if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; + if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; + if (t.state === "ant_attack") return "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; + if (t.state === "birth_ritual") return t.target?.name ? `${t.target.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 (t.state === "sunbath") return "\u6674\u308c\u305f\u65e5\u306a\u305f\u3067\u30b9\u30c8\u30ec\u30b9\u3092\u4e0b\u3052\u3066\u3044\u308b"; + if (t.state === "frozen") return "\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3055\u308c\u3066\u3044\u308b"; + if (t.state === "seek_food") return target !== "\u306a\u3057" ? `${target}\u3092\u63a2\u3057\u3066\u3044\u308b` : "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b"; + if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "seek_bed") return target !== "\u306a\u3057" ? `${target}\u3078\u5411\u304b\u3063\u3066\u3044\u308b` : "\u7720\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "play_ball") return "\u30dc\u30fc\u30eb\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; + if (t.state === "seek_friend" && (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate") return t.target?.name ? `${t.target.name}\u306b\u7e41\u6b96\u306e\u305f\u3081\u8fd1\u3065\u3044\u3066\u3044\u308b` : "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "seek_friend") return "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b"; + if (t.state === "follow_parent") return "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f"; + if (t.state === "cursor_friend") return "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; + if (t.state === "cursor_enemy") return "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; + if (t.state === "fight") return target !== "\u306a\u3057" ? `${target}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b"; + if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5\u3067\u8abf\u5b50\u304c\u60aa\u3044"; + if ((t.hurtTimer || 0) > 0.08) return "\u75db\u307f\u3067\u6df7\u4e71\u3057\u3066\u3044\u308b"; + if ((t.fearTimer || 0) > 0.08) return "\u6016\u304c\u3063\u3066\u3044\u308b"; + if ((t.grassEatTimer || 0) > 0.04) return "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"; const thought = String(t.thought || "").trim(); if (thought) return thought; - return "周囲を見ている"; + return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; }; function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); } 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 68d28f2..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(); @@ -189,7 +252,7 @@ function bindUI() { document.addEventListener("click", (e) => { if (!isActivePlacementTool()) return; if (clickedInsideGameOrToolUi(e.target)) return; - selectTool("observe"); // 道具設置モードを解除 + selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664 }); ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); }); ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog); @@ -279,552 +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 = "フィールドに戻された"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - showToast("フィールド外なので元の位置に戻しました。"); - uiCache.grabOrigin = null; - return true; - } - uiCache.grabOrigin = null; - return false; - } - const beginTouchGrab = (p, e) => { - const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; - if (!target) return false; - audio.grab?.(); - uiCache.grabbing = true; - uiCache.grabMoved = false; - uiCache.grabTarget = target; - uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; - rememberGrabOrigin(target); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - uiCache.grabStartX = e.touches?.[0]?.clientX || 0; - uiCache.grabStartY = e.touches?.[0]?.clientY || 0; - if (uiCache.grabKind === "tarinai") { - target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); - target.thought = "つままれている"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); - } else { - if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); - target.dropTimer = 0; - target.dropMax = 0; - target.dropImpactDone = true; - } - uiCache.touchMode = "grab"; - renderStats(); - return true; - }; - - canvas?.addEventListener("touchstart", (e) => { - if (!e.touches?.length) return; - e.preventDefault(); - uiCache.touchMoved = false; - const mode = mobileInputMode(); - if ((mode === "camera" || mode === "family") && e.touches.length === 1) { - const t = e.touches[0]; - uiCache.touchStartX = t.clientX; - uiCache.touchStartY = t.clientY; - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - uiCache.touchMode = "cameraPan"; - return; - } - if (e.touches.length >= 2) { - const c = touchCenter(e.touches); - uiCache.touchMode = "pinchZoom"; - uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches)); - uiCache.pinchStartZoom = world.fieldZoom || 1; - uiCache.pinchLastCenterX = c?.clientX || 0; - uiCache.pinchLastCenterY = c?.clientY || 0; - return; - } - const t = e.touches[0]; - const p = screenToWorldClient(t.clientX, t.clientY); - uiCache.touchStartX = t.clientX; - uiCache.touchStartY = t.clientY; - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - uiCache.touchLastWorldX = p.x; - uiCache.touchLastWorldY = p.y; - if (mode === "tool" && world.tool === "observe") { - uiCache.touchMode = "toolTapOnly"; - return; - } - if (world.tool === "water_hose" && p.inside) { - audio.waterHose?.(); - uiCache.hosing = true; - uiCache.hoseMoved = false; - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - uiCache.hoseStartX = t.clientX; - uiCache.hoseStartY = t.clientY; - world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); - uiCache.touchMode = "hose"; - render(); - return; - } - if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return; - uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap"; - }, { passive: false }); - - canvas?.addEventListener("touchmove", (e) => { - if (!e.touches?.length || !uiCache.touchMode) return; - e.preventDefault(); - if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) { - const c = touchCenter(e.touches); - const distNow = Math.max(1, touchDistance(e.touches)); - const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow); - setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio); - const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX); - const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY); - uiCache.pinchLastCenterX = c.clientX; - uiCache.pinchLastCenterY = c.clientY; - if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy); - uiCache.touchMoved = true; - render(); - return; - } - const t = e.touches[0]; - const p = screenToWorldClient(t.clientX, t.clientY); - const screenDx = t.clientX - (uiCache.touchLastX || t.clientX); - const screenDy = t.clientY - (uiCache.touchLastY || t.clientY); - const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY)); - uiCache.touchLastX = t.clientX; - uiCache.touchLastY = t.clientY; - if (uiCache.touchMode === "cameraPan") { - if (totalMove > 2) uiCache.touchMoved = true; - world.panCamera?.(-screenDx, -screenDy); - render(); - return; - } - if (uiCache.touchMode === "toolTapOnly") { - if (totalMove > 8) uiCache.touchMoved = true; - return; - } - if (uiCache.touchMode === "hose" && uiCache.hosing) { - const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); - const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; } - if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); - render(); - return; - } - if (uiCache.touchMode === "grab" && uiCache.grabbing) { - const target = uiCache.grabTarget; - if (!target || target.dead) { - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.touchMode = ""; - return; - } - const dx = p.x - (uiCache.grabLastWorldX ?? p.x); - const dy = p.y - (uiCache.grabLastWorldY ?? p.y); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } - const pad = target.radius || target.r || 16; - if (uiCache.grabKind === "tarinai" && !p.inside) { - const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0); - target.x = raw.x; - target.y = raw.y; - } else { - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); - } - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); - if (uiCache.grabKind === "tarinai") { - target.goIdle?.("つままれている"); - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - } else if (target.type === "ball") { - target.prevX = target.x - dx; - target.prevY = target.y - dy; - target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); - } - world.drawListDirty = true; - const now = performance.now(); - if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = now; - } - render(); - return; - } - if (uiCache.touchMode === "panOrTap") { - if (totalMove > 7) { - uiCache.touchMoved = true; - world.panCamera?.(-screenDx, -screenDy); - render(); - } - } - }, { passive: false }); - - canvas?.addEventListener("touchend", (e) => { - e.preventDefault(); - if (uiCache.hosing) { - uiCache.hosing = false; - world.rebuildSpatial?.(); - renderStats(); - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (target) { - if (uiCache.grabKind === "tarinai") { - target.target = null; - target.targetKey = ""; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); - finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; - } - } - audio.drop?.(); - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = 0; - renderStats(); - } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { - const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - world.handleClick(p.x, p.y); - renderStats(); - } - } else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) { - const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY); - if (p.inside) { - world.handleClick(p.x, p.y); - renderStats(); - } - } - if (!e.touches?.length) { - uiCache.touchMode = ""; - uiCache.touchMoved = false; - } - }, { passive: false }); - - canvas?.addEventListener("touchcancel", () => { - uiCache.touchMode = ""; - uiCache.touchMoved = false; - uiCache.hosing = false; - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - }, { passive: true }); - - - canvas?.addEventListener("contextmenu", (e) => e.preventDefault()); - canvas?.addEventListener("mousedown", (e) => { - if (e.button === 0 && world.tool === "water_hose") { - audio.waterHose?.(); - const p = screenToWorld(e); - if (!p.inside) return; - e.preventDefault(); - uiCache.hosing = true; - uiCache.hoseMoved = false; - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - uiCache.hoseStartX = e.clientX; - uiCache.hoseStartY = e.clientY; - world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10); - render(); - return; - } - if (e.button === 0 && world.tool === "pinch") { - const p = screenToWorld(e); - if (!p.inside) return; - const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; - if (!target) { - showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); - return; - } - e.preventDefault(); - audio.grab?.(); - uiCache.grabbing = true; - uiCache.grabMoved = false; - uiCache.grabTarget = target; - uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; - rememberGrabOrigin(target); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - uiCache.grabStartX = e.clientX; - uiCache.grabStartY = e.clientY; - if (uiCache.grabKind === "tarinai") { - target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); - target.thought = "つままれている"; - target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); - } else { - if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); - target.dropTimer = 0; - target.dropMax = 0; - target.dropImpactDone = true; - } - canvas.style.cursor = "grabbing"; - renderStats(); - return; - } - if (e.button !== 2) return; - e.preventDefault(); - uiCache.panning = true; - uiCache.panMoved = false; - uiCache.panButton = e.button; - uiCache.panStartX = e.clientX; - uiCache.panStartY = e.clientY; - uiCache.panLastX = e.clientX; - uiCache.panLastY = e.clientY; - }); - window.addEventListener("mouseup", (e) => { - if (uiCache.hosing) { - uiCache.hosing = false; - canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"))); - world.rebuildSpatial?.(); - renderStats(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (target) { - if (uiCache.grabKind === "tarinai") { - target.target = null; - target.targetKey = ""; - target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); - finishTarinaiGrab(target, e.clientX, e.clientY); - } else if (target.type === "ball") { - target.prevX = target.x; - target.prevY = target.y; - } - } - audio.drop?.(); - uiCache.grabbing = false; - uiCache.grabTarget = null; - uiCache.grabKind = ""; - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = 0; - canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - renderStats(); - return; - } - if (!uiCache.panning) return; - if (uiCache.panButton !== null && e.button !== uiCache.panButton) return; - uiCache.panning = false; - uiCache.panButton = null; - canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")); - }); - window.addEventListener("mousemove", (e) => { - if (uiCache.hosing) { - const p = screenToWorld(e); - const dx = p.x - (uiCache.hoseLastWorldX ?? p.x); - const dy = p.y - (uiCache.hoseLastWorldY ?? p.y); - uiCache.hoseLastWorldX = p.x; - uiCache.hoseLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY)); - if (totalMove > 3) uiCache.hoseMoved = true; - if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10); - canvas.style.cursor = "crosshair"; - render(); - return; - } - if (uiCache.grabbing) { - const target = uiCache.grabTarget; - if (!target || target.dead) { - uiCache.grabbing = false; - uiCache.grabTarget = null; - canvas.style.cursor = world.tool === "pinch" ? "grab" : "default"; - return; - } - const p = screenToWorld(e); - const dx = p.x - (uiCache.grabLastWorldX ?? p.x); - const dy = p.y - (uiCache.grabLastWorldY ?? p.y); - uiCache.grabLastWorldX = p.x; - uiCache.grabLastWorldY = p.y; - const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); - if (totalMove > 3) uiCache.grabMoved = true; - const pad = target.radius || target.r || 16; - if (uiCache.grabKind === "tarinai" && !p.inside) { - const raw = clientToWorldRaw(e.clientX, e.clientY); - target.x = raw.x; - target.y = raw.y; - } else { - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); - } - target.vx = clamp(dx * 16, -160, 160); - target.vy = clamp(dy * 16, -160, 160); - if (uiCache.grabKind === "tarinai") { - target.goIdle?.("つままれている"); - } else if (target.type === "ball") { - target.prevX = target.x - dx; - target.prevY = target.y - dy; - target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); - } - world.drawListDirty = true; - const now = performance.now(); - if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) { - world.rebuildSpatial?.(); - uiCache.grabLastSpatialAt = now; - } - canvas.style.cursor = "grabbing"; - return; - } - if (!uiCache.panning) return; - const dx = e.clientX - uiCache.panLastX; - const dy = e.clientY - uiCache.panLastY; - uiCache.panLastX = e.clientX; - uiCache.panLastY = e.clientY; - const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY); - if (totalMove > 8) { - canvas.style.cursor = "grabbing"; - world.panCamera?.(-dx, -dy); - uiCache.panMoved = true; - render(); - } - }); - - canvas?.addEventListener("click", (e) => { - if (uiCache.hoseMoved) { - uiCache.hoseMoved = false; - return; - } - if (uiCache.grabMoved) { - uiCache.grabMoved = false; - return; - } - if (uiCache.panMoved) { - uiCache.panMoved = false; - return; - } - const p = screenToWorld(e); - if (!p.inside) return; - world.handleClick(p.x, p.y); - renderStats(); - }); - - canvas?.addEventListener("mousemove", (e) => { - const p = screenToWorld(e); - const prevX = world.pointer.x; - const prevY = world.pointer.y; - world.pointer.x = p.x; - world.pointer.y = p.y; - world.pointer.inside = !!p.inside; - const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0)); - world.pointer.motion = clamp(delta / 18, 0, 2); - world.pointer.movedAt = performance.now(); - }); - canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false }); - canvas?.addEventListener("mouseleave", () => { - world.pointer.inside = false; - world.pointer.motion = 0; - }); + 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 d2217a4..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 || "のんびり"); + 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 1f150f2..376220c 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -2,9 +2,8 @@ function resizeCanvas() { const rect = canvas.getBoundingClientRect(); - const perf = world?.performanceLevel ? world.performanceLevel() : 0; - const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2); - const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr)); + const 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; @@ -48,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 39136b7..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); @@ -169,20 +158,20 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") { function selectedInventoryItems(t) { const worldRef = t?.world || world; - const labels = { grass_bed: "かんたんベッド", plushie: "ぬいぐるみ" }; + const labels = { grass_bed: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", plushie: "\u306c\u3044\u3050\u308b\u307f" }; const items = (worldRef?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id); const counts = new Map(); for (const it of items) { const label = labels[it.type] || toolLabel(it.type) || it.type; counts.set(label, (counts.get(label) || 0) + 1); } - return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} ×${count}` : label); + return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} \u00d7${count}` : label); } function selectedInventoryHtml(t) { const entries = selectedInventoryItems(t); - if (!entries.length) return `
    所持品なし
    `; - return `
    所持品${entries.map(v => `${escapeHtml(v)}`).join("")}
    `; + if (!entries.length) return `
    \u6240\u6301\u54c1\u306a\u3057
    `; + return `
    \u6240\u6301\u54c1${entries.map(v => `${escapeHtml(v)}`).join("")}
    `; } function selectedStateText(t) { @@ -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() : {}; @@ -216,9 +209,10 @@ function renderSelected() { ].join(","); const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : []; const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; + 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), - t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", t.activeBehavior?.id || "", t.activeBehavior?.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), @@ -273,7 +267,7 @@ function renderSelected() { const needRowsHtml = needKeys.map(key => { const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key; const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); - const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : ""; + const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : ""; return `
    ${escapeHtml(label)}${escapeHtml(value)}
    `; }).join(""); const healthHtml = [ @@ -286,13 +280,11 @@ function renderSelected() { ? itemEffectSummary.map(e => `
    ${escapeHtml(e.label)}${escapeHtml(e.detail)}
    `).join("") : `
    \u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u306a\u3057
    `; const relationHtml = ` -
    \u53cb\u9054${relationListHtml(t, "friend")}
    -
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    -
    \u3051\u3093\u304b\u52dd\u7387${escapeHtml(fightWinRateLabel(t))}
    - `; - const familyHtml = ` -
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    -
    \u5b50${t.children?.length || 0}
    +
    友達${relationListHtml(t, "friend")}
    +
    敵対${relationListHtml(t, "enemy")}
    +
    ${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "不明")}
    +
    ${t.children?.length || 0}
    +
    けんか勝率${escapeHtml(fightWinRateLabel(t))}
    `; ui.selectedInfo.innerHTML = `
    @@ -304,8 +296,7 @@ function renderSelected() { ${selectedDataCategoryHtml("health", "\u6b32\u6c42", healthHtml)} ${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)} ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} - ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} - ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} + ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2\u3068\u5bb6\u65cf", relationHtml)} ${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))} ${selectedDataCategoryHtml("records", "\u8a18\u9332", `
      ${recordHtml}
    `)}
    @@ -325,19 +316,22 @@ function stateLabel(s) { } function reasonLabel(t) { - if (!t) return "なし"; - const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim(); + if (!t) return "\u306a\u3057"; + 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; - if (t?.intent?.reasonText) return t.intent.reasonText; - return live || "なし"; + const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior; + if (behaviorState?.reason || behaviorState?.text) return behaviorState.reason || behaviorState.text; + return live || "\u306a\u3057"; } function targetLabel(target) { - return window.TEXT_CATALOG?.targetLabel?.(target) || "なし"; + return window.TEXT_CATALOG?.targetLabel?.(target) || "\u306a\u3057"; } function fightWinRateLabel(t) { @@ -355,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 37d6733..63cc52e 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -1,10 +1,11 @@ "use strict"; const SCALABLE_TOOLS = new Set(scalableToolIds()); +const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose"]); const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; -const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" }; +const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; function renderToolPalette() { if (!ui?.toolPalette || typeof toolCategories !== "function") return; @@ -23,11 +24,17 @@ 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.textContent = def.label || toolId; + btn.dataset.toolCategory = category.id || ""; + const label = document.createElement("span"); + label.className = "tool-label"; + label.textContent = def.label || toolId; + btn.title = label.textContent; + btn.appendChild(label); attachToolSpritePreview(btn, def, toolId); if (toolId === world?.tool) btn.classList.add("selected"); body.appendChild(btn); @@ -38,31 +45,47 @@ function renderToolPalette() { ui.toolPalette.appendChild(frag); } +function drawToolPreviewCanvas(canvas, itemType, { watermark = false } = {}) { + if (!canvas || !itemType) return false; + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (typeof window.drawToolItemPreview === "function") { + return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false; + } + if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType) && typeof window.drawConsumableFieldSprite === "function") { + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + const baseScale = itemType === "protein" || itemType === "niteropu" ? 0.9 : 1.0; + ctx.scale(baseScale, baseScale); + window.drawConsumableFieldSprite(ctx, itemType, 17.5, 112.4, { compact: true }); + ctx.restore(); + return true; + } + return false; +} + +function createToolPreviewCanvas(className, size = 96) { + const canvas = document.createElement("canvas"); + canvas.className = className; + canvas.width = size; + canvas.height = size; + canvas.setAttribute("aria-hidden", "true"); + return canvas; +} + function attachToolSpritePreview(btn, def, toolId) { const itemType = def?.itemType || toolId; if (!btn || !itemType || !def?.placeable) return; - const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite; - if (typeof drawPreview !== "function") return; - const canvas = document.createElement("canvas"); - canvas.className = "tool-sprite-preview"; - canvas.width = 72; - canvas.height = 72; - canvas.setAttribute("aria-hidden", "true"); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - let drawn = false; - if (typeof window.drawToolItemPreview === "function") { - drawn = window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true }) !== false; - } else if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType)) { - ctx.translate(canvas.width / 2, canvas.height / 2); - const scale = itemType === "protein" || itemType === "niteropu" ? 0.82 : 0.92; - ctx.scale(scale, scale); - window.drawConsumableFieldSprite(ctx, itemType, 13.5, 112.4, { compact: true }); - drawn = true; - } - if (!drawn) return; + if (toolId === "ball" || itemType === "ball") return; + if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return; + + const iconCanvas = createToolPreviewCanvas("tool-sprite-preview", 160); + const iconDrawn = drawToolPreviewCanvas(iconCanvas, itemType); + if (!iconDrawn) return; + btn.classList.add("tool-has-canvas-icon"); - btn.prepend(canvas); + btn.prepend(iconCanvas); } function toolSizeFor(tool) { 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 a4258ad..80322df 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.14"; - const APP_BUILD = "forced-behavior-sleep-rhythm"; + 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/weather_system.js b/js/weather_system.js index ea67d8c..360421c 100644 --- a/js/weather_system.js +++ b/js/weather_system.js @@ -20,7 +20,6 @@ shouldDropRainWater(worldRef, dt = 0) { return Boolean( worldRef?.weather === "light_rain" && - (worldRef.itemCounts?.water || 0) < 24 && Math.random() < Math.max(0, Number(dt) || 0) * 0.12 ); }, diff --git a/js/world.js b/js/world.js index a37b0be..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: "のんびり", description: "何も無し", 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 2b48aee..f425d3a 100644 --- a/js/world_ants_system.js +++ b/js/world_ants_system.js @@ -6,35 +6,40 @@ Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ updateAnts(dt) { if (!this.ants) this.ants = []; - const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest"); - for (const nest of nests) { - if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); - if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6; - if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); - nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt); - const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length; - const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); - const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0); - if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) { - const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32); - const a = new AntActor(this, { - kind: "worker", - homeId: nest.id, - x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18), - y: nest.y - nest.r * 1.00 + rand(-3, 3), - hp, - maxHp: ANT_WORKER_HP || 32, - }); - this.ants.push(a); - this.emit?.("ant:spawned", { ant: a, nest }); + this.antNestUpdateAccum = (this.antNestUpdateAccum || 0) + dt; + if (this.antNestUpdateAccum >= 3.0) { + const nestDt = this.antNestUpdateAccum; + this.antNestUpdateAccum = 0; + const nests = this.itemsOfType?.("ant_nest") || (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest"); + for (const nest of nests) { + if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest); + if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6; if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); - nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45; - this.drawListDirty = true; + nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - nestDt); + let outside = 0; + for (const a of this.ants) if (a && !a.dead && a.kind === "worker" && a.homeId === nest.id) outside += 1; + const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside); + const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0); + if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) { + const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32); + const a = new AntActor(this, { + kind: "worker", + homeId: nest.id, + x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18), + y: nest.y - nest.r * 1.00 + rand(-3, 3), + hp, + maxHp: ANT_WORKER_HP || 32, + }); + this.ants.push(a); + this.emit?.("ant:spawned", { ant: a, nest }); + if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest); + nest.antSpawnTimer = rand(2.5, 5.5) + outside * 0.65; + this.drawListDirty = true; + } } } const aliveAntCount = (this.ants || []).reduce((n, a) => n + (a && !a.dead ? 1 : 0), 0); - const q = this.performanceQuality ? this.performanceQuality() : null; - const stride = Math.max(1, Math.min(3, q?.antStride || (aliveAntCount >= 70 ? 3 : aliveAntCount >= 28 ? 2 : 1))); + const stride = aliveAntCount >= 28 ? 3 : 2; this.antUpdatePhase = ((this.antUpdatePhase || 0) + 1) % stride; for (let i = 0; i < this.ants.length; i++) { const a = this.ants[i]; @@ -110,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 d1fd863..f6afdec 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -79,8 +79,8 @@ if (t.addStress) t.addStress(22 * p, { threshold: 8 }); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); - if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); - else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); } + if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); + else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); } let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { @@ -94,7 +94,7 @@ panic: true, target: { x, y }, fearTimer: 1.7 + p, - thought: "爆竹の爆風でパニックになっている", + thought: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", }); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); @@ -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; @@ -181,12 +181,12 @@ panic: true, target: { x, y }, fearTimer: 1.7 + q, - thought: "爆発病の爆発に巻き込まれている", + thought: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", }); o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8); o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85); - if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" }); - else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); } + if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" }); + else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); } o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); @@ -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; @@ -279,29 +279,23 @@ it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160); it.stage = "fresh"; it.burstFromOshibyo = true; - this.items.push(it); - this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + this.addItem?.(it, "zunchi-burst") || this.items.push(it); } this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); - this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] }); + this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] }); audio.place?.("zunchi"); - this.rebuildSpatial?.(true); + this.ensureSpatial?.("zunchi-burst"); }, - spawnZunchi(x, y) { - const zunchiLimit = CONFIG.zunchiLimit ?? 56; - if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { - let oldest = null; - for (const it of this.items) { - if (it.type !== "zunchi" || it.dead) continue; - if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it; - } - if (oldest) oldest.amount = 0; - } + spawnZunchi(x, y, source = null) { const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); it.amount = 260; - this.items.push(it); - this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + it.stage = "fresh"; + it.producedById = source?.id || ""; + it.producedAt = this.time || 0; + it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0); + it.eatProtectedUntil = (this.time || 0) + 90; + this.addItem?.(it, "zunchi-spawn") || this.items.push(it); audio.place?.("zunchi"); }, @@ -392,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; @@ -439,32 +406,11 @@ entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; } if (options.panic && entity instanceof Tarinai) { - if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" }); - else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); } + if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" }); + else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); } } return true; }, - - applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { - if (!entity || !Number.isFinite(strength)) return false; - let dx = (entity.x || 0) - sourceX; - let dy = (entity.y || 0) - sourceY; - let d = Math.hypot(dx, dy); - if (!Number.isFinite(d) || d < 0.001) { - const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); - dx = Math.cos(a); - dy = Math.sin(a); - d = 1; - } - const random = options.random || 0; - return this.applyImpulse( - entity, - dx / d * strength + rand(-random, random), - dy / d * strength + rand(-random, random), - options, - ); - }, - applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; const normalized = options.cause || cause || "\u885d\u7a81"; @@ -492,8 +438,8 @@ victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42)); this.emit?.("fight:lost", { winner: attacker, loser: victim, damage }); - if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" }); - else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } + if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" }); + else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } const dx = victim.x - attacker.x; const dy = victim.y - attacker.y; const d = Math.hypot(dx, dy) || 1; @@ -579,9 +525,9 @@ t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 }); t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); - t.thought = "げんこつがこわい"; + t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044"; } else { - t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している"; + t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b"; } t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); @@ -613,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(); @@ -641,7 +576,7 @@ return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { - t.goIdle("ボールが混み合っているので眺めている"); + t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"); t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); } } @@ -682,8 +617,9 @@ if ((this.itemCounts?.ball || 0) < 2) return; const now = this.time || 0; if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map(); - const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball"); + const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball"); for (const a of balls) { + if (!a || a.dead || a.type !== "ball") continue; const ar = a.r || 18; const avx = a.vx || 0; const avy = a.vy || 0; @@ -769,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; @@ -816,14 +752,14 @@ panic: true, target: { x: ball.x, y: ball.y }, fearTimer: 0.95, - thought: "高速のボールにぶつかってパニックになっている", + thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", }); this.applyImpactDamage(t, damage, "\u885d\u7a81"); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); if (t.addStress) t.addStress(stressGain, { threshold: 8 }); - if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); - else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true }); + if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); + else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); @@ -929,7 +865,7 @@ panic: true, target: { x: impactor.x, y: impactor.y }, fearTimer: 0.55, - thought: "高速でぶつかって混乱している", + thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", }); impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); @@ -937,8 +873,8 @@ if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); for (const t of [target, impactor]) { if (!t || t.dead) continue; - if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); - else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true }); + if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); + else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true }); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); 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 514fb38..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; @@ -39,6 +46,7 @@ this.familyVersion = 0; this.relationNotices = {}; this.resolvedFightIds = {}; + this.fightPairCooldowns = {}; this.foodSpoilagePenalty = 0; this.foodSpoilageEvents = 0; this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 }; @@ -50,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; @@ -62,8 +71,15 @@ generation: 1, }); } - for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); - this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); + 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"); @@ -73,7 +89,7 @@ }, tarinaiFamilyKey(t) { - return t?.familyKey || t?.archiveKey || t?.id || ""; + return t?.familyKey || t?.id || ""; }, assignTarinaiLiveId(t) { @@ -342,7 +358,7 @@ if (!other || other === dead || other.dead) continue; if (other.target === dead || other.target?.id === deadId) { other.target = null; - if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった"); + if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("\u53c2\u7167\u3057\u3066\u3044\u305f\u76f8\u624b\u304c\u3044\u306a\u304f\u306a\u3063\u305f"); changed = true; } if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } @@ -372,7 +388,6 @@ this.markFamilyTreeDirty("death"); } } - this.notifyDeathToRelations?.(t, finalReason); this.familyPrunePending = true; }, @@ -405,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; @@ -428,11 +501,35 @@ b.birthRitualRole = 1; a.birthRitualLeader = true; b.birthRitualLeader = false; - a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" }); - b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" }); - if (typeof setLiveActionText === "function") { - setLiveActionText(a, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${b.name || "相手"}と繁殖の前ぶれをしている`, target: b }); - setLiveActionText(b, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${a.name || "相手"}と繁殖の前ぶれをしている`, target: a }); + 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`; + 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); @@ -447,8 +544,8 @@ a.birthRitualTimer = b.birthRitualTimer = 0; a.birthPartnerId = b.birthPartnerId = null; a.birthRitualLeader = b.birthRitualLeader = false; - a.goIdle("誕生の前ぶれが終わった"); - b.goIdle("誕生の前ぶれが終わった"); + a.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f"); + b.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f"); if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) { a.birthRitualRole = b.birthRitualRole = 0; return null; @@ -573,17 +670,44 @@ 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; }, + fightPairKey(a, b) { + const aid = a?.id || a?.familyKey || ""; + const bid = b?.id || b?.familyKey || ""; + if (!aid || !bid) return ""; + return String(aid) < String(bid) ? `${aid}:${bid}` : `${bid}:${aid}`; + }, + + fightPairCooldownRemaining(a, b) { + const key = this.fightPairKey?.(a, b); + if (!key) return 0; + const until = Number(this.fightPairCooldowns?.[key] || 0) || 0; + return Math.max(0, until - (this.time || 0)); + }, + + markFightPairCooldown(a, b, seconds = 3.2) { + const key = this.fightPairKey?.(a, b); + if (!key) return; + this.fightPairCooldowns = this.fightPairCooldowns || {}; + this.fightPairCooldowns[key] = Math.max(Number(this.fightPairCooldowns[key] || 0) || 0, (this.time || 0) + Math.max(0.2, Number(seconds) || 3.2)); + }, + + isAlreadyFightingPair(a, b) { + if (!a || !b) return false; + return a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id) && ((b.fightTargetIds || []).includes(a.id) || b.fightTargetId === a.id); + }, + startForcedFight(a, b) { if (!a || !b || a === b || a.dead || b.dead) return false; if (!this.canFightPair(a, b)) return false; - const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id); + const alreadyFighting = this.isAlreadyFightingPair?.(a, b); if (alreadyFighting) return true; + if ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04) return false; + if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false; for (const t of [a, b]) { t.birthRitualTimer = 0; t.birthRitualMax = 0; @@ -601,7 +725,7 @@ t.goIdle(""); } this.startFightMochiIntimidation(a, b); - this.startFight(a, b); + this.startFight(a, b, { forced: true, initiator: a }); return true; }, @@ -629,33 +753,36 @@ }, startConflict(a, b) { - if (!a || !b || a.dead || b.dead) return; - if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return; - if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; } - if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; + if (!a || !b || a.dead || b.dead) return false; + if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return false; + if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) return this.startForcedFight(a, b); + if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return false; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false; + if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false; + if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false; const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const actor = scoreA >= scoreB ? a : b; const target = actor === a ? b : a; - if (!this.canFightPair(actor, target)) return; + if (!this.canFightPair(actor, target)) return false; const actorScore = actor === a ? scoreA : scoreB; const targetScore = actor === a ? scoreB : scoreA; const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04; const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90); if (canIntimidate && Math.random() < intimidationChance) { - this.startIntimidation(actor, target, actorScore, targetScore); - return; + return this.startIntimidation(actor, target, actorScore, targetScore); } - this.startFight(a, b); + return this.startFight(actor, target, { initiator: actor }); }, startIntimidation(actor, target, actorScore = 0, targetScore = 0) { if (!actor || !target || actor.dead || target.dead) return false; + if ((actor.fightTimer || 0) > 0.04 || (target.fightTimer || 0) > 0.04) return false; + if ((actor.intimidateTimer || 0) > 0.04 || (target.intimidateTimer || 0) > 0.04) return false; + if (this.fightPairCooldownRemaining?.(actor, target) > 0.04) return false; if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8; const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0); @@ -670,17 +797,17 @@ target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); - actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); + actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65)); actor.intimidateTargetId = target.id; - actor.setActionState("intimidate", { target, reason: "相手を威嚇している" }); - if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: `${target.name || "相手"}を威嚇している`, target, phase: "perform", source: "behavior" }); + actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); + 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, color: "rgba(145, 106, 55, 0.70)", })); this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55)); target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); if (Math.random() >= successChance) { actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation"); @@ -688,20 +815,20 @@ actor.intimidateTimer = 0; actor.intimidateTargetId = null; target.intimidatedTimer = 0; - if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した"); - if (target.state === "panic") target.goIdle("威嚇から戻った"); + if (actor.state === "intimidate") actor.goIdle("\u5a01\u5687\u306b\u5931\u6557\u3057\u305f"); + if (target.state === "panic") target.goIdle("\u5a01\u5687\u304b\u3089\u623b\u3063\u305f"); actor.spriteLockUntil = 0; target.spriteLockUntil = 0; - this.startFight(actor, target); + this.startFight(actor, target, { initiator: actor }); return true; } actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation"); target.defeatedById = actor.id; target.fightWinnerId = actor.id; - target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); - if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" }); - else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); } + target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0)); + if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" }); + else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); } this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); target.vy += rand(-18, 18); @@ -711,21 +838,41 @@ this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] }); actor.lastLog = target.lastLog = this.time; } + this.markFightPairCooldown?.(actor, target, 2.8); return true; }, - startFight(a, b) { - if (!a || !b || a.dead || b.dead) return; - if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return; - if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` }); - b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` }); - if (typeof setLiveActionText === "function") { - setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${b.name || "相手"}と喧嘩している`, target: b, phase: "perform", source: "behavior" }); - setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${a.name || "相手"}と喧嘩している`, target: a, phase: "perform", source: "behavior" }); + startFight(a, b, opts = {}) { + if (!a || !b || a.dead || b.dead) return false; + if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return false; + if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false; + if (this.isAlreadyFightingPair?.(a, b)) return true; + const allowForcedStart = Boolean(opts?.forced); + if (!allowForcedStart && ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04 || (a.intimidateTimer || 0) > 0.04 || (b.intimidateTimer || 0) > 0.04)) return false; + if (!allowForcedStart && this.fightPairCooldownRemaining?.(a, b) > 0.04) return false; + this.markFightPairCooldown?.(a, b, 3.4); + const initiator = opts?.initiator === b ? b : a; + const receiver = initiator === a ? b : a; + const causeFor = (self, other) => { + if (((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(self) : self.behavior)?.source === "fight_mochi") || (self.fightMochiTimer || 0) > 0.04) return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c"; + if (self === receiver && other === initiator) return `${other.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`; + return `${other.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`; + }; + const textFor = (self, other) => typeof buildReasonText === "function" + ? buildReasonText("social", (typeof getTarinaiBehaviorTiedNeeds === "function" ? getTarinaiBehaviorTiedNeeds(self, "social") : self.behavior?.tiedNeeds) || ["social"], { id: "fight_rival", need: "social", subNeed: "conflict", label: self === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` }, self, this, { causeText: causeFor(self, other) }) + : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b`; + const reasonA = textFor(a, b); + const reasonB = textFor(b, a); + a.counterAttackFromId = a === receiver ? initiator.id : null; + b.counterAttackFromId = b === receiver ? initiator.id : null; + a.setActionState("fight", { target: b, reason: reasonA }); + b.setActionState("fight", { target: a, reason: reasonB }); + 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(4.5, 7.0)); - b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); + a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8)); + b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8)); a.nextHeadbutt = this.time + rand(0.08, 0.18); b.nextHeadbutt = a.nextHeadbutt; a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); @@ -758,9 +905,10 @@ })); 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; } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 6a82d6e..1a7bf30 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -18,10 +18,10 @@