From d5c661bc227b88736bb92b28732fc7bc830be719 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 24 Jun 2026 13:48:22 +0900 Subject: [PATCH 1/5] stable --- PATCH_NOTES.md | 110 +- README.md | 6 +- app_manifest.json | 5 +- assets/objects/plushie.webp | Bin 0 -> 810 bytes css/base.css | 8 +- docs/ARCHITECTURE.md | 22 +- docs/COMPACT_SAVE_V2_REPORT.md | 37 + docs/PLAYER_VISIBLE_DIFF_REPORT.md | 52 + docs/REGRESSION_CHECKLIST.md | 45 + docs/REMAINING_WORK_SPLIT.md | 46 + docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md | 63 + docs/SOCIAL_BALANCE_FIX_REPORT.md | 28 + index.html | 136 +- js/ants.js | 10 +- js/data.js | 21 +- js/debug_tools.js | 8 +- js/effect_registry.js | 12 +- js/freeze_system.js | 32 +- js/health.js | 2 +- js/items.js | 85 +- js/relation_event_system.js | 32 +- js/render.js | 83 +- js/save_system.js | 189 +- js/snapshot_system.js | 467 ++++- js/structures.js | 28 +- js/tarinai.js | 12 +- js/tarinai_action_spec.js | 201 ++ js/tarinai_action_state.js | 25 +- js/tarinai_behavior_state.js | 417 ++++ js/tarinai_disease_nest.js | 16 +- js/tarinai_identity_social.js | 4 +- js/tarinai_item_effects.js | 18 +- js/tarinai_needs_items.js | 1832 +++++++++++++---- js/tarinai_render.js | 10 - js/tarinai_social_move_life.js | 30 +- js/text_catalog.js | 116 +- js/ui_bind.js | 22 +- js/ui_charts.js | 2 +- js/ui_selected.js | 39 +- js/ui_tools.js | 3 +- js/version.js | 4 +- js/world.js | 2 +- js/world_ants_system.js | 2 +- js/world_combat_effects.js | 51 +- js/world_family_social.js | 143 +- js/world_placement_log.js | 72 +- js/world_spatial_budget.js | 150 +- js/world_update.js | 88 +- js/world_view.js | 3 + .../regression_check.cpython-313.pyc | Bin 0 -> 19344 bytes scripts/generate_app_files.py | 21 +- scripts/regression_check.py | 408 ++++ service-worker.js | 4 +- 53 files changed, 4268 insertions(+), 954 deletions(-) create mode 100644 assets/objects/plushie.webp create mode 100644 docs/COMPACT_SAVE_V2_REPORT.md create mode 100644 docs/PLAYER_VISIBLE_DIFF_REPORT.md create mode 100644 docs/REGRESSION_CHECKLIST.md create mode 100644 docs/REMAINING_WORK_SPLIT.md create mode 100644 docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md create mode 100644 docs/SOCIAL_BALANCE_FIX_REPORT.md create mode 100644 js/tarinai_action_spec.js create mode 100644 js/tarinai_behavior_state.js create mode 100644 scripts/__pycache__/regression_check.cpython-313.pyc create mode 100644 scripts/regression_check.py diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index c17894a..201434d 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,4 +1,96 @@ -# Patch Notes 15.24.12 +# 15.24.35 + +- 持ち主不明のかんたんベッドは、最初に利用した個体の所有物になるよう変更。 +- かんたんベッド同士が近すぎる位置に作られにくいよう、配置ブロックと建築位置選定を調整。 +- 複製機にセットされた食べ物の種類を、食べ物探索の優先順位と接触距離ソートに反映。 + +# 15.24.34 + +- ぬいぐるみを添付スプライトに差し替え、白背景を透過化。 +- たりないデータの「関係」と「家族」を1セクションに統合。 +- 観察中の友達/敵対ハイライト判定を相互関係ベースに拡張。 +- かんたんベッドで睡眠中の個体に、親子でも友達でもない個体が寝に来た場合は喧嘩開始。 + +# Patch Notes 15.24.33 + +## 15.24.33 + +- Version: `15.24.33`. +- Build: `compact-save-v2`. +- Added item-bucket dirty tracking, `ensureItemBuckets()`, `itemById()`, `liveItemsOfType()`, and `addItem()` to keep type buckets and id lookup coherent after runtime item additions. +- Routed common item creation paths through `World.addItem()` for placements, rain water, grass growth/spawn, zunchi burst/spawn, ant corpses/nests, Tarinai death traces, and constructed owned structures. +- Expanded runtime diagnostics with bucket dirty state, item id-map size, and bucket rebuild counters. +- Reworked several hot item lookups to use buckets or id-map helpers where safe. +- Updated regression checks for item-bucket helper availability and direct item push documentation. +- Added final pre-save runtime-polish report and remaining-work split note. +- Did not touch save migration, population/family limits, or family-tree UI. + +# Patch Notes 15.24.31 + +## 15.24.31 + +- Version: `15.24.31`. +- Build: `social-balance-runtime-polish`. +- Fixed social-action balance regression where conflict could dominate too often after ActionSpec migration. +- Reduced normal conflict pressure, lowered fight/intimidation priority, and raised mate/bond selection weight. +- Added pair-level fight cooldown guards so one pair/individual cannot trigger repeated fight starts in the same burst. +- Shortened intimidation/defeated display timers. +- Preserved fight mochi as a strong forced exception while reducing ordinary proximity-fight probability. +- Added regression checks for social balance constants and fight re-entry guards. +- Did not touch save migration, population/family limits, or family-tree UI. + +# Patch Notes 15.24.30 + +## 15.24.30 + +- Version: `15.24.30`. +- Build: `action-spec-sunbath-runtime-check`. +- Moved `sunbath` into `createSunbathActionSpec()` and registered it directly in `TARINAI_ACTION_DEFINITIONS`. +- Removed the remaining `configureTarinaiNeedActions()` late-binding bridge. +- Kept the existing sunbath body/timer subsystem intact; player-visible sunbath behavior should remain unchanged. +- Updated regression checks so late action patching is treated as a failure. +- Did not touch save migration, population/family limits, or family-tree UI. + +# Patch Notes 15.24.29 + +## 15.24.29 +- Migrated social/fear/combat/mate/birth runtime actions into ActionSpec factories. +- Kept family data, family tree UI, population limits, and save migration untouched. +- Added regression checks for pass 3 ActionSpec migration. +- Build: `action-spec-social-runtime-check`. + +# Patch Notes 15.24.28 + +## 15.24.28 + +- Added centralized behavior-text composition through `composeTarinaiBehaviorText()` and `patchBehaviorTextFromComposer()`. +- Added `textTarinaiAction()` to the ActionSpec lifecycle helpers. +- Kept existing explicit live behavior text as the default display fallback so player-facing wording does not churn during normal observation. +- Added a player-visible diff check comparing the current build against the initial 15.24.20 ZIP. +- Kept save migration, population/family caps, and family-tree UI out of scope. + +# Patch Notes 15.24.25 + +## 15.24.25 + +- Added `scripts/regression_check.py` and `docs/REGRESSION_CHECKLIST.md` to freeze the current generated-file, syntax, and ActionSpec lifecycle baseline. +- Expanded `js/tarinai_action_spec.js` with explicit lifecycle helpers: `selectTarinaiActionTarget`, `canStartTarinaiAction`, `startTarinaiAction`, `tickTarinaiAction`, `finishTarinaiAction`, and `failTarinaiAction`. +- Migrated need-action definitions from legacy `condition/run/update` shape toward explicit `selectTarget/canStart/start/tick` ActionSpec fields. +- Routed priority/action eligibility through `canStartTarinaiAction()` while preserving legacy fallback behavior. +- Added finish/fail lifecycle calls for completed or failed action ticks. +- Kept save migration, population caps, family caps, and family-tree UI out of scope. + +# Patch Notes 15.24.23 + +## 15.24.23 + +- Added coarse-grained spatial invalidation via `markSpatialDirty()` + `ensureSpatial()` for the main update loop. +- Reduced redundant spatial rebuilds around item, ball, ant, and Tarinai update phases while keeping direct `rebuildSpatial()` calls compatible. +- Added per-type item buckets in `updateItemCounts()` and switched ball-ball collision setup to `itemsOfType("ball")`. +- Reworked render layering into a cached dynamic render stack so expensive y-sort work runs on a short interval or count/signature change, not every paint. +- Kept population and family-tree UI behavior unchanged. + +# Patch Notes 15.24.22 - 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. @@ -9,7 +101,21 @@ - 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 +## 15.24.22 +- `js/tarinai_action_spec.js` を追加し、行動を `ActionSpec` として登録できる共通層を導入。 +- 既存の `condition` / `run` / `update` を維持しつつ、`canStart` / `start` / `tick` / `finish` / `fail` / `text` に段階移行できる互換ラッパーを追加。 +- `TARINAI_ACTIONS` を `TARINAI_ACTION_DEFINITIONS` から登録生成する形に変更。既存参照は維持。 +- 行動開始・継続処理を `startTarinaiAction()` / `tickTarinaiAction()` 経由へ移行。 +- `behavior` に `specId` / `specKind` を保持し、表示・旧API互換と行動仕様の対応を追いやすくした。 + +## 15.24.21 +- 外部強制行動を queued / active / retry / expire の状態機械に拡張。 +- 強制行動の source / forcedReasonText を activeBehavior に保持し、旧タイマー同期で失われにくくした。 +- 関係欲求の開始判定を social 大分類ではなく bond / family / mate / conflict のサブ欲求に寄せた。 +- 食事行動を activeBehavior 内で対象接近から摂食完了まで完結するよう整理。 +- 行動選択時に高い未選択欲求がある場合、「眠たいけど、食べている」のように対立理由を表示。 + +## 15.24.21 - 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。 - へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。 - 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。 diff --git a/README.md b/README.md index 7a87766..a5324c1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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..91e069b 100644 --- a/js/ants.js +++ b/js/ants.js @@ -344,10 +344,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 +370,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/data.js b/js/data.js index 4374c72..127727e 100644 --- a/js/data.js +++ b/js/data.js @@ -44,6 +44,7 @@ const DECOR_ASSETS = [ { id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" }, { id: "oshibyo", path: "assets/objects/oshibyo.webp" }, { id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" }, + { id: "plushie", path: "assets/objects/plushie.webp" }, ]; const TOOL_DEFINITIONS = Object.freeze({ @@ -75,9 +76,9 @@ const TOOL_DEFINITIONS = Object.freeze({ 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: "食べ物や薬をセットすると、自動供給してくれる。" }, + 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" }, @@ -133,11 +134,11 @@ function pinBehaviorFor(type = "") { 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"] }, + { 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() { @@ -171,7 +172,7 @@ function itemAmountFor(type = "", fallback = 80) { } const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); -const TOOL_SIZE_LABELS = 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); @@ -255,7 +256,7 @@ const CONFIG = { weatherChangeMin: 46, weatherChangeMax: 96, bubbleLimit: 32, - grassLimit: 180, + grassLimit: 360, highPopulationMode: 70, ultraPopulationMode: 120, }; diff --git a/js/debug_tools.js b/js/debug_tools.js index 30a7b7c..c8b6869 100644 --- a/js/debug_tools.js +++ b/js/debug_tools.js @@ -21,13 +21,19 @@ 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(", "); panel.textContent = [ `tarinai v${window.TARINAI_VERSION || "?"}`, `fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`, `items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`, `effects ${(w?.effects || []).length}`, `terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`, - `drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`, + `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}`, + `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)}`, diff --git a/js/effect_registry.js b/js/effect_registry.js index 6ee71de..115ffb9 100644 --- a/js/effect_registry.js +++ b/js/effect_registry.js @@ -111,8 +111,8 @@ 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: "へこ餅で繁殖できる相手を探している" }); + 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; }, @@ -125,8 +125,8 @@ 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: "けんか餅で気に入らない相手を探している" }); + 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; }, @@ -141,8 +141,8 @@ 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.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; } diff --git a/js/freeze_system.js b/js/freeze_system.js index 61c99c8..7c6c1d4 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -47,8 +47,8 @@ .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-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; } @@ -162,8 +162,8 @@ worldRef.rebuildSpatial?.(true); saveFrozenStore(worldRef); renderFrozenPanel(worldRef); - global.showToast?.(`${t.name}を冷凍保存しました。`); - worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] }); + global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`); + worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] }); global.renderStats?.(); global.render?.(); return true; @@ -194,18 +194,18 @@ seen.add(key); unique.push(loss); } - const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0; + const relPriority = unique.some(l => l.relation === "\u89aa" || l.relation === "\u5b50") ? 1 : 0; const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); - if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); + if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); else { if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 }); t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); - t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, wake: true }); } - t.bubble?.("いない", 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] }); + t.bubble?.("\u3044\u306a\u3044", 1.1, "rgba(60,50,68,0.82)"); + t.recordChangeCause?.("\u51b7\u51cd\u4e2d\u306e\u8a03\u5831", "\u30b9\u30c8\u30ec\u30b9", { value: stress }); + t.addRecord?.(`\u51b7\u51cd\u4e2d\u306b${unique.map(l => l.name || "\u8ab0\u304b").slice(0, 3).join("\u3001")}\u304c\u6b7b\u4ea1\u3057\u3066\u3044\u305f\u3053\u3068\u306b\u6c17\u3065\u3044\u305f\u3002`, "death"); + worldRef.log?.(`${t.name}\u306f\u89e3\u51cd\u6642\u306b\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u304d\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death", { participants: [t] }); } function thawFrozen(freezeId, x, y, worldRef = global.world) { @@ -230,9 +230,9 @@ t.panicTarget = null; t.insideNestBoxId = ""; t.sleeping = false; - if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている"); + 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 = "解凍されて少し驚いている"; + 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(); @@ -247,8 +247,8 @@ worldRef.rebuildSpatial?.(true); saveFrozenStore(worldRef); renderFrozenPanel(worldRef); - global.showToast?.(`${t.name}を解凍しました。`); - worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] }); + global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`); + worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] }); global.renderStats?.(); global.render?.(); return true; @@ -284,7 +284,7 @@ if (!card) return; pendingThawId = card.dataset.frozenId || ""; for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card); - global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。"); + global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002"); }); } const canvas = global.canvas || document.getElementById("gameCanvas"); 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/items.js b/js/items.js index 8fd40ea..d6fc648 100644 --- a/js/items.js +++ b/js/items.js @@ -210,7 +210,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); @@ -356,7 +356,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", @@ -467,10 +467,12 @@ class Item { this.lastPokeAngle = rand(0, Math.PI * 2); } if (type === "zunchi") { + this.amount = Math.max(Number(this.amount || 0) || 0, 240); this.stage = "fresh"; this.stageTimer = 0; this.fertility = 0; this.freshness = 1; + this.spawnGrace = 1.2; this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02"; } if (type === "ant_nest") { @@ -528,6 +530,7 @@ class Item { } 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); @@ -687,11 +690,11 @@ class Item { 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" }); + 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: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); + 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); @@ -924,11 +927,14 @@ class Item { 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); for (const t of worldRef.tarinai) { 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; } @@ -948,7 +954,10 @@ class Item { 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)); + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = 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; @@ -962,23 +971,23 @@ class Item { 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] }); + 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: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); + 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: "画鋲が刺さってパニックになっている", wake: true }); + 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, "画鋲"); + 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}に画鋲が刺さった。`, "accident", { participants: [t] }); + 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; @@ -994,29 +1003,32 @@ class Item { 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; + const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1); + const visualSide = 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 = isOshibyo ? 0.52 : 0.28; + 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 = "おしり鋲でずんちが溜まってつらい"; + 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, "画鋲"); + 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: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); + 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: "画鋲が刺さってパニックになっている", wake: true }); + 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); @@ -1038,6 +1050,16 @@ class Item { 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; @@ -1104,14 +1126,14 @@ class Item { 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; + const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * (2 / 3); this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88); this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1); const 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); + this.seedTimer = rand(9, 17); } if (this.age > (this.lifeSpan || 640) * 0.82) { const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1); @@ -1123,10 +1145,10 @@ class Item { } 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; + this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * (2 / 3); 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); + this.seedTimer = rand(12, 27); + if (Math.random() < 0.62 + this.fertilityBoost * 0.22) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1); } } seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) { @@ -1141,8 +1163,7 @@ class Item { 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; + worldRef.addItem?.(sprout, "grass-seed") || worldRef.items.push(sprout); made += 1; } return made; @@ -1159,21 +1180,22 @@ class Item { waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1); } this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3); + const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt; if (this.stage === "fresh") { this.freshness = clamp(1 - this.stageTimer / 110, 0, 1); - this.amount -= dt * 0.05 * decayBoost; + this.amount -= decayDt * 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; + this.amount -= decayDt * 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; + this.amount -= decayDt * 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; + this.amount -= decayDt * 0.26 * decayBoost; } } get dead() { @@ -1185,6 +1207,10 @@ class Item { 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?.tarinai?.find?.(o => o && !o.dead && o.id === 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; @@ -1274,6 +1300,7 @@ class Item { 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; @@ -1546,7 +1573,7 @@ class Item { 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.fillText("\u2026", 0, -this.r * 0.58); } ctx.restore(); } else if (this.type === "ant_nest") { diff --git a/js/relation_event_system.js b/js/relation_event_system.js index 4d7cd80..7310956 100644 --- a/js/relation_event_system.js +++ b/js/relation_event_system.js @@ -10,31 +10,35 @@ 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 "親"; + if (deadFamily && (observer.parents || []).includes(deadFamily)) return "\u89aa"; + if (deadFamily && (observer.children || []).includes(deadFamily)) return "\u5b50"; + if (observerFamily && (dead.parents || []).includes(observerFamily)) return "\u5b50"; + if (observerFamily && (dead.children || []).includes(observerFamily)) return "\u89aa"; const rel = deadId ? observer.relationships?.[deadId] : null; const reverse = observerId ? dead.relationships?.[observerId] : null; - if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達"; + if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "\u53cb\u9054"; return ""; } function applyLiveDeathShock(observer, dead, relation, worldRef) { - const strong = relation === "親" || relation === "子"; - if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた"); + const strong = relation === "\u89aa" || relation === "\u5b50"; + if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u3044\u305f"); const stress = strong ? 18 : 11; const fear = strong ? 2.1 : 1.45; - if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); + if (observer.enterPanic) { + const relLabel = relation || "\u8eab\u8fd1\u306a\u500b\u4f53"; + observer.lastPanicDetail = `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u305f`; + observer.enterPanic({ threat: dead, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); + } else { observer.sleeping = false; - observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true }); + observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relation || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, wake: true }); observer.fearTimer = Math.max(observer.fearTimer || 0, fear); if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 }); observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); } - observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress }); - observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death"); + observer.recordChangeCause?.(`${relation}\u306e\u6b7b\u4ea1`, "\u30b9\u30c8\u30ec\u30b9", { value: stress }); + observer.addRecord?.(`${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\u306e\u6b7b\u306b\u6c17\u3065\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death"); } function notifyDeathToRelations(dead, reason = "") { @@ -59,16 +63,16 @@ if (!duplicate) { entry.pendingLosses.unshift({ relation, - name: dead.name || "たりない", + name: dead.name || "\u305f\u308a\u306a\u3044", familyKey: deadFamily, liveId: deadId, - reason: reason || dead.deathReason || "死亡", + reason: reason || dead.deathReason || "\u6b7b\u4ea1", time: worldRef.time || 0, }); if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES; } } - if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] }); + if (liveHits) worldRef.log?.(`${dead.name || "\u305f\u308a\u306a\u3044"}\u306e\u6b7b\u306b\u3001\u8eab\u8fd1\u306a\u500b\u4f53\u304c\u52d5\u63fa\u3057\u305f\u3002`, "death", { participants: [dead] }); global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef); global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); } diff --git a/js/render.js b/js/render.js index 3612151..4d775dc 100644 --- a/js/render.js +++ b/js/render.js @@ -348,6 +348,55 @@ 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 ensureBackgroundCache(w, h, lighting) { const key = backgroundCacheKey(world, lighting); if (!backgroundCache.canvas) { @@ -756,19 +805,12 @@ function render() { drawPlacementPreview(ctx, world); if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH); - // 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) }); + // Dynamic render stacks are sorted on a short interval rather than every paint. + // Visibility is still checked per frame, so camera movement remains responsive. + const renderStack = ensureWorldRenderStack(world); + 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; @@ -786,20 +828,15 @@ function render() { ctx.setLineDash([]); ctx.restore(); - 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); diff --git a/js/save_system.js b/js/save_system.js index 52b26a2..9738749 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -1,57 +1,152 @@ "use strict"; (function (global) { - const STORAGE_PREFIX = "tarinai_save_slot_v1_"; - const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; + const STORAGE_PREFIX = "tarinai_save_slot_v2_"; + const EXPORT_PREFIX = "TN2!"; const SLOT_COUNT = 9; const Snapshot = global.TarinaiSnapshot; if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); const createSnapshot = (...args) => Snapshot.createSnapshot(...args); const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); - 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, ""); + 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 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); + 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; } - function encodeSnapshot(snapshot) { - const json = JSON.stringify(snapshot); - const bytes = new TextEncoder().encode(json); - return EXPORT_PREFIX + bytesToBase64(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; } - function decodeSnapshot(text) { + async function encodeSnapshot(snapshot) { + const compact = snapshot && snapshot.v === 2 ? snapshot : createSnapshot(global.world); + const json = JSON.stringify(compact); + const bytes = new TextEncoder().encode(json); + let codec = "R"; + let packed = bytes; + try { + const compressed = await compressBytes(bytes); + if (compressed.length < bytes.length) { + codec = "D"; + packed = compressed; + } + } catch (error) { + console.warn("save compression fallback", error); + } + return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`; + } + + async function 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); + if (raw.startsWith("{") || raw.startsWith("[")) { + const data = JSON.parse(raw); + if (data?.v !== 2) throw new Error("unsupported save version"); + return data; + } + if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text"); + const codec = raw.charAt(EXPORT_PREFIX.length) || "R"; + const payload = raw.slice(EXPORT_PREFIX.length + 1); + const packed = base91Decode(payload); + const bytes = await decompressBytes(packed, codec); const json = new TextDecoder().decode(bytes); - return JSON.parse(json); + const data = JSON.parse(json); + if (data?.v !== 2) throw new Error("unsupported save version"); + return data; } function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } - function saveSlot(slot) { + async function saveSlot(slot) { const snapshot = createSnapshot(global.world); localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); return snapshot; } - function loadSlot(slot) { + async function loadSlot(slot) { const raw = localStorage.getItem(slotKey(slot)); if (!raw) throw new Error("empty slot"); - return restoreSnapshot(JSON.parse(raw), global.world); + const snapshot = JSON.parse(raw); + if (snapshot?.v !== 2) throw new Error("unsupported slot version"); + return restoreSnapshot(snapshot, global.world); } function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } @@ -66,10 +161,14 @@ 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 +240,7 @@ openDialog(); }); const dialog = ensureDialog(); - dialog.addEventListener("click", (e) => { + 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 +248,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); 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/snapshot_system.js b/js/snapshot_system.js index 68c1e10..13ecf0b 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 = 2; + 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,63 @@ 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 compactNumberArray(obj, keys, scale = 1) { + return keys.map(k => q(obj?.[k], scale)); + } + + function expandNumberArray(arr, keys, scale = 1) { + const out = {}; + if (!Array.isArray(arr)) return out; + keys.forEach((key, i) => { out[key] = u(arr[i], scale); }); + return out; + } + + function needsToArray(needs = {}) { + return NEED_KEYS.map(k => q(needs[k], 1)); + } + + 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,22 +133,209 @@ } 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", + 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, ]; - 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: {} } }; + } + + function compactBehavior(b = null, tarinaiIndex = new Map(), itemIndex = new Map()) { + if (!b || typeof b !== "object") return null; + const ref = b.targetRef || b.target || null; + let target = null; + if (ref?.kind === "tarinai") target = ["t", tarinaiIndex.get(ref.id) ?? -1]; + else if (ref?.kind === "item") target = ["i", itemIndex.get(ref.id) ?? -1]; + else if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) target = ["p", q(ref.x, 1), q(ref.y, 1)]; + return [b.id || "", b.need || "", b.subNeed || "", b.phase || "", b.forced ? 1 : 0, target].filter((v, i) => i < 5 || v); + } + + function expandBehavior(arr = null) { + if (!Array.isArray(arr) || !arr[0]) return null; + return { + id: arr[0] || "", + need: arr[1] || "", + subNeed: arr[2] || "", + phase: arr[3] || "", + forced: !!arr[4], + }; + } + + 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 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]; + 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)]; + 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]; + 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); + } 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 snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { + // Compatibility helper for non-save subsystems such as freeze. The exported save format no longer uses this broad snapshot. const data = copyOwnData(entity, skip); const targetRef = refFor(worldRef, entity?.target); const panicTargetRef = refFor(worldRef, entity?.panicTarget); @@ -103,25 +348,27 @@ 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(Boolean); + 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], + 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, }; } @@ -141,11 +388,52 @@ return t; } + function expandTarinai(row, index, worldRef) { + const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); + if (!TarinaiClass) throw new Error("Tarinai is not available"); + 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 v2 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,82 +441,83 @@ 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.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.updateEffectCounts === "function") worldRef.updateEffectCounts(); if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); - if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load-v2"); if (typeof worldRef.clampCamera === "function") worldRef.clampCamera(); if (typeof renderLog === "function") renderLog(worldRef.logs || []); if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); diff --git a/js/structures.js b/js/structures.js index 7e6203e..9cb5b3f 100644 --- a/js/structures.js +++ b/js/structures.js @@ -78,7 +78,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 +106,13 @@ 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 img = typeof getRenderableImage === "function" ? getRenderableImage("plushie", "plushie") : global.images?.get?.("plushie"); 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("plushie") : 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)"; @@ -178,16 +178,18 @@ 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) { + 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)); - } return true; }, onDestroyed(structure, world, breaker) { diff --git a/js/tarinai.js b/js/tarinai.js index a71b69c..fb0f4a2 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -67,11 +67,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 } : (opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : 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,8 +81,6 @@ 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.deathReason = opts.deathReason || ""; this.lastDamageCause = opts.lastDamageCause || ""; diff --git a/js/tarinai_action_spec.js b/js/tarinai_action_spec.js new file mode 100644 index 0000000..68a3e32 --- /dev/null +++ b/js/tarinai_action_spec.js @@ -0,0 +1,201 @@ +"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. + // Legacy hooks (`condition`, `run`, `update`) remain accepted so old code can be + // migrated incrementally, but newly migrated actions should use the lifecycle names. + const spec = { + ...input, + id, + 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 originalCondition = hasOwnFn(input, "condition") ? input.condition : null; + const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null; + const originalStart = hasOwnFn(input, "start") ? input.start : null; + const originalRun = hasOwnFn(input, "run") ? input.run : null; + const originalTick = hasOwnFn(input, "tick") ? input.tick : null; + const originalUpdate = hasOwnFn(input, "update") ? input.update : null; + const originalFinish = hasOwnFn(input, "finish") ? input.finish : null; + const originalFail = hasOwnFn(input, "fail") ? input.fail : null; + const originalText = hasOwnFn(input, "text") ? input.text : null; + + 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)); + if (originalCondition) return Boolean(originalCondition.call(spec, tarinai, world, ctx)); + return true; + }; + + spec.start = function start(tarinai, world, ctx = {}) { + if (originalStart) return originalStart.call(spec, tarinai, world, ctx); + if (originalRun) return originalRun.call(spec, tarinai, world, ctx); + return true; + }; + + spec.tick = function tick(tarinai, world, dt = 0, needs = null, ctx = {}) { + if (originalTick) return originalTick.call(spec, tarinai, world, dt, needs, ctx); + if (originalUpdate) return originalUpdate.call(spec, tarinai, world, dt, needs, ctx); + return undefined; + }; + + 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..91a0462 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -24,13 +24,13 @@ }, 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 setLiveActionText === "function") setLiveActionText(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 +48,8 @@ }, goIdle(reason = "") { - if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "待っている"); - return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false }); + if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b"); + return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", sleeping: false }); }, startSleeping(target = null, reason = "") { @@ -64,10 +64,8 @@ targetId: target?.id || null, }; } - 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" }); + const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" }); return this.setActionState("sleep", { target, reason: text, sleeping: true }); }, @@ -82,7 +80,7 @@ currentBehaviorText() { if (typeof activeBehaviorText === "function") return activeBehaviorText(this); - return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim(); + return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim(); }, enterPanic(opts = {}) { @@ -100,18 +98,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 setLiveActionText === "function") { + const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044"); + setLiveActionText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" }); + } this.fearTimer = Math.max(this.fearTimer || 0, 2.4); } if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); diff --git a/js/tarinai_behavior_state.js b/js/tarinai_behavior_state.js new file mode 100644 index 0000000..9eada82 --- /dev/null +++ b/js/tarinai_behavior_state.js @@ -0,0 +1,417 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js"); + + function isObject(value) { + return Boolean(value && typeof value === "object"); + } + + function finiteOr(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + function localBehaviorTargetId(target = null) { + if (!target) return null; + if (target.id != null) return target.id; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; + return null; + } + + function nowFor(tarinai = null) { + return finiteOr(tarinai?.world?.time, 0); + } + + function hidden(obj, key, value) { + if (!obj) return value; + Object.defineProperty(obj, key, { + value, + writable: true, + configurable: true, + enumerable: false, + }); + return value; + } + + function actionIdFrom(input = {}, legacyIntent = null, legacyAction = null, previous = null) { + return String(input.id || input.actionId || legacyIntent?.actionId || legacyAction?.id || previous?.id || ""); + } + + 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; + const request = { + uid, + id, + source: String(src.source || prev.source || "external"), + priority: finiteOr(src.priority, finiteOr(prev.priority, 0)), + status: String(src.status || prev.status || "active"), + reasonText: String(src.reasonText || src.forcedReasonText || prev.reasonText || prev.forcedReasonText || ""), + 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 || ""), + }; + return request; + } + + function normalizeBehaviorValue(input, tarinai = null, previous = null) { + if (!isObject(input)) return null; + const legacyIntent = isObject(input.intent) ? input.intent : null; + const legacyAction = isObject(input.currentAction) ? input.currentAction : null; + const now = nowFor(tarinai); + const id = actionIdFrom(input, legacyIntent, legacyAction, previous) || String(tarinai?.state || "idle"); + const need = String(input.need || legacyIntent?.need || legacyAction?.need || previous?.need || "fulfill"); + const label = String(input.label || input.actionLabel || legacyIntent?.actionLabel || previous?.label || id || "行動している"); + const reasonText = String(input.reasonText || input.presentText || legacyIntent?.reasonText || previous?.reasonText || label || ""); + const target = input.target !== undefined ? input.target : tarinai?.target; + const startedAt = finiteOr(input.startedAt ?? legacyIntent?.startedAt ?? legacyAction?.startedAt, finiteOr(previous?.startedAt, now)); + const minDuration = finiteOr(input.minDuration ?? legacyAction?.minDuration, finiteOr(previous?.minDuration, 0.45)); + const lockSeconds = finiteOr(input.lockSeconds ?? legacyAction?.lockSeconds, finiteOr(previous?.lockSeconds, 1.0)); + const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length + ? [...input.tiedNeeds] + : (Array.isArray(legacyIntent?.tiedNeeds) && legacyIntent.tiedNeeds.length + ? [...legacyIntent.tiedNeeds] + : (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need])); + const behavior = { + id, + need, + subNeed: String(input.subNeed || legacyAction?.subNeed || previous?.subNeed || ""), + phase: String(input.phase || legacyAction?.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "active")), + state: String(input.state || tarinai?.state || previous?.state || "idle"), + label, + presentText: String(input.presentText || reasonText || ""), + reasonText, + targetId: input.targetId !== undefined ? input.targetId : localBehaviorTargetId(target), + tiedNeeds, + startedAt, + updatedAt: finiteOr(input.updatedAt, now), + minDuration, + lockSeconds, + deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt, + source: String(input.source || previous?.source || "need"), + sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || previous?.sourceReasonText || previous?.forcedReasonText || ""), + forcedReasonText: String(input.forcedReasonText || input.sourceReasonText || previous?.forcedReasonText || previous?.sourceReasonText || ""), + causeText: String(input.causeText || legacyIntent?.causeText || previous?.causeText || ""), + forced: Boolean(input.forced ?? previous?.forced ?? false), + forcedId: String(input.forcedId || previous?.forcedId || ""), + priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)), + specId: String(input.specId || input.actionSpecId || previous?.specId || id), + specKind: String(input.specKind || previous?.specKind || "need_action"), + }; + const forcedRequest = normalizeForcedRequestValue( + input.forcedRequest || input.request || null, + behavior.forced || behavior.forcedId || previous?.forcedRequest + ? { ...(previous?.forcedRequest || {}), uid: behavior.forcedId || previous?.forcedRequest?.uid || "", id: behavior.id, source: behavior.source, priority: behavior.priority, reasonText: behavior.forcedReasonText || behavior.sourceReasonText, causeText: behavior.causeText, targetId: behavior.targetId } + : null + ); + behavior.forcedRequest = forcedRequest; + if (forcedRequest) { + behavior.forced = true; + behavior.forcedId = forcedRequest.uid || behavior.forcedId; + behavior.source = forcedRequest.source || behavior.source || "external"; + behavior.priority = finiteOr(forcedRequest.priority, behavior.priority); + behavior.forcedReasonText = behavior.forcedReasonText || forcedRequest.reasonText || ""; + behavior.sourceReasonText = behavior.sourceReasonText || forcedRequest.reasonText || behavior.forcedReasonText || ""; + behavior.causeText = behavior.causeText || forcedRequest.causeText || ""; + } + if (Number.isFinite(Number(input.serial))) behavior.serial = Number(input.serial); + return syncLegacyViews(behavior); + } + + function behaviorFromLegacyIntent(intent, currentAction = null, tarinai = null, previous = null) { + if (!isObject(intent) && !isObject(currentAction)) return null; + const now = nowFor(tarinai); + return normalizeBehaviorValue({ + id: currentAction?.id || intent?.actionId || tarinai?.state || "idle", + need: intent?.need || currentAction?.need || previous?.need || "fulfill", + subNeed: currentAction?.subNeed || previous?.subNeed || "", + phase: currentAction?.phase || previous?.phase || "active", + state: tarinai?.state || previous?.state || "idle", + label: intent?.actionLabel || previous?.label || "行動している", + presentText: intent?.reasonText || previous?.presentText || "", + reasonText: intent?.reasonText || previous?.reasonText || intent?.actionLabel || "", + causeText: intent?.causeText || previous?.causeText || "", + tiedNeeds: Array.isArray(intent?.tiedNeeds) && intent.tiedNeeds.length ? [...intent.tiedNeeds] : undefined, + startedAt: intent?.startedAt ?? currentAction?.startedAt ?? now, + minDuration: currentAction?.minDuration, + lockSeconds: currentAction?.lockSeconds, + priority: previous?.priority, + source: previous?.source || "legacy", + forced: previous?.forced, + forcedId: previous?.forcedId, + sourceReasonText: previous?.sourceReasonText, + forcedReasonText: previous?.forcedReasonText, + }, tarinai, previous); + } + + function syncLegacyViews(behavior) { + if (!isObject(behavior)) return null; + const intent = { + need: behavior.need || "fulfill", + tiedNeeds: Array.isArray(behavior.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [behavior.need || "fulfill"], + actionId: behavior.id || "idle", + actionLabel: behavior.label || behavior.presentText || behavior.reasonText || "行動している", + reasonText: behavior.reasonText || behavior.presentText || behavior.label || "", + causeText: behavior.causeText || "", + startedAt: finiteOr(behavior.startedAt, 0), + }; + const currentAction = { + id: behavior.id || "idle", + need: behavior.need || intent.need, + subNeed: behavior.subNeed || "", + startedAt: finiteOr(behavior.startedAt, 0), + minDuration: finiteOr(behavior.minDuration, 0.45), + lockSeconds: finiteOr(behavior.lockSeconds, 1.0), + phase: behavior.phase || "active", + specId: behavior.specId || behavior.id || "idle", + specKind: behavior.specKind || "need_action", + }; + const forcedRequest = normalizeForcedRequestValue(behavior.forcedRequest || null, + behavior.forced || behavior.forcedId ? { uid: behavior.forcedId || "", id: behavior.id || "", source: behavior.source || "external", priority: finiteOr(behavior.priority, 0), reasonText: behavior.forcedReasonText || behavior.sourceReasonText || "", causeText: behavior.causeText || "", targetId: behavior.targetId } : null); + behavior.forcedRequest = forcedRequest; + const activeForcedBehavior = forcedRequest + ? { uid: forcedRequest.uid || behavior.forcedId || "", id: forcedRequest.id || behavior.id || "", source: forcedRequest.source || behavior.source || "external", priority: finiteOr(forcedRequest.priority, finiteOr(behavior.priority, 0)) } + : null; + hidden(behavior, "_intent", intent); + hidden(behavior, "_currentAction", currentAction); + hidden(behavior, "_activeForcedBehavior", activeForcedBehavior); + return behavior; + } + + function normalizeBehaviorState(opts = {}, tarinai = null) { + const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null; + let behavior = normalizeBehaviorValue(opts.behavior || opts.activeBehavior || null, tarinai, previous); + if (!behavior) behavior = behaviorFromLegacyIntent(opts.intent || null, opts.currentAction || null, tarinai, previous); + if (!behavior) return null; + if (isObject(opts.intent)) mergeIntent(tarinai, opts.intent, behavior); + if (isObject(opts.currentAction)) mergeCurrentAction(tarinai, opts.currentAction, behavior); + if (isObject(opts.activeForcedBehavior)) mergeActiveForcedBehavior(tarinai, opts.activeForcedBehavior, behavior); + return syncLegacyViews(behavior); + } + + function ensureBehavior(tarinai) { + if (!tarinai) return null; + if (isObject(tarinai.behavior)) return syncLegacyViews(tarinai.behavior); + return null; + } + + function assignBehavior(tarinai, value) { + if (!tarinai) return null; + const next = normalizeBehaviorValue(value, tarinai, tarinai.behavior || null); + tarinai.behavior = next; + return next; + } + + function getTarinaiBehavior(tarinai) { + return ensureBehavior(tarinai); + } + + function getTarinaiBehaviorId(tarinai, fallback = "") { + const b = ensureBehavior(tarinai); + return String(b?.id || fallback || ""); + } + + function getTarinaiBehaviorNeed(tarinai, fallback = "") { + const b = ensureBehavior(tarinai); + return String(b?.need || fallback || ""); + } + + function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") { + const b = ensureBehavior(tarinai); + if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds]; + return [String(b?.need || fallbackNeed || "fulfill")]; + } + + function getTarinaiBehaviorText(tarinai, fallback = "") { + const b = ensureBehavior(tarinai); + return String(b?.presentText || b?.reasonText || b?.label || fallback || "").trim(); + } + + function patchTarinaiBehavior(tarinai, fields = {}) { + if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai); + const previous = ensureBehavior(tarinai) || {}; + 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; + if (opts.reason) tarinai.thought = String(opts.reason || ""); + return null; + } + + function setTarinaiForcedBehavior(tarinai, value = null) { + return mergeActiveForcedBehavior(tarinai, value || null); + } + + function mergeIntent(tarinai, value, behavior = null) { + if (!tarinai || !isObject(value)) return null; + const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({ + id: value.actionId || tarinai.state || "idle", + need: value.need || "fulfill", + label: value.actionLabel || value.reasonText || "行動している", + reasonText: value.reasonText || value.actionLabel || "", + tiedNeeds: value.tiedNeeds, + causeText: value.causeText || "", + startedAt: value.startedAt, + source: "intent", + }, tarinai, null); + if (!b) return null; + if (value.actionId != null) b.id = String(value.actionId || b.id || "idle"); + if (value.need != null) b.need = String(value.need || b.need || "fulfill"); + if (value.actionLabel != null) b.label = String(value.actionLabel || b.label || ""); + if (value.reasonText != null) { + b.reasonText = String(value.reasonText || ""); + b.presentText = b.presentText || b.reasonText; + } + if (value.causeText != null) b.causeText = String(value.causeText || ""); + if (Array.isArray(value.tiedNeeds) && value.tiedNeeds.length) b.tiedNeeds = [...value.tiedNeeds]; + if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt); + if (value.specId != null) b.specId = String(value.specId || b.id || "idle"); + if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action"); + tarinai.behavior = syncLegacyViews(b); + return tarinai.behavior._intent; + } + + function mergeCurrentAction(tarinai, value, behavior = null) { + if (!tarinai || !isObject(value)) return null; + const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({ + id: value.id || tarinai.state || "idle", + need: value.need || "fulfill", + subNeed: value.subNeed || "", + startedAt: value.startedAt, + minDuration: value.minDuration, + lockSeconds: value.lockSeconds, + phase: value.phase, + source: "currentAction", + }, tarinai, null); + if (!b) return null; + if (value.id != null) b.id = String(value.id || b.id || "idle"); + if (value.need != null) b.need = String(value.need || b.need || "fulfill"); + if (value.subNeed != null) b.subNeed = String(value.subNeed || ""); + if (value.phase != null) b.phase = String(value.phase || b.phase || "active"); + if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt); + if (Number.isFinite(Number(value.minDuration))) b.minDuration = Number(value.minDuration); + if (Number.isFinite(Number(value.lockSeconds))) b.lockSeconds = Number(value.lockSeconds); + if (value.specId != null) b.specId = String(value.specId || b.id || "idle"); + if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action"); + tarinai.behavior = syncLegacyViews(b); + return tarinai.behavior._currentAction; + } + + function mergeActiveForcedBehavior(tarinai, value, behavior = null) { + if (!tarinai) return null; + const b = behavior || ensureBehavior(tarinai); + if (!b) return null; + if (!value) { + b.forced = false; + b.forcedId = ""; + b.forcedRequest = null; + b.sourceReasonText = b.sourceReasonText || ""; + b.forcedReasonText = b.forcedReasonText || ""; + tarinai.behavior = syncLegacyViews(b); + return null; + } + const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.id || "idle", source: b.source || "external", priority: b.priority || 0, reasonText: b.forcedReasonText || b.sourceReasonText || "", causeText: b.causeText || "", targetId: b.targetId }); + b.forced = true; + b.forcedRequest = request; + b.forcedId = String(request?.uid || value.uid || value.forcedId || b.forcedId || ""); + if (request?.id || value.id != null) b.id = String(request?.id || value.id || b.id || "idle"); + b.source = String(request?.source || value.source || b.source || "external"); + b.priority = finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0))); + b.forcedReasonText = b.forcedReasonText || request?.reasonText || ""; + b.sourceReasonText = b.sourceReasonText || request?.reasonText || b.forcedReasonText || ""; + b.causeText = b.causeText || request?.causeText || ""; + tarinai.behavior = syncLegacyViews(b); + return tarinai.behavior._activeForcedBehavior; + } + + function getTarinaiForcedBehavior(tarinai) { + const b = ensureBehavior(tarinai); + if (!b || !b.forced) return null; + return b.forcedRequest || b._activeForcedBehavior || null; + } + + function isTarinaiBehaviorForced(tarinai) { + const b = ensureBehavior(tarinai); + return Boolean(b?.forced || b?.forcedId || b?.forcedRequest); + } + + Object.defineProperties(Tarinai.prototype, { + activeBehavior: { + configurable: true, + get() { + return ensureBehavior(this); + }, + set(value) { + if (!value) { clearTarinaiBehavior(this); return; } + assignBehavior(this, value); + }, + }, + intent: { + configurable: true, + get() { + const b = ensureBehavior(this); + return b ? b._intent : null; + }, + set(value) { + if (!value) { clearTarinaiBehavior(this); return; } + mergeIntent(this, value); + }, + }, + currentAction: { + configurable: true, + get() { + const b = ensureBehavior(this); + return b ? b._currentAction : null; + }, + set(value) { + if (!value) { clearTarinaiBehavior(this); return; } + mergeCurrentAction(this, value); + }, + }, + activeForcedBehavior: { + configurable: true, + get() { + const b = ensureBehavior(this); + return b ? b._activeForcedBehavior : null; + }, + set(value) { + mergeActiveForcedBehavior(this, value || null); + }, + }, + }); + + global.normalizeTarinaiBehaviorState = normalizeBehaviorState; + global.normalizeTarinaiBehaviorValue = normalizeBehaviorValue; + global.syncTarinaiBehaviorLegacyViews = syncLegacyViews; + global.getTarinaiBehavior = getTarinaiBehavior; + global.currentTarinaiBehavior = getTarinaiBehavior; + global.getTarinaiBehaviorId = getTarinaiBehaviorId; + global.getTarinaiBehaviorNeed = getTarinaiBehaviorNeed; + global.getTarinaiBehaviorTiedNeeds = getTarinaiBehaviorTiedNeeds; + global.getTarinaiBehaviorText = getTarinaiBehaviorText; + global.patchTarinaiBehavior = patchTarinaiBehavior; + global.setTarinaiBehavior = assignBehavior; + global.clearTarinaiBehavior = clearTarinaiBehavior; + global.normalizeTarinaiForcedRequest = normalizeForcedRequestValue; + global.getTarinaiForcedBehavior = getTarinaiForcedBehavior; + global.isTarinaiBehaviorForced = isTarinaiBehaviorForced; + global.setTarinaiForcedBehavior = setTarinaiForcedBehavior; + global.clearTarinaiForcedBehavior = function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index fa06865..ba194d1 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -85,7 +85,7 @@ 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 +104,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 +181,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) { @@ -211,8 +211,8 @@ 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 +228,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 +278,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_identity_social.js b/js/tarinai_identity_social.js index c50f81f..3f4068d 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -371,7 +371,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 +379,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..1beb5d7 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -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 currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior)?.forced)) { + this.intentLockTimer = Math.min(this.intentLockTimer || 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); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 5836476..4cdd6ce 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -16,11 +16,11 @@ const TARINAI_NEED_PHRASES = { health: "\u8abf\u5b50\u304c\u60aa\u3044", safety: "\u3042\u3076\u306a\u3044", social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", - fulfill: "なにか満たされない", + fulfill: "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044", }; const TARINAI_NEED_THRESHOLDS = { - food: { start: 54, continue: 34 }, + food: { start: 64, continue: 20 }, sleep: { start: 62, continue: 30 }, health: { start: 54, continue: 32 }, safety: { start: 42, continue: 20 }, @@ -29,7 +29,7 @@ const TARINAI_NEED_THRESHOLDS = { }; const TARINAI_NEED_SATISFACTION_DECAY = { - food: 1.25, + food: 0.85, sleep: 0.75, health: 0.65, safety: 1.10, @@ -38,20 +38,22 @@ const TARINAI_NEED_SATISFACTION_DECAY = { }; const TARINAI_ACTION_LOCK_SECONDS = { - eat_food: 3.4, - drink_water: 4.2, + 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: 5.4, + use_medicine: 2.4, rest_to_recover: 6.0, + sunbath: 10.5, 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, + fight_rival: 3.6, + intimidate_enemy: 1.15, play: 8.2, build_grass_bed: 10.0, build_plushie: 10.0, @@ -61,19 +63,21 @@ const TARINAI_ACTION_LOCK_SECONDS = { }; const TARINAI_ACTION_PRIORITY = { - fight_rival: 100, - intimidate_enemy: 96, + fight_rival: 56, + intimidate_enemy: 42, panic_escape: 94, flee: 92, hide_at_owned_structure: 90, use_medicine: 86, rest_to_recover: 84, + sunbath: 83, sleep_in_bed: 80, + sleep_build_grass_bed: 79, sleep_anywhere: 78, birth_ritual: 72, - approach_mate: 70, - approach_parent_or_child: 62, - approach_friend: 60, + approach_mate: 68, + approach_parent_or_child: 66, + approach_friend: 66, eat_food: 50, drink_water: 48, build_grass_bed: 30, @@ -186,7 +190,7 @@ function applyNeedRelief(tarinai, deltas = {}) { return hasSatisfaction || hasShock; } -function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "パニックになっている") { +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; @@ -207,7 +211,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "パ 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); + 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; @@ -215,7 +219,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "パ return false; } -function triggerNeedShockReaction(tarinai, breaker = null, reason = "パニックになっている") { +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()) }; @@ -246,7 +250,7 @@ function scoreNeedForAction(needs, tarinai, world, need) { 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; + 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); @@ -265,64 +269,172 @@ function chooseTopNeedRandom(needs, tarinai = null, world = null) { } 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 behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const actionId = action?.id || behavior?.id || ""; + if (actionId === "build_grass_bed") return "\u5bdd\u5e8a\u304c\u307b\u3057\u3044"; + if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044"; + if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"; + if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044"; + if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b"; + if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b"; const parts = tarinai?.fulfillReasonParts || {}; const candidates = [ - ["bed", "寝床がほしい", parts.bed], - ["plushie", "ぬいぐるみがほしい", parts.plushie], - ["boredom", "退屈している", parts.boredom], - ["material", "草でなにか作れそう", parts.material], - ["openness", "新しいことが気になる", parts.openness], + ["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 || "なにか満たされない"; + 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 "繁殖できる相手が気になる"; - 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 "怖い"; + 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 buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null) { - const chosenPhrase = action?.label || action?.phrase || "待っている"; +function actionTextForReason(action = null, tarinai = null) { + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const id = action?.id || behavior?.id || ""; + const target = tarinai?.target || currentBehaviorTarget?.(tarinai) || null; + const name = target?.name || "\u76f8\u624b"; + if (id === "birth_ritual") return target?.name ? `${name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; + if (id === "fight_rival") { + const cause = String(behavior?.causeText || ""); + if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b"; + return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b"; + } + if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + return String(action?.label || action?.phrase || behavior?.label || "\u5f85\u3063\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, ""); +} + +function forcedCausePhrase(source = "", fallback = "") { + const key = String(source || ""); + if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c"; + if (key === "fight_mochi") return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c"; + if (key === "sleep_drug") return "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c"; + if (key === "drug") return "\u85ac\u306e\u52b9\u679c"; + return String(fallback || "").replace(/[\u3002\uff01\uff1f]+$/, ""); +} + +function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u3066\u3044\u308b") { + const c = String(cause || "").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); + const a = String(actionText || "\u884c\u52d5\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "").trim(); + if (!c) return `${a}\u3002`; + if (c.endsWith("\u52b9\u679c") || c.endsWith("\u5f71\u97ff")) return `${c}\u3067\u3001${a}\u3002`; + if (c.endsWith("\u6c17\u4ed8\u3044\u305f")) return `${c.replace(/\u6c17\u4ed8\u3044\u305f$/, "\u6c17\u4ed8\u3044\u3066")}\u3001${a}\u3002`; + if (c.endsWith("\u6c17\u3065\u3044\u305f")) return `${c.replace(/\u6c17\u3065\u3044\u305f$/, "\u6c17\u3065\u3044\u3066")}\u3001${a}\u3002`; + if (c.endsWith("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f")) return `${c.replace(/\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f$/, "\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")}\u3001${a}\u3002`; + if (c.endsWith("\u5a01\u5687\u3055\u308c\u305f")) return `${c.replace(/\u5a01\u5687\u3055\u308c\u305f$/, "\u5a01\u5687\u3055\u308c")}\u3001${a}\u3002`; + return `${c}\u306e\u3067\u3001${a}\u3002`; +} + +function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) { + if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, ""); + const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {}; + if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, ""); + if (b.forced || b.source) { + const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.forcedReasonText || ""); + if (forced) return forced; + } + if (action?.id === "panic_escape" && tarinai?.lastPanicDetail) return String(tarinai.lastPanicDetail).replace(/[\u3002\uff01\uff1f]+$/, ""); + if (action?.id === "fight_rival") { + const target = currentBehaviorTarget?.(tarinai) || tarinai?.target || null; + if (tarinai?.counterAttackFromId && target?.id === tarinai.counterAttackFromId) return `${target.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`; + if (target?.name) return `${target.name}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`; + } + if (action?.id === "birth_ritual" || action?.subNeed === "mate") { + if ((tarinai?.loveMochiTimer || 0) > 0.04) return "\u3078\u3053\u9905\u306e\u52b9\u679c"; + } + return needPhraseForReason(chosenNeed, tarinai, action); +} + +function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) { + const actionText = actionTextForReason(action, tarinai); + const cause = causePhraseForReason(chosenNeed, action, tarinai, options); const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; + const main = formatCauseActionText(cause, actionText); if (tied.length) { const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; - return `${needPhraseForReason(ignored, tarinai, action)}けど、${chosenPhrase}。`; + return `${needPhraseForReason(ignored, tarinai, action)}\u3051\u3069\u3001${main}`; } - return `${needPhraseForReason(chosenNeed, tarinai, action)}ので、${chosenPhrase}。`; + return main; } +function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") { + const primary = String(behavior?.need || fallbackNeed || "fulfill"); + const raw = tarinai?.needRaw || tarinai?.needs || {}; + const list = Array.isArray(behavior?.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [primary]; + if (!list.includes(primary)) list.unshift(primary); + return list.filter((key, idx) => key && (idx === 0 || Number(raw?.[key] || 0) > 0.5)); +} + +function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) { + const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null; + const directCause = options?.causeText || behavior?.causeText || request?.causeText || ""; + if (directCause) return String(directCause || "").replace(/[。!?]+$/, ""); + if (behavior?.forced || request) { + const source = request?.source || behavior?.source || "external"; + const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.forcedReasonText || ""; + const forcedCause = forcedCausePhrase(source, sourceReason); + if (forcedCause) return forcedCause; + } + return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options); +} + +function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) { + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; + if (!b) return ""; + const stored = String(b.presentText || b.reasonText || "").trim(); + if (stored && options.regenerate !== true) return stored; + const action = options.action || actionById(b.specId || b.id) || actionById(b.id) || null; + const need = String(options.need || b.need || action?.need || "fulfill"); + const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, need); + const causeText = causeForBehaviorText(tarinai, { ...b, need }, action, options); + const generated = action ? buildReasonText(need, tiedNeeds, action, tarinai, tarinai?.world || null, { ...options, causeText }) : ""; + const actionLabel = typeof textTarinaiAction === "function" ? textTarinaiAction(action || b.id, tarinai, tarinai?.world || null, { ...options, behavior: b, need, tiedNeeds, causeText, phase: options.phase || b.phase || "active" }) : ""; + const fallback = String(actionLabel || b.label || "").trim(); + return String(generated || fallback || "").trim(); +} + +function patchBehaviorTextFromComposer(tarinai, behavior = null, options = {}) { + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null; + if (!b) return ""; + const prevReason = String(b.reasonText || ""); + const text = composeTarinaiBehaviorText(tarinai, b, { ...options, regenerate: true }); + if (!text) return ""; + const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, b.need || options.need || "fulfill"); + if (text !== b.reasonText || text !== b.presentText || JSON.stringify(tiedNeeds) !== JSON.stringify(b.tiedNeeds || [])) { + if (typeof patchTarinaiBehavior === "function") patchTarinaiBehavior(tarinai, { reasonText: text, presentText: text, tiedNeeds }); + else { b.reasonText = text; b.presentText = text; b.tiedNeeds = tiedNeeds; if (typeof syncTarinaiBehaviorLegacyViews === "function") syncTarinaiBehaviorLegacyViews(b); } + } + if (text && (!tarinai.thought || tarinai.thought === prevReason || tarinai.thought === b.label)) tarinai.thought = text; + return text; +} + function refreshIntentReasonText(tarinai, action = null) { - 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 || ""; + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + if (!behavior?.need) return ""; + return patchBehaviorTextFromComposer(tarinai, behavior, { action }); } + + function actionById(id = "") { const key = String(id || ""); - return Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null; + 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) { @@ -334,12 +446,13 @@ function behaviorTargetId(target = null) { function currentBehaviorTarget(tarinai, behavior = null) { if (!tarinai) return null; - const b = behavior || tarinai.activeBehavior || null; + const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || null; if (!b) return tarinai.target || null; if (tarinai.target && !tarinai.target.dead && (!b.targetId || behaviorTargetId(tarinai.target) === b.targetId)) return tarinai.target; if (!b.targetId) return null; const id = b.targetId; return (tarinai.world?.tarinai || []).find(o => o && !o.dead && o.id === id) + || tarinai.world?.itemById?.(id) || (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id) || null; } @@ -367,6 +480,7 @@ 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.itemById?.(entry.targetId) || (world.items || []).find(item => item && !item.dead && item.id === entry.targetId) || null; if (found) return found; @@ -385,32 +499,91 @@ function forcedBehaviorSourcePriority(source = "external") { return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130; } +function forcedBehaviorQueueOf(tarinai) { + if (!tarinai) return []; + if (!Array.isArray(tarinai.forcedBehaviorQueue)) tarinai.forcedBehaviorQueue = []; + return tarinai.forcedBehaviorQueue; +} + +function setForcedBehaviorQueue(tarinai, queue = []) { + if (!tarinai) return []; + tarinai.forcedBehaviorQueue = (Array.isArray(queue) ? queue : []).filter(Boolean); + return tarinai.forcedBehaviorQueue; +} + +function normalizeForcedBehaviorEntry(tarinai, actionId, opts = {}) { + const now = tarinai?.world?.time || 0; + const source = opts.source || "external"; + const basePriority = forcedBehaviorSourcePriority(source); + const raw = { + uid: opts.uid || `${String(actionId)}:${source}:${now.toFixed(3)}:${Math.random().toString(36).slice(2, 8)}`, + id: String(actionId), + targetId: opts.targetId ?? behaviorTargetId(opts.target), + reasonText: opts.reasonText || opts.reason || "", + causeText: opts.causeText || "", + source, + priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority, + force: opts.force !== false, + replaceSameSource: opts.replaceSameSource !== false, + interrupt: opts.interrupt !== false, + createdAt: now, + status: opts.status || "queued", + attempts: Number(opts.attempts || 0) || 0, + lastTriedAt: Number(opts.lastTriedAt ?? -999) || -999, + retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25, + expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18), + duration: Number(opts.duration ?? 0) || 0, + minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0, + searchRange: Number(opts.searchRange ?? 0) || 0, + }; + return typeof normalizeTarinaiForcedRequest === "function" ? { ...raw, ...normalizeTarinaiForcedRequest(raw, raw), force: raw.force, replaceSameSource: raw.replaceSameSource, interrupt: raw.interrupt, retryDelay: raw.retryDelay, attempts: raw.attempts, lastTriedAt: raw.lastTriedAt, searchRange: raw.searchRange } : raw; +} + +function markForcedBehaviorActive(tarinai, entry, action = null) { + if (!tarinai || !entry) return null; + entry.status = "active"; + entry.activeSince = entry.activeSince || tarinai.world?.time || 0; + entry.startedAt = entry.startedAt || entry.activeSince; + const payload = { ...entry, id: action?.id || entry.id, status: "active" }; + return typeof setTarinaiForcedBehavior === "function" ? setTarinaiForcedBehavior(tarinai, payload) : payload; +} + +function currentForcedBehaviorRequest(tarinai) { + if (typeof getTarinaiForcedBehavior === "function") return getTarinaiForcedBehavior(tarinai); + const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + return b?.forced ? (b.forcedRequest || b._activeForcedBehavior || { uid: b.forcedId || "", id: b.id || "", source: b.source || "external", priority: Number(b.priority || 0) || 0 }) : null; +} + function clearForcedBehaviorQueue(tarinai, predicate = null) { - if (!tarinai || !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; + if (!tarinai) return 0; + const queue = forcedBehaviorQueueOf(tarinai); + const before = queue.length; + const next = typeof predicate !== "function" ? [] : queue.filter(entry => !predicate(entry)); + setForcedBehaviorQueue(tarinai, next); + const active = currentForcedBehaviorRequest(tarinai); + if (active?.uid && !next.some(entry => entry && entry.uid === active.uid) && typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai); + return before - next.length; } function activeBehaviorText(tarinai) { - const b = tarinai?.activeBehavior; + const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; if (!b) return ""; const state = String(tarinai.state || ""); const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04; const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state); if (!validByState && !b.forced) return ""; - return String(b.presentText || b.reasonText || b.label || "").trim(); + return composeTarinaiBehaviorText(tarinai, b) || String(b.presentText || b.reasonText || b.label || "").trim(); } + function setActiveBehavior(tarinai, fields = {}) { if (!tarinai || tarinai.dead) return null; const now = tarinai.world?.time || 0; - const previous = tarinai.activeBehavior || {}; - const actionId = fields.id || fields.actionId || previous.id || tarinai.intent?.actionId || tarinai.state || "idle"; + const previous = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const actionId = fields.id || fields.actionId || previous.id || tarinai.state || "idle"; const action = fields.action || actionById(actionId) || null; - const need = fields.need || action?.need || previous.need || tarinai.intent?.need || "fulfill"; - const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している"; + const need = fields.need || action?.need || previous.need || "fulfill"; + const label = fields.label || fields.actionLabel || action?.label || previous.label || "\u884c\u52d5\u3057\u3066\u3044\u308b"; const reasonText = fields.reasonText || fields.presentText || label; const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previous.id === actionId ? previous.startedAt : now); const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0; @@ -433,31 +606,20 @@ function setActiveBehavior(tarinai, fields = {}) { lockSeconds, deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt, source: fields.source || previous.source || "need", + sourceReasonText: fields.sourceReasonText || fields.forcedReasonText || previous.sourceReasonText || previous.forcedReasonText || "", + forcedReasonText: fields.forcedReasonText || fields.sourceReasonText || previous.forcedReasonText || previous.sourceReasonText || "", + causeText: fields.causeText || previous.causeText || "", forced: Boolean(fields.forced ?? previous.forced ?? false), + forcedId: fields.forcedId || previous.forcedId || "", + forcedRequest: fields.forcedRequest || previous.forcedRequest || null, priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0, + specId: fields.specId || action?.id || previous.specId || actionId, + specKind: fields.specKind || action?.kind || previous.specKind || "need_action", }; tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1; next.serial = tarinai.behaviorSerial; - 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 (typeof setTarinaiBehavior === "function") setTarinaiBehavior(tarinai, next); + else tarinai.behavior = typeof normalizeTarinaiBehaviorValue === "function" ? normalizeTarinaiBehaviorValue(next, tarinai, previous) : next; if (target !== undefined) tarinai.target = target; if (reasonText) tarinai.thought = reasonText; return next; @@ -471,97 +633,137 @@ function setActiveBehaviorFromAction(tarinai, action, choice = {}, reasonText = need: choice.need || action.need, subNeed: options.subNeed || action.subNeed || "", state: options.state || action.state || tarinai?.state || "idle", - label: action.label || options.actionLabel || "行動している", + label: action.label || options.actionLabel || "\u884c\u52d5\u3057\u3066\u3044\u308b", presentText: reasonText || action.label, reasonText: reasonText || action.label, target: options.target !== undefined ? options.target : tarinai?.target, tiedNeeds: choice.tiedNeeds || [choice.need || action.need], source: options.source || "need", forced: Boolean(options.forced), + forcedId: options.forcedId || "", + sourceReasonText: options.sourceReasonText || options.forcedReasonText || "", + forcedReasonText: options.forcedReasonText || options.sourceReasonText || "", + causeText: options.causeText || "", + forcedRequest: options.forcedRequest || null, priority: options.priority ?? actionPriority(action), lockSeconds: options.lockSeconds, minDuration: options.minDuration, phase: options.phase || "start", + specId: action.id, + specKind: action.kind || "need_action", }); } function clearActiveBehavior(tarinai, reason = "") { if (!tarinai) return; - tarinai.activeBehavior = null; - tarinai.currentAction = null; + 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 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); + 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 = 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; + 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; + } - const activePriority = Number(tarinai.activeBehavior?.priority ?? actionPriority(tarinai.activeBehavior?.id || tarinai.intent?.actionId)) || 0; - const activeForced = Boolean(tarinai.activeBehavior?.forced); + // \u5b9f\u884c\u4e2d\u306e\u5f37\u5236\u884c\u52d5\u306f\u30ad\u30e5\u30fc\u306b\u6b8b\u3057\u3001\u5b8c\u4e86/\u5931\u6557/\u671f\u9650\u5207\u308c\u307e\u3067\u52b9\u679c\u3092\u4fdd\u6301\u3059\u308b\u3002 + const activeQueueBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior; + const activeForcedId = activeQueueBehavior?.forcedId || ""; + for (const entry of queue) { + if (!entry.uid) entry.uid = `${entry.id}:${entry.source || "external"}:${entry.createdAt || now}:${Math.random().toString(36).slice(2, 8)}`; + if (entry.uid === activeForcedId && activeQueueBehavior?.forced && activeQueueBehavior?.id === entry.id) { + markForcedBehaviorActive(tarinai, entry, actionById(entry.id)); + } else if (entry.status === "active") { + // activeBehavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002 + entry.status = "queued"; + entry.lastFailedAt = now; + } + } + + queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); + setForcedBehaviorQueue(tarinai, queue); + + const activePriority = Number(activeQueueBehavior?.priority ?? actionPriority(activeQueueBehavior?.id)) || 0; + const activeForced = Boolean(activeQueueBehavior?.forced); for (let i = 0; i < queue.length; i++) { const entry = queue[i]; const action = actionById(entry.id); if (!action) { queue.splice(i, 1); i--; continue; } + if (entry.status === "active" && activeQueueBehavior?.forcedId === entry.uid) continue; const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0; - if (tarinai.activeBehavior && (tarinai.intentLockTimer || 0) > 0.08) { + if (activeQueueBehavior && (tarinai.intentLockTimer || 0) > 0.08) { if (activeForced && entryPriority <= activePriority + 8) continue; if (!entry.interrupt && entryPriority <= activePriority) continue; if (entryPriority <= activePriority && !entry.force) continue; } if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue; entry.lastTriedAt = now; + entry.attempts = (Number(entry.attempts || 0) || 0) + 1; const target = resolveForcedBehaviorTarget(tarinai, world, entry, action); - if (target) tarinai.target = target; + 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 reasonText = entry.reasonText || buildReasonText(need, choice.tiedNeeds, action, tarinai, world); - if (tarinai.activeBehavior && entryPriority > activePriority) { + const causeText = entry.causeText || forcedCausePhrase(entry.source || "", entry.reasonText || ""); + const reasonText = buildReasonText(need, choice.tiedNeeds, action, tarinai, world, { causeText }); + if (activeQueueBehavior && entryPriority > activePriority) { + retryForcedBehavior(tarinai, activeQueueBehavior, "interrupted_by_higher_forced_behavior"); clearActiveBehavior(tarinai); tarinai.intentLockTimer = 0; if (tarinai.state === "sleep" || tarinai.sleeping) { @@ -571,127 +773,169 @@ function processForcedBehaviorQueue(tarinai, world, needs) { } const started = startNeedAction(tarinai, world, choice, action, reasonText, { forced: true, + forcedId: entry.uid, + forcedReasonText: entry.reasonText || reasonText, + sourceReasonText: entry.reasonText || reasonText, + causeText, priority: entryPriority, source: entry.source, target, + forcedRequest: { ...entry, status: "active", reasonText: entry.reasonText || reasonText, causeText }, lockSeconds: entry.duration || undefined, minDuration: entry.minDuration || undefined, }); if (started) { - queue.splice(i, 1); - tarinai.forcedBehaviorQueue = queue; + markForcedBehaviorActive(tarinai, entry, action); + setForcedBehaviorQueue(tarinai, queue); return true; } } - tarinai.forcedBehaviorQueue = queue; + setForcedBehaviorQueue(tarinai, queue); return false; } -function setLiveActionText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null } = {}) { +function setLiveActionText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) { if (!tarinai) return; const now = tarinai.world?.time || 0; - const 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 previousBehavior = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {}; + const nextNeed = need || previousBehavior.need || "social"; + const nextActionId = actionId || previousBehavior.id || tarinai.state || "idle"; + const nextActionLabel = actionLabel || reasonText || previousBehavior.label || tarinai.state || "行動している"; const action = actionById(nextActionId); - 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, - }; + const preserveForced = Boolean(previousBehavior.forced && source === "state" && (!actionId || previousBehavior.id === nextActionId)); + let nextReasonText = reasonText || nextActionLabel; + const rawNeedsForText = tarinai.needRaw || tarinai.needs || {}; + const existingTiedNeeds = (Array.isArray(previousBehavior.tiedNeeds) && previousBehavior.tiedNeeds.length ? [...previousBehavior.tiedNeeds] : [nextNeed]) + .filter((key, idx) => idx === 0 || Number(rawNeedsForText?.[key] || 0) > 0.5); + if (existingTiedNeeds.some(key => key && key !== nextNeed) && !String(nextReasonText).includes("けど、")) { + nextReasonText = buildReasonText(nextNeed, existingTiedNeeds, { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null); + } + const nextSource = preserveForced ? (previousBehavior.source || source || "state") : (source || previousBehavior.source || "state"); + const nextForced = forced ?? Boolean(previousBehavior.forced); + const nextForcedId = forcedId || previousBehavior.forcedId || ""; + const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || previousBehavior.forcedReasonText || ""; + const nextCauseText = causeText || previousBehavior.causeText || ""; + if (nextCauseText) { + nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText }); + } if (target !== undefined) tarinai.target = target; setActiveBehavior(tarinai, { id: nextActionId, action, need: nextNeed, - subNeed: subNeed || action?.subNeed || "", + subNeed: subNeed || action?.subNeed || previousBehavior.subNeed || "", state: tarinai.state || action?.state || "idle", label: nextActionLabel, presentText: nextReasonText, reasonText: nextReasonText, target: target !== undefined ? target : tarinai.target, - tiedNeeds: tarinai.intent.tiedNeeds, - source: source || tarinai.activeBehavior?.source || "state", - forced: forced ?? Boolean(tarinai.activeBehavior?.forced), - priority: tarinai.activeBehavior?.priority ?? actionPriority(action), + tiedNeeds: existingTiedNeeds, + source: nextSource, + sourceReasonText: nextSourceReason, + forcedReasonText: nextSourceReason, + causeText: nextCauseText, + forced: nextForced, + forcedId: nextForcedId, + forcedRequest: previousBehavior.forcedRequest || null, + priority: previousBehavior.priority ?? actionPriority(action), phase, + startedAt: previousBehavior.startedAt || now, + minDuration: previousBehavior.minDuration || 0.8, + lockSeconds: previousBehavior.lockSeconds || 1.0, }); } + function synchronizeActionTextFromState(tarinai) { if (!tarinai || tarinai.dead) return; const state = String(tarinai.state || ""); const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : ""; if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null; - const label = partner?.name ? `${partner.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている"; - setLiveActionText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: label, target: partner }); + const label = partner?.name ? `${partner.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b"; + setLiveActionText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b", reasonText: label, target: partner }); return; } if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: catalogText || "食べている" }); + setLiveActionText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: catalogText || "\u98df\u3079\u3066\u3044\u308b" }); return; } if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: catalogText || "喧嘩している" }); + setLiveActionText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: catalogText || "\u55a7\u5629\u3057\u3066\u3044\u308b" }); return; } if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "逃げている", reasonText: catalogText || "怖くて逃げている" }); + setLiveActionText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "\u9003\u3052\u3066\u3044\u308b", reasonText: catalogText || "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b" }); return; } if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) { - setLiveActionText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: catalogText || "威嚇している" }); + setLiveActionText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: catalogText || "\u5a01\u5687\u3057\u3066\u3044\u308b" }); return; } if (state === "sleep" || tarinai.sleeping) { - setLiveActionText(tarinai, { need: "sleep", actionId: tarinai.intent?.actionId || "sleep_anywhere", actionLabel: "眠っている", reasonText: catalogText || "眠っている" }); + setLiveActionText(tarinai, { need: "sleep", actionId: (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai.behavior?.id) || "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: catalogText || "\u7720\u3063\u3066\u3044\u308b" }); } } +function effectiveFoodTypeForItem(item) { + if (!item) return ""; + return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); +} + +function 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 === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); + return false; +} + 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.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") 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 === "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 effectiveFoodTypeForItem(item) { - if (!item) return ""; - return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); +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; - if (type === "sweet") return 4000; + 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 === "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") 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; } @@ -700,7 +944,7 @@ function foodPriorityScore(tarinai, item, distance = 0) { 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; + if (item?.type === "duplicator") rank += 85; return rank - Math.max(0, Number(distance) || 0) * 0.65; } @@ -710,10 +954,10 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { 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); + const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item); if (d > maxDist) continue; let score = d; - if (role === "food") { + if (role === "food" || role === "medicine") { const priority = foodPriorityScore(tarinai, item, d); score = -priority; } @@ -722,9 +966,41 @@ function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { 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) { - const own = findOwnedStructure(world, tarinai, "grass_bed", maxDist); - return own || world?.bestBedFor?.(tarinai, maxDist) || findNearestItemWithRole(world, tarinai, "sleepPlace", maxDist); + 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) { @@ -754,15 +1030,15 @@ function evaluateConflictUrge(tarinai, world, maxDist = 96) { 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 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 ? 22 : 0; - const personalityBonus = clamp(aggression, -1, 1) * 14; + 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 = { @@ -770,11 +1046,11 @@ function evaluateConflictUrge(tarinai, world, maxDist = 96) { score, forced: !!battleDrug || !!otherBattleDrug, defensive: mixedZunchi || (rel.fear || 0) > 35, - reason: mixedZunchi ? "近くの相手が落ち着かない" : battleDrug ? "きずつき餅で気が立っている" : "気に入らない相手が近い", + 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 > 0 ? best : null; + return best && best.score > (best.forced ? 4 : 12) ? best : null; } function conflictTargetFor(tarinai, world, minScore = 34) { @@ -785,7 +1061,7 @@ function conflictTargetFor(tarinai, world, minScore = 34) { 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; + 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) { @@ -803,6 +1079,18 @@ function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { return best; } +function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of world?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId) continue; + if (type && item.type !== type) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + function consumeGrassMaterial(material) { if (!material) return false; material.amount = Math.max(0, (material.amount || 0) - 18); @@ -818,7 +1106,7 @@ function moveToOrUse(t, target, state = "seek_food", reason = "") { } 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, refreshIntentReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setLiveActionText, setActiveBehavior, clearActiveBehavior, activeBehaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, forcedBehaviorQueueOf, currentForcedBehaviorRequest, actionPriority, forcedBehaviorSourcePriority, completeForcedBehavior, retryForcedBehavior, relationFearSafe, isLiveTarinaiEntity, actionById }); } function decayNeedSatisfaction(tarinai, dt = 0) { @@ -881,7 +1169,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 +1182,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; @@ -970,29 +1259,684 @@ function updateNeeds(tarinai, world, dt = 0) { 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 tarinaiNeedCalcInterval(tarinai, world) { + const perf = world?.performanceLevel ? Number(world.performanceLevel() || 0) : 0; + const pop = Array.isArray(world?.tarinai) ? world.tarinai.length : 0; + const base = 0.42 + Math.min(0.34, pop * 0.0022) + perf * 0.16; + const jitter = typeof stableUnit === "function" ? stableUnit(tarinai?.familyKey || tarinai?.id || "tarinai", "need-calc-interval") * 0.32 : Math.random() * 0.24; + 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 clamp(urgent ? 0.24 + jitter * 0.34 : base + jitter, 0.22, 1.10); +} + +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; + } + if ((tarinai?.hurtTimer || 0) > 0.04 || (tarinai?.defeatedTimer || 0) > 0.04 || (tarinai?.fightTimer || 0) > 0.04) return true; + return false; +} + +function updateNeedsCached(tarinai, world, dt = 0, options = {}) { + if (!tarinai) return createDefaultNeeds(); + const now = Number(world?.time || 0) || 0; + const step = Math.max(0, Number(dt) || 0); + tarinai.needCalcAccumDt = Math.min(3.0, (Number(tarinai.needCalcAccumDt || 0) || 0) + step); + if (!Number.isFinite(tarinai.nextNeedCalcAt)) { + const firstInterval = tarinaiNeedCalcInterval(tarinai, world); + const offset = typeof stableUnit === "function" ? stableUnit(tarinai.familyKey || tarinai.id || "tarinai", "need-calc-offset") * firstInterval : Math.random() * firstInterval; + tarinai.nextNeedCalcAt = now + offset; + } + const force = Boolean(options.force || shouldForceNeedRecalc(tarinai, world)); + if (force || now >= (tarinai.nextNeedCalcAt || 0)) { + const calcDt = Math.max(0.016, Math.min(1.50, tarinai.needCalcAccumDt || step || 0.016)); + tarinai.needCalcAccumDt = 0; + const result = updateNeeds(tarinai, world, calcDt); + tarinai.lastNeedCalcAt = now; + tarinai.nextNeedCalcAt = now + tarinaiNeedCalcInterval(tarinai, world); + return result; + } + return tarinai.needRaw || tarinai.needs || createDefaultNeeds(); +} +function selectRoleTarget(role, range) { + return function selectTarget(tarinai, world) { + return findNearestItemWithRole(world, tarinai, role, range); + }; +} + +function startMoveToTarget(state, fallbackLabel = "行動している") { + return function start(tarinai, world, ctx = {}) { + const target = ctx.target ?? this.selectTarget?.(tarinai, world, ctx) ?? null; + return moveToOrUse(tarinai, target, state, this.label || fallbackLabel); + }; +} + +function selectOwnedStructure(type, range = Infinity) { + return function selectTarget(tarinai, world) { + return findOwnedStructure(world, tarinai, type, range); + }; +} + +function selectMateTarget(tarinai, world, range = 440) { + return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null; +} + +function basicCanStartWithTarget(t, world, ctx = {}) { + return !!(ctx.target ?? this.selectTarget?.(t, world, ctx)); +} + +function createConsumableActionSpec({ id, subNeed = "", state, label, phrase, weight, role, range }) { + return { + id, + need: role === "medicine" ? "health" : "food", + subNeed, + state, + label, + phrase, + weight, + timerBacked: true, + selectTarget: selectRoleTarget(role, range), + canStart: basicCanStartWithTarget, + start: startMoveToTarget(state, label), + tick(t, world, dt) { return updateConsumableBehavior(t, world, dt, role); }, + finish(t) { + if (role === "drink" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: 8, health: 2 }, "drink"); + return true; + }, + fail(t) { + if (t) t.target = null; + return false; + }, + }; +} + +function createSleepInBedActionSpec() { + return { + id: "sleep_in_bed", + need: "sleep", + state: "seek_bed", + label: "寝る場所を探している", + phrase: "寝る場所へ戻った", + weight: 42, + timerBacked: true, + selectTarget(t, world) { return findNearestSleepPlace(world, t, 760); }, + canStart: basicCanStartWithTarget, + start(t, world, ctx = {}) { + const bed = ctx.target ?? this.selectTarget(t, world, ctx); + if (!bed) return false; + if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); + bed.use?.(t, world); + t.startSleeping?.(bed, bed.type === "nest_box" ? "巣箱の中で眠っている" : "干草寝床で眠っている"); + return true; + }, + tick(t) { + if (t?.state === "sleep" || t?.sleeping) return true; + return basicCanStartWithTarget.call(this, t, t?.world || null, {}) ? true : false; + }, + }; +} + +function createSleepBuildGrassBedActionSpec() { + return { + id: "sleep_build_grass_bed", + need: "sleep", + subNeed: "bed", + state: "build", + label: "寝床がないのでかんたんベッドを作っている", + phrase: "かんたんベッドを作った", + weight: 18, + timerBacked: true, + selectTarget: selectRoleTarget("grassMaterial", 520), + canStart(t, world, ctx = {}) { + return !findNearestSleepPlace(world, t, 760) + && !findOwnedStructure(world, t, "grass_bed") + && !!(ctx.target ?? this.selectTarget(t, world, ctx)) + && !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state); + }, + start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); }, + tick(t, world, dt) { + if (findOwnedStructure(world, t, "grass_bed")) return "finished"; + return continueBuildPlan(t, world, dt) ? true : false; + }, + }; +} + +function createSleepAnywhereActionSpec() { + return { + id: "sleep_anywhere", + need: "sleep", + state: "sleep", + label: "その場で休んでいる", + phrase: "休んだ", + weight: 8, + timerBacked: true, + canStart(t, world) { return !findNearestSleepPlace(world, t, 760); }, + start(t) { t.startSleeping?.(null, this.label); return true; }, + tick(t) { return (t?.state === "sleep" || t?.sleeping) ? true : false; }, + }; +} + +function createRestActionSpec() { + return { + id: "rest_to_recover", + need: "health", + state: "idle", + label: "回復のため休んでいる", + phrase: "休んだ", + weight: 24, + canStart() { return true; }, + start(t) { + t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); + t.energy = clamp((t.energy || 0) + 1.6, 0, 100); + return true; + }, + tick(t) { + t.energy = clamp((t.energy || 0) + 0.8, 0, 100); + return true; + }, + }; +} + +function createSunbathActionSpec() { + return { + id: "sunbath", + need: "health", + subNeed: "sunbath", + state: "sunbath", + label: "日光浴している", + phrase: "日光浴している", + weight: 32, + timerBacked: true, + canStart(t) { + return (Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0) >= needThreshold("health", "start") + && !!t?.canStartSunbath?.(); + }, + start(t) { + return !!t?.startSunbath?.(); + }, + tick(t) { + return (t?.sunbathTimer || 0) > 0.04 ? true : "finished"; + }, + finish(t) { + if ((t?.sunbathTimer || 0) <= 0 && t?.state === "sunbath") t.finishSunbath?.(); + return true; + }, + fail(t) { + if (t && t.state === "sunbath") { + t.sunbathTimer = 0; + t.goIdle?.("日光浴をやめた"); + } + return false; + }, + }; +} + +function createWanderActionSpec() { + function targetFor(t, world) { + const radius = rand(80, 180); + return { + x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)), + y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)), + dead: false, + detour: true, + }; + } + return { + id: "wander_lightly", + need: "fulfill", + state: "wander", + label: "少し歩いている", + phrase: "少し歩いた", + weight: 14, + canStart() { return true; }, + selectTarget: targetFor, + start(t, world, ctx = {}) { + t.wanderAngle += rand(-1.2, 1.2); + const target = ctx.target && Number.isFinite(ctx.target.x) ? ctx.target : targetFor(t, world); + t.wanderTarget = target; + t.setActionState?.("wander", { target, reason: this.label, sleeping: false }); + return true; + }, + tick(t, world) { + const target = t?.wanderTarget || currentBehaviorTarget(t) || null; + if (!target || !Number.isFinite(target.x) || dist(t, target) <= Math.max(28, (t.radius || 20) + 10)) return "finished"; + moveToOrUse(t, target, "wander", this.label); + return true; + }, + }; +} + +function selectFightRivalTarget(tarinai, world, range = 360) { + const candidate = conflictTargetFor(tarinai, world, (tarinai?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced ? 18 : 34); + const current = currentBehaviorTarget(tarinai); + return isLiveTarinaiEntity(candidate?.target) + ? candidate.target + : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(tarinai, world, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced ? 760 : range)); +} + + +function createPanicEscapeActionSpec() { + return { + id: "panic_escape", + need: "safety", + subNeed: "panic", + state: "panic", + label: "怖くて逃げている", + phrase: "逃げた", + weight: 62, + timerBacked: true, + selectTarget(t, world) { + return t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.tarinai.find(o => o.id === t.defeatedById && !o.dead) : null); + }, + canStart(t, world, ctx = {}) { + return (Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) >= 58) + || !!(ctx.target ?? this.selectTarget(t, world, ctx)) + || (t?.fearTimer || 0) > 0.18 + || (t?.defeatedTimer || 0) > 0.04; + }, + start(t, world, ctx = {}) { + const threat = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false }); + t.fearTimer = Math.max(t.fearTimer || 0, 1.6); + return true; + }, + tick: updatePanicBehavior, + }; +} + +function createFleeActionSpec() { + return { + id: "flee", + need: "safety", + subNeed: "avoid", + state: "flee", + label: "危ないものから離れている", + phrase: "離れた", + weight: 40, + selectTarget(t, world) { return findNearbyDanger(world, t, 220); }, + canStart: basicCanStartWithTarget, + start(t, world, ctx = {}) { + const danger = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); + return true; + }, + tick: updatePanicBehavior, + }; +} + +function createApproachFriendActionSpec() { + return { + id: "approach_friend", + need: "social", + subNeed: "bond", + state: "seek_friend", + label: "仲間に近づいている", + phrase: "仲間に近づいた", + weight: 46, + selectTarget(t, world) { return t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420) || null; }, + canStart: basicCanStartWithTarget, + start: startMoveToTarget("seek_friend", "仲間に近づいている"), + tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); }, + }; +} + +function createApproachFamilyActionSpec() { + return { + id: "approach_parent_or_child", + need: "social", + subNeed: "family", + state: "follow_parent", + label: "家族に近づいている", + phrase: "家族に近づいた", + weight: 28, + selectTarget(t) { return t.parentToFollow?.() || null; }, + canStart: basicCanStartWithTarget, + start: startMoveToTarget("follow_parent", "家族に近づいている"), + tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); }, + }; +} + +function createApproachMateActionSpec() { + return { + id: "approach_mate", + need: "social", + subNeed: "mate", + state: "seek_friend", + label: "繁殖できる相手に近づいている", + phrase: "繁殖できる相手に近づいている", + weight: 48, + selectTarget(t, world) { return selectMateTarget(t, world, 520); }, + canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start(t, world, ctx = {}) { + const other = ctx.target ?? this.selectTarget(t, world, ctx); + if (!other) return false; + if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; + return moveToOrUse(t, other, "seek_friend", this.label); + }, + tick: updateMateBehavior, + }; +} + +function createFightRivalActionSpec() { + return { + id: "fight_rival", + need: "social", + subNeed: "conflict", + state: "seek_enemy", + label: "気に入らない相手に向かっている", + phrase: "喧嘩を始めた", + weight: 18, + timerBacked: true, + selectTarget(t, world) { return selectFightRivalTarget(t, world); }, + canStart(t, world, ctx = {}) { + if ((t.fightCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; + const forced = (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior)?.forced; + const candidate = conflictTargetFor(t, world, forced ? 24 : 42); + if (!forced) { + const parts = t?.socialReasonParts || {}; + const conflict = Number(parts.conflict || 0) || 0; + const socialPull = Math.max(Number(parts.bond || 0) || 0, Number(parts.mate || 0) || 0, Number(parts.family || 0) || 0); + if (!candidate?.target || conflict < 30 || conflict < socialPull * 0.82) return false; + } + return !!(ctx.target ?? candidate?.target ?? this.selectTarget(t, world, ctx)); + }, + start(t, world, ctx = {}) { + const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 18 : 34); + const rival = ctx.target ?? this.selectTarget(t, world, ctx); + if (!isLiveTarinaiEntity(rival)) return false; + t.conflictTargetId = rival.id; + t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48); + if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label); + if (candidate?.forced || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); + world.startConflict?.(t, rival); + return true; + }, + tick: updateFightBehavior, + }; +} + +function createIntimidateEnemyActionSpec() { + return { + id: "intimidate_enemy", + need: "social", + subNeed: "conflict", + state: "intimidate", + label: "気になる相手を威嚇している", + phrase: "威嚇した", + weight: 8, + timerBacked: true, + selectTarget(t) { return t.recentFightRival?.(80, 180) || null; }, + canStart(t, world, ctx = {}) { + if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false; + const parts = t?.socialReasonParts || {}; + if ((t.fightMochiTimer || 0) <= 0.04 && Number(parts.conflict || 0) < 24) return false; + return basicCanStartWithTarget.call(this, t, world, ctx); + }, + start(t, world, ctx = {}) { + const rival = ctx.target ?? this.selectTarget(t, world, ctx); + t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); + t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85); + return true; + }, + tick(t, world) { + if ((t.intimidateTimer || 0) > 0.04) return true; + const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180); + if (!rival) return false; + return world?.startIntimidation?.(t, rival) || true; + }, + }; +} + +function createBirthRitualActionSpec() { + return { + id: "birth_ritual", + need: "social", + subNeed: "mate", + label: "繁殖の前ぶれをしている", + phrase: "繁殖の前ぶれをしている", + weight: 0, + state: "birth_ritual", + timerBacked: true, + canStart(t) { return (t.birthRitualTimer || 0) > 0.04; }, + start() { return true; }, + tick(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; }, + }; +} + +const TARINAI_ACTION_DEFINITIONS = [ + createConsumableActionSpec({ + id: "eat_food", + subNeed: "meal", + state: "seek_food", + label: "食べ物を探している", + phrase: "食べ物を食べている", + weight: 62, + role: "food", + range: 760, + }), + createConsumableActionSpec({ + id: "drink_water", + subNeed: "water", + state: "seek_water", + label: "水を探している", + phrase: "水を飲んだ", + weight: 24, + role: "drink", + range: 560, + }), + createSleepInBedActionSpec(), + createSleepBuildGrassBedActionSpec(), + createSleepAnywhereActionSpec(), + createConsumableActionSpec({ + id: "use_medicine", + subNeed: "medicine", + state: "seek_food", + label: "回復できるものを探している", + phrase: "回復した", + weight: 34, + role: "medicine", + range: 620, + }), + createRestActionSpec(), + createSunbathActionSpec(), + createPanicEscapeActionSpec(), + createFleeActionSpec(), + { + id: "hide_at_owned_structure", + need: "safety", + state: "seek_bed", + label: "自分の場所に隠れている", + phrase: "自分の場所へ戻った", + weight: 30, + selectTarget: selectOwnedStructure("grass_bed", 620), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("seek_bed", "自分の場所に隠れている"), + }, + createApproachFriendActionSpec(), + createApproachFamilyActionSpec(), + createApproachMateActionSpec(), + createFightRivalActionSpec(), + createIntimidateEnemyActionSpec(), + { + id: "play", + need: "fulfill", + state: "play_ball", + label: "遊んでいる", + phrase: "遊んだ", + weight: 24, + selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; }, + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("play_ball", "遊んでいる"), + }, + { + id: "build_grass_bed", + need: "fulfill", + state: "build", + label: "かんたんベッドを作っている", + phrase: "かんたんベッドを作った", + weight: 34, + selectTarget: selectRoleTarget("grassMaterial", 520), + canStart(t, world, ctx = {}) { return !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 !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, + start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); }, + }, + { + id: "return_owned_structure", + need: "fulfill", + state: "seek_bed", + label: "自分の場所へ戻っている", + phrase: "自分の場所へ戻った", + weight: 22, + selectTarget: selectOwnedStructure("grass_bed", 620), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start: startMoveToTarget("seek_bed", "自分の場所へ戻っている"), + }, + { + id: "use_plushie", + need: "fulfill", + state: "idle", + label: "ぬいぐるみを抱えている", + phrase: "ぬいぐるみを抱えた", + weight: 18, + selectTarget: selectOwnedStructure("plushie", Infinity), + canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, + start(t, world, ctx = {}) { + const plushie = ctx.target ?? this.selectTarget(t, world, ctx); + plushie?.use?.(t, world); + t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); + return true; + }, + }, + createBirthRitualActionSpec(), + createWanderActionSpec(), ]; +const TARINAI_ACTIONS = typeof registerTarinaiActionSpecs === "function" + ? registerTarinaiActionSpecs(TARINAI_ACTION_DEFINITIONS) + : TARINAI_ACTION_DEFINITIONS; + + + +function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = "#f1dfb7") { + if (!t || !item || !(eating > 0)) return false; + t.beginEating?.(item, label || "\u98df\u3079\u3066\u3044\u308b"); + t.vx *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); + t.vy *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60); + t.eatTimer = Math.max(t.eatTimer || 0, 1.0); + t.eatCooldown = rand(0.96, 1.12); + t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16); + if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal"); + t.lastMealColor = color; + if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) { + const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y }; + t.world?.spawnEatEffect?.(mouth.x, mouth.y, color); + } + if ((t.world?.time || 0) > (t.nextEatSound || -999)) { + audio.eat?.(); + t.nextEatSound = (t.world?.time || 0) + 0.42; + } + return true; +} + +function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { + if (!t || !world || !target || target.dead) return false; + const foodType = effectiveFoodTypeForItem(target); + const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269"; + const effectiveDt = Math.max(0.10, Number(dt) || 0.12); + + if (role === "drink" && (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, { 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?.(bite * 0.08); + return true; + } + + if ((foodType === "sweet" || foodType === "food" || (typeof isServingFoodType === "function" && isServingFoodType(foodType)) || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && t.foodItemHasServingLeft?.(target)) { + const eating = t.consumeFoodServing?.(target); + if (!(eating > 0)) return false; + const isParam = typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType); + const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam; + const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7"; + finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color); + const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8; + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief); + t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46))); + t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; + if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" }); + if (foodType === "sweet") { + applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 }); + if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); + if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); + } + t.makePoop?.(eating * (foodType === "sweet" ? 0.18 : 0.20)); + return true; + } + + if (target.type === "grass" && (target.amount || 0) > 0) { + const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1)); + const bite = Math.min(target.amount, rand(4.2, 7.2) * servingScale); + if (!(bite > 0)) return false; + target.amount -= bite; + target.eatenAmount = (target.eatenAmount || 0) + bite; + target.growth = clamp((target.growth ?? target.amount / 120) - bite / 150, 0.04, 1.2); + finishBehaviorConsumption(t, target, "grass", bite, effectiveDt, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b", "#5b9d39"); + t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.46) : (t.hunger -= bite * 0.46); + t.recoverHealth?.(bite * 0.18); + applyNeedRelief(t, { food: -2, fulfill: -1 }); + t.makePoop?.(bite * 0.10); + return true; + } + + if (target.type === "zunchi" && (target.amount || 0) > 0) { + const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; + if (!t.isZunchiSlave && foodNeed < 82 && (t.hunger || 0) < 88) 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?.(bite * 0.08); + return true; + } + return false; +} function behaviorFoodTarget(t, world, role = "food", maxDist = 860) { const best = findNearestItemWithRole(world, t, role, maxDist); @@ -1007,18 +1951,18 @@ function updateConsumableBehavior(t, world, dt, role = "food") { 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); + 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; - 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; + if (consumeBehaviorTarget(t, world, target, role, dt)) return "finished"; + t.setActionState?.(state, { target, reason: label, sleeping: false }); return t.foodItemHasServingLeft?.(target) ? true : false; } @@ -1037,9 +1981,9 @@ function triggerFriendContact(t, other, world, dt = 0) { 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" }); + if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u306f\u3046", 2.2, "rgba(92,132,78,0.78)"); + t.setActionState?.("idle", { target: other, reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, sleeping: false }); + if (typeof setLiveActionText === "function") setLiveActionText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "\u4ef2\u9593\u3068\u4e00\u7dd2\u306b\u3044\u308b", reasonText: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, target: other, phase: "perform", source: "behavior" }); return "finished"; } @@ -1051,14 +1995,20 @@ function updateSocialContactBehavior(t, world, dt, mode = "bond") { } 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" ? "家族に近づいている" : "仲間に近づいている"); + 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); - if (conflict?.target === other || (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04) { + 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, 120); - if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10) { + const mate = mateTargetFor(t, world, 150); + const parts = t.socialReasonParts || {}; + const mateIntent = mode === "mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate" || (t.loveMochiTimer || 0) > 0.04 || (other.loveMochiTimer || 0) > 0.04; + const mateDominant = Number(parts.mate || 0) >= Math.max(Number(parts.bond || 0), Number(parts.conflict || 0)) + 10; + if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10 && (mateIntent || mateDominant)) { if (world.startBirthRitual?.(t, other)) return "finished"; } return triggerFriendContact(t, other, world, dt); @@ -1069,30 +2019,30 @@ function updateMateBehavior(t, world, dt) { 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", "繁殖できる相手に近づいている"); + const otherWantsMate = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.id) === "approach_mate" || (other.loveMochiTimer || 0) > 0.04 || (other.reproductionTimer || 0) <= 4; + const selfForced = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.loveMochiTimer || 0) > 0.04; + if ((otherWantsMate || selfForced || (t.reproductionTimer || 0) <= 4) && world.startBirthRitual?.(t, other)) return "finished"; + moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b"); if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t); return true; } - return moveToOrUse(t, other, "seek_friend", "繁殖できる相手に近づいている"); + 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.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 }); + 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 || t.activeBehavior?.forced ? 24 : 42); + const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 24 : 42); const current = currentBehaviorTarget(t); - const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, t.activeBehavior?.forced ? 760 : 360)); + const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced ? 760 : 360)); if (!isLiveTarinaiEntity(rival)) return false; t.conflictTargetId = rival.id; - if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "気に入らない相手に向かっている"); - if (candidate?.forced || t.activeBehavior?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); + if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u306b\u5411\u304b\u3063\u3066\u3044\u308b"); + if (candidate?.forced || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior)?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); world.startConflict?.(t, rival); return true; } @@ -1100,28 +2050,48 @@ function updateFightBehavior(t, world, dt) { 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.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b", wake: true, sleeping: false }); } t.fearTimer = Math.max(t.fearTimer || 0, 0.26); if ((Number(needs?.safety || 0) || 0) < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.08) return "finished"; return true; } -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"; } }); +if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINITIONS, TARINAI_ACTIONS, composeTarinaiBehaviorText, patchBehaviorTextFromComposer }); + +function structureBuildProbe(type, x, y) { + const def = globalThis.StructureRegistry?.get?.(type) || {}; + const r = type === "plushie" ? 13 : (type === "grass_bed" ? 18 : 24); + return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" }; +} + +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 }; } -configureTarinaiNeedActions(); function buildOwnedStructureFromGrass(t, world, type, label) { const material = findNearbyMaterial(world, t, "grassMaterial", 520); @@ -1143,7 +2113,8 @@ function continueBuildPlan(t, world, dt) { t.buildPlan = null; return false; } - let material = (world?.items || []).find(item => item && !item.dead && item.id === plan.materialId && roleMatches(item, "grassMaterial")); + 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) { t.buildPlan = null; @@ -1159,16 +2130,17 @@ function continueBuildPlan(t, world, dt) { 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); + const spot = findStructureBuildSpot(t, world, plan.type); + const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world); if (!structure) { t.buildPlan = null; return false; } - world.items.push(structure); + 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 }); - applyNeedRelief(t, { fulfill: -(plan.type === "plushie" ? 50 : 36), safety: plan.type === "plushie" ? -4 : -8 }); + applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); t.buildPlan = null; return true; } @@ -1184,15 +2156,20 @@ function weightedPickAction(actions) { } function chooseActionForNeed(need, tarinai, world) { - const actions = TARINAI_ACTIONS.filter(action => action.need === need && (!action.condition || action.condition(tarinai, world))); + const actions = TARINAI_ACTIONS.filter(action => action.need === need && (typeof canStartTarinaiAction === "function" ? canStartTarinaiAction(action, tarinai, world) : (!action.condition || action.condition(tarinai, world)))); const weighted = actions.map(action => { let bonus = 0; if (need === "social") { const p = tarinai?.socialReasonParts || {}; - 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; + 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); @@ -1206,13 +2183,61 @@ function chooseActionForNeed(need, tarinai, world) { 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?.id) === 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 (typeof canStartTarinaiAction === "function" ? !canStartTarinaiAction(action, tarinai, world) : (action.condition && !action.condition(tarinai, world))) continue; result.push(action); } return result; @@ -1226,10 +2251,15 @@ function choosePriorityNeedAction(needs, tarinai, world) { 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; + 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); @@ -1238,21 +2268,50 @@ function choosePriorityNeedAction(needs, tarinai, world) { } const priority = actionPriority(action); const needValue = Number(needs?.[need] || 0) || 0; - return { action, priority, score: priority * 1000 + needValue * 3 + (Number(action.weight || 1) + bonus) }; + 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) + .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 && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced)) return false; + } 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; } @@ -1265,10 +2324,19 @@ function startNeedAction(tarinai, world, choice, action, reasonText, options = { phase: "start", }); tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, lockSeconds); - const ran = action?.run?.(tarinai, world, options); + 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 ((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced) retryForcedBehavior(tarinai, (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior), "start_failed"); + tarinai.actionCooldowns = tarinai.actionCooldowns || {}; + tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {}; + tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] = Math.max(tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] || 0, 1.4); + tarinai.actionIdCooldowns[action?.id || "unknown"] = Math.max(tarinai.actionIdCooldowns[action?.id || "unknown"] || 0, 2.2); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); + clearActiveBehavior(tarinai, (typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(tarinai) : "") || action?.label || "\u5f85\u3063\u3066\u3044\u308b"); + tarinai.goIdle?.(action?.label || "\u5f85\u3063\u3066\u3044\u308b"); } else { const target = tarinai.target || options.target; setActiveBehaviorFromAction(tarinai, action, choice, reasonText, { @@ -1276,7 +2344,7 @@ function startNeedAction(tarinai, world, choice, action, 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,60 +2353,86 @@ 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; + const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior; + const actionId = behavior?.id; if (!actionId) return false; - if (shouldInterruptCurrentAction(tarinai, needs) && !tarinai?.activeBehavior?.forced) return false; + if (shouldInterruptCurrentAction(tarinai, needs) && !(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior)?.forced) 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 && (typeof canStartTarinaiAction === "function" ? !canStartTarinaiAction(action, tarinai, world) : (action.condition && !action.condition(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) { + || (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04) + || ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15); + const foodSuppressed = need === "food" && !(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15); + if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior)?.forced)) { tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed))); const refreshedReason = refreshIntentReasonText(tarinai, action); - const ranUpdate = action.update?.(tarinai, world, dt, needs); + const ranUpdate = typeof tickTarinaiAction === "function" + ? tickTarinaiAction(action, tarinai, world, dt, needs, { phase: "continue" }) + : action.update?.(tarinai, world, dt, needs); if (ranUpdate === "finished") { + if (typeof finishTarinaiAction === "function") finishTarinaiAction(action, tarinai, world, { phase: "finished" }); + const finishedBehavior = behavior; + if (finishedBehavior?.forced) completeForcedBehavior(tarinai, finishedBehavior, "completed"); tarinai.actionCooldowns = tarinai.actionCooldowns || {}; tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); clearActiveBehavior(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 (behavior?.forced) retryForcedBehavior(tarinai, behavior, "update_failed"); + tarinai.actionCooldowns = tarinai.actionCooldowns || {}; + tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {}; + tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); + tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 2.4); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55); + clearActiveBehavior(tarinai); + return false; + } + setActiveBehaviorFromAction(tarinai, action, { need, tiedNeeds: behavior?.tiedNeeds || [need] }, refreshedReason || behavior?.reasonText || 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: Boolean(behavior?.forced), + priority: behavior?.priority || 0, + causeText: behavior?.causeText || "", + sourceReasonText: behavior?.sourceReasonText || "", + forcedReasonText: behavior?.forcedReasonText || "", + 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 = activeBehaviorText(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); + tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.35); clearActiveBehavior(tarinai); return false; } @@ -1348,6 +2442,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 +2455,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; @@ -1373,18 +2470,22 @@ function protectSleepSession(tarinai, world, needs) { tarinai.sleepSession = { startedAt: now, minDuration: rand(12, 22), targetEnergy: rand(80, 92), maxDuration: rand(34, 54), targetId: tarinai.target?.id || null }; } 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.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b"; + setActiveBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reasonText: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", presentText: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" }); tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, 0.8); + 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.sleeping = false; tarinai.sleepSession = null; - tarinai.currentAction = null; + clearActiveBehavior(tarinai); if (sleptLongEnough) applyNeedSatisfaction(tarinai, { sleep: 28, safety: 6 }, "sleep"); - tarinai.goIdle?.("目が覚めた"); + tarinai.goIdle?.("\u76ee\u304c\u899a\u3081\u305f"); return false; } @@ -1406,7 +2507,9 @@ function protectSleepSession(tarinai, world, needs) { 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() { @@ -1520,17 +2623,18 @@ function protectSleepSession(tarinai, world, needs) { isSunbathTime() { const weatherOk = (this.world?.weather || "") === "sunny"; const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24); - return weatherOk && hour >= 6 && hour <= 15.5; + return weatherOk && hour >= 6 && hour <= 13.5; }, canStartSunbath() { - if (this.dead || this.sleepDisease || this.sleeping) return false; + if (this.dead || this.sleeping) return false; if (!this.isSunbathTime()) return false; if ((this.sunbathCooldown || 0) > 0) return false; - if (this.hasLodgedPinEffect?.("blocksZunchi")) return false; + if (this.stuckPushpinId || this.currentLodgedPin?.()) 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; + const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; + return healthNeed >= needThreshold("health", "start"); }, tryRecoverBySunbath() { @@ -1547,15 +2651,28 @@ function protectSleepSession(tarinai, world, needs) { return healed; }, + 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() { 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.setActionState?.("sunbath", { target: null, reason: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", sleeping: false }); + this.sunbathTimer = Math.max(6, (CONFIG.dayLength || 120) / 12); + this.sunbathCooldown = 0; this.sunbathFrameTimer = 0; this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); this.sunbathRecoveryChecked = false; - this.tryRecoverBySunbath(); + this.vx = 0; + this.vy = 0; + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reasonText: "\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 +2681,17 @@ 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); + this.sunbathFrameTimer = 0.4; } - 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.goIdle("\u65e5\u5149\u6d74\u3092\u7d42\u3048\u305f"); } }, @@ -1609,6 +2728,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); @@ -1939,30 +3061,31 @@ 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)) { + const lockedBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior; + if ((this.intentLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { if (continueCurrentIntent(this, this.world, needs, dt)) return; - this.thought = this.intent.reasonText || this.intent.actionLabel || this.thought; + this.thought = lockedBehavior.reasonText || 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: "繁殖の前ぶれをしている" }); + 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" }); + this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" }); this.bubble("!", 1.2); return; } @@ -1977,9 +3100,9 @@ function protectSleepSession(tarinai, world, needs) { 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 setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" }); this.bubble("!", 1.6); return; } @@ -1987,7 +3110,7 @@ function protectSleepSession(tarinai, world, needs) { if (this.defeatedTimer > 0.04) { const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); 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 +3128,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 +3144,27 @@ 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 ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) { + this.intentLockTimer = Math.max(this.intentLockTimer || 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 picked = choosePriorityNeedAction(needs, this, this.world); if (!picked?.action) { const choice = chooseTopNeedRandom(needs, this, this.world); - this.intent = { need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], actionId: "idle", actionLabel: "待っている", reasonText: "少し落ち着いている。", startedAt: this.world?.time || 0 }; + setActiveBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reasonText: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 }); clearActiveBehavior(this); this.intentLockTimer = Math.max(this.intentLockTimer || 0, 3.0); - this.goIdle?.("少し落ち着いている"); + this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b"); return; } const action = picked.action; @@ -2068,21 +3201,22 @@ function protectSleepSession(tarinai, world, needs) { 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(); + const foodCandidates = this.world.nearbyItems(this.x, this.y, this.radius + 46).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))); + 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"; @@ -2236,7 +3370,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; } @@ -2275,7 +3409,7 @@ function protectSleepSession(tarinai, world, needs) { 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); } } diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 23626ca..023ea6c 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -300,16 +300,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(); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 7ff70e1..2eee469 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -22,8 +22,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 +136,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 +156,10 @@ 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 mateIntent = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.id) === "approach_mate"; if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) { this.world.startBirthRitual(this, o); } @@ -197,7 +197,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); @@ -276,9 +276,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; + if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null; this.intentLockTimer = 0; - this.goIdle?.("少し歩いた"); + this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f"); return; } if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) { @@ -406,11 +406,11 @@ 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) this.world.addItem?.(new Item("grass", spot.x, spot.y), "tarinai-death-grass") || this.world.items.push(new Item("grass", spot.x, spot.y)); } this.world.markDead(this, finalReason); this.releasedLiveToken = this.liveToken || 0; diff --git a/js/text_catalog.js b/js/text_catalog.js index 90d3086..27bba71 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -53,34 +53,34 @@ 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", + 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: "きずつき病", + 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; @@ -90,53 +90,53 @@ 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 (!t) return "\u306a\u3057"; + if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b"; + const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim(); if (behaviorText) return behaviorText; const 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_bind.js b/js/ui_bind.js index 68d28f2..7be46cb 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -189,7 +189,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); @@ -380,9 +380,9 @@ function bindUI() { target.y = uiCache.grabOrigin.y; target.vx = uiCache.grabOrigin.vx || 0; target.vy = uiCache.grabOrigin.vy || 0; - target.thought = "フィールドに戻された"; + target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f"; target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - showToast("フィールド外なので元の位置に戻しました。"); + 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; } @@ -403,10 +403,12 @@ function bindUI() { uiCache.grabStartX = e.touches?.[0]?.clientX || 0; uiCache.grabStartY = e.touches?.[0]?.clientY || 0; if (uiCache.grabKind === "tarinai") { + const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); + if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch"); target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); + 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 = "つままれている"; + 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"); @@ -540,7 +542,7 @@ function bindUI() { target.vx = clamp(dx * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160); if (uiCache.grabKind === "tarinai") { - target.goIdle?.("つままれている"); + target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); } else if (target.type === "ball") { target.prevX = target.x - dx; @@ -658,10 +660,12 @@ function bindUI() { uiCache.grabStartX = e.clientX; uiCache.grabStartY = e.clientY; if (uiCache.grabKind === "tarinai") { + const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); + if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch"); target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); + 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 = "つままれている"; + 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"); @@ -761,7 +765,7 @@ function bindUI() { target.vx = clamp(dx * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160); if (uiCache.grabKind === "tarinai") { - target.goIdle?.("つままれている"); + target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); } else if (target.type === "ball") { target.prevX = target.x - dx; target.prevY = target.y - dy; diff --git a/js/ui_charts.js b/js/ui_charts.js index d2217a4..0b97725 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -18,7 +18,7 @@ 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 || "\u306e\u3093\u3073\u308a"); updateColonyHistory(values); drawColonyChart(values); renderSelected(); diff --git a/js/ui_selected.js b/js/ui_selected.js index 39136b7..308507f 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -169,20 +169,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) { @@ -216,9 +216,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?.id || "", behaviorState?.label || "", behaviorState?.reasonText || "", behaviorState?.id || "", behaviorState?.presentText || "", 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 +274,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 +287,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 +303,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 +323,20 @@ 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 activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).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?.reasonText) return behaviorState.reasonText; + return live || "\u306a\u3057"; } function targetLabel(target) { - return window.TEXT_CATALOG?.targetLabel?.(target) || "なし"; + return window.TEXT_CATALOG?.targetLabel?.(target) || "\u306a\u3057"; } function fightWinRateLabel(t) { diff --git a/js/ui_tools.js b/js/ui_tools.js index 37d6733..b666f60 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -4,7 +4,7 @@ const SCALABLE_TOOLS = new Set(scalableToolIds()); const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; -const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" }; +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; @@ -41,6 +41,7 @@ function renderToolPalette() { function attachToolSpritePreview(btn, def, toolId) { const itemType = def?.itemType || toolId; if (!btn || !itemType || !def?.placeable) return; + if (toolId === "ball" || itemType === "ball") return; const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite; if (typeof drawPreview !== "function") return; const canvas = document.createElement("canvas"); diff --git a/js/version.js b/js/version.js index a4258ad..7ec90cf 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 = "15.24.35"; + const APP_BUILD = "owned-bed-duplicator-priority"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index a37b0be..91e023c 100644 --- a/js/world.js +++ b/js/world.js @@ -67,7 +67,7 @@ class World { constructor() { this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; - this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } }; + this.colonyMood = { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", 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..1a5a4f3 100644 --- a/js/world_ants_system.js +++ b/js/world_ants_system.js @@ -6,7 +6,7 @@ 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"); + 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; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index d1fd863..3ca7a03 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); @@ -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); @@ -279,13 +279,12 @@ 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) { @@ -300,8 +299,7 @@ } const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40)); it.amount = 260; - this.items.push(it); - this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + this.addItem?.(it, "zunchi-spawn") || this.items.push(it); audio.place?.("zunchi"); }, @@ -439,8 +437,8 @@ 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; }, @@ -492,8 +490,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 +577,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)); @@ -641,7 +639,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 +680,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; @@ -816,14 +815,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 +928,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 +936,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_family_social.js b/js/world_family_social.js index 514fb38..1a4c3df 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -39,6 +39,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 }; @@ -62,8 +63,8 @@ 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++) this.addItem?.(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)), "reset-grass") || this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); + 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.updateEffectCounts(); this.markTerrainDirty?.("reset"); @@ -342,7 +343,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; } @@ -428,11 +429,17 @@ b.birthRitualRole = 1; a.birthRitualLeader = true; b.birthRitualLeader = false; - a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" }); - b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" }); + a.setActionState("birth_ritual", { target: b, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" }); + b.setActionState("birth_ritual", { target: a, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" }); if (typeof setLiveActionText === "function") { - 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 aBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(a) : a.behavior; + const bBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(b) : b.behavior; + const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`; + const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`; + const aText = typeof buildReasonText === "function" ? buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause }) : `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`; + const bText = typeof buildReasonText === "function" ? buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }) : `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`; + setLiveActionText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b }); + setLiveActionText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a }); } a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); @@ -447,8 +454,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; @@ -579,11 +586,39 @@ 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 +636,7 @@ t.goIdle(""); } this.startFightMochiIntimidation(a, b); - this.startFight(a, b); + this.startFight(a, b, { forced: true, initiator: a }); return true; }, @@ -629,33 +664,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 +708,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 setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); 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 +726,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 +749,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 || "相手"}と喧嘩している` }); + 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 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" }); + setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" }); + setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" }); } - 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); @@ -761,6 +819,7 @@ this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] }); a.lastLog = b.lastLog = this.time; } + return true; } })); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 6a82d6e..85b1415 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -18,10 +18,10 @@