diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index 305a63f..a18517f 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,23 +1,27 @@ # Patch notes -## 15.20.12 +## 15.20.16 - Added canonical item visual, food, and disease registries; wired food decay/visual helpers and mystery-drug disease sampling to registry data. - Generated the tool palette from `TOOL_CATEGORIES` at runtime and removed duplicated category markup from `index.html`. -- Added compact family-tree personality tags and a selected-Tarinai recent-change section for personality, damage, item-effect, disease, and ant-carry causes. +- Added compact family-tree personality tags, daily personality-change details, and selected-Tarinai rolling recent-change causes for personality, damage, item-effect, disease, and ant-carry changes. - Added `InputModeManager` for touch-first mobile classification and `WeatherSystem` for weather transitions/rain-water spawning. - Added central domain events for placement, eating, decay, effects, diseases, damage, death, birth, fights, ants, weather, and rain item drops. - Moved item-effect modifiers, active summaries, apply/tick behavior callbacks, and food interest class queries into registries. +- Moved explosion-disease base infection chance reads into `DiseaseRegistry` while preserving protein/niterop disease multipliers. +- Moved serving-food nutrition and hunger-relief reads into `FoodRegistry`, including laxative's zero hunger relief. +- Routed direct love/fight/sleep item eating through `EffectRegistry` while preserving the previous bite-specific stress, fear, and sleep-disease chance behavior. +- Routed sleep drug, rain-water item, and water-bowl field visuals through `item_visual_registry.js`; added water-bowl icon-generation support. - Moved canonical sound gains, sample paths, categories, cooldowns, and ID category mapping out of `audio.js` into `sound_pack.js`. - Added named ant ecology extension hooks/comments without changing ant balance. -- Bumped cache/version references to `15.20.12`; documented the new source-of-truth rules. +- Bumped cache/version references to `15.20.16`; documented the new source-of-truth rules. ## 15.20.12 - Updated documentation after inspecting the current game surface: field choices, startup flow, tool groups, chart/family/debug surfaces, localStorage settings, and validation fallback when Node is unavailable. - Removed unused `css/styles.css` shim and no-op `js/ui_family.js` loader entry; refactored duplicate world initialization and allocation-heavy live-count paths. -- Removed item-effect speech bubbles: `ドン`, `ぐるる`, `すっきり`, `Hg xN`, `ちまっ`, `でかっ`, `へにゃ`, `ムキッ`, and `mm...`. +- Removed the unwanted item-effect speech bubbles listed in `docs/prompt.txt`. - Reorganized the tool palette into color-coded collapsible groups: operation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles. - Changed the tool UI ball icon to a soccer-ball emoji preview. - Changed time phases: morning 05:00, day 10:00, evening 17:00, night 20:00. @@ -32,3 +36,88 @@ - Consolidated documentation into dense English files; left `docs/prompt.txt` untouched. - Restored rain, revised water/zunda/mercury item sprites, added zunchi-slave head zunchi, fight win rate, fight defeat chance, and post-wake lock. - Earlier patches added parameter items, persistent item effects, WebP assets, sound categories, push notifications, ant systems, family tree UI, performance manager, event bus, manifest generation, CSS split, and large-file JS split. + +## Debug patch: panic chain and action reasons + +- Removed the friend-only panic chain that made only a best friend follow a panic target. +- Added general nearby panic contagion: any nearby live tarinai in panic can cause another tarinai to panic. Neuroticism, distance, and current stress affect the probability. +- Added / completed visible action reasons for: + - eating all food and effect items + - eating grass, ant corpses, and zunchi + - drinking / touching water + - resting near stone, bed, and nest box + - sunbathing + - zunchi-slave avoidance + - being pinched / dragged + - firecracker blast panic + - explosion-disease blast panic + - falling object / stone / genkotsu impact + - high-speed ball collision + - high-speed tarinai collision + - intimidation, intimidation escape, fights, birth ritual, and thaw surprise + - fence-blocked path give-up +- Added state labels for `sunbath`, `ant_attack`, `ant_intimidate`, and `frozen`. +- Expanded target labels for all current tool/item target types, including medicine/effect items, ant corpses, ant nests, firecrackers, pushpins, signboards, and fences. +- Verification performed: + - `node --check` over every `js/*.js` + - state-label coverage scan: no uncovered tarinai states + - old friend-panic chain scan: zero matches + + +## おしり鋲・冷凍操作変更パッチ +- ずんちどれい化条件の喧嘩勝率を30%以下に変更。 +- 衰弱死因判定を厳格化。 +- 冷凍手段をクリック式から「つまむで冷凍UIへ運ぶ」方式へ変更。 +- 新アイテム「おしり鋲」を追加。刺さるとずんちを溜め、日光浴不可。抜くと溜まったずんちが高速で飛散。 +- 高速ずんち衝突時の吹き飛び・パニック理由文を追加。 + +## 15.22.0 統合リファクタ + +- `snapshot_system.js` を追加し、セーブ/ロードと冷凍で重複していた `clonePlain` / `copyOwnData` / `applyOwnData` / 参照復元 / スナップショット作成・復元を共通化。 +- `tarinai_action_state.js` を追加し、`setActionState()` / `enterPanic()` / `beginEating()` により、状態変更・パニック化・食事開始の理由文設定を統合。 +- 直接 `state = "panic"` していた処理を `enterPanic()` 経由へ変更。爆竹、爆発病、高速ボール、高速衝突、アリ、画鋲、近傍パニック連鎖、関係者死亡、解凍時の訃報などが対象。 +- 直接 `state = "eat"` していた食事処理を `beginEating()` 経由へ変更。 +- `physics_helpers.js` を追加し、壁反射・摩擦・回転を `TarinaiPhysics.applyKinematicItemMotion()` に共通化。おしり鋲/画鋲/高速ずんちの移動処理に適用。 +- `ITEM_TRAITS` と `PIN_BEHAVIORS` を追加し、アイテム分類と画鋲/おしり鋲の差分挙動をテーブル化。 +- `text_catalog.js` に状態ラベル、対象ラベル、理由文補完を集約。`ui_selected.js` はカタログ参照へ変更。 +- `family_graph.js` を追加し、家系グラフの連結成分探索を共通化。同期家系データと描画側に適用。 +- `relation_event_system.js` を追加し、関係者死亡の即時反応と冷凍個体への遅延通知を共通化。 +- `ui_helpers.js` を追加し、HTMLエスケープ等のUI共通処理を切り出し。 +- 構文チェック: 全 `js/*.js` OK。 +- 重複確認: `clonePlain` / `copyOwnData` / `applyOwnData` は `snapshot_system.js` に集約、直接 `state = "panic"` と直接 `state = "eat"` は除去済み。 + +## 15.23.0 safe duplicate integration pass + +- `registry_base.js` を追加し、`food_registry.js` / `effect_registry.js` / `disease_registry.js` の `get` / `has` / `label` / `color` / `debugSnapshot` 系の共通基盤を統合。 +- `patch_helpers.js` を追加し、後付けの `World.prototype` パッチを `TarinaiPatcher.patchWorld()` 経由に統合。冷凍・関係者死亡通知で利用。 +- `data.js` に `sleepFurniture` / `draggable` trait と、`isPinType()` / `isSleepFurnitureType()` / `lodgedPinFor()` / `hasLodgedPinEffect()` / `normalizedToolSizeFor()` / `toolSizeScaleFor()` を追加。 +- `ui_tools.js` と `world_view.js` のツールサイズ算出を `normalizedToolSizeFor()` / `toolSizeScaleFor()` に統合。 +- `tarinai_action_state.js` に `currentLodgedPin()` / `currentLodgedPinBehavior()` / `hasLodgedPinEffect()` / `goIdle()` / `startSleeping()` / `seekTarget()` を追加。 +- `blockedByOshibyo` 系の重複判定を `hasLodgedPinEffect("blocksZunchi")` に統合。 +- 画鋲/おしり鋲の型判定を `isPinType()` に寄せ、描画・配置・つまみ・削除判定の分岐を整理。 +- 睡眠家具判定を `isSleepFurnitureType()` に寄せ、ベッド/巣箱の直書き判定を削減。 +- 手動アリ近傍探索の一部を `world.nearbyAnts()` に寄せた。 +- `pruneExtinctFamilies()` を `TarinaiFamilyGraph.connectedComponents()` に寄せた。`family_graph.js` 側のデフォルト edge も親子参照を使うようにした。 +- `save_system.js` / `freeze_system.js` / `ui_selected.js` のHTMLエスケープを `TarinaiUIHelpers.htmlEscape()` に統合。 +- `assets.js` のスプライト/汚れオーバーレイ用キャンバス生成を `TarinaiPhysics.createCachedCanvas()` に寄せた。 +- 危険度の高い `render.js` 全体再設計、`Tarinai.update()` の状態機械化、`ants.js` の状態機械化、`items.js` 描画全体のレジストリ化は未実施。 +- 確認: 全 `js/*.js` の `node --check` 通過。 + +### 15.23.0 追確認 + +- `service-worker.js` / `app_manifest.json` のスクリプト一覧を `index.html` の実ロード順に同期。新規共通化ファイルもキャッシュ対象に追加。 +- アリ運搬中に寝ているたりないの状態変更も `startSleeping()` 経由へ寄せた。 +- 最終確認: 全 `js/*.js` と `service-worker.js` の `node --check` 通過。 +- 最終確認: `blockedByOshibyo`、`type === "oshibyo"`、`type === "pushpin"` の直接判定は 0件。`clonePlain` / `copyOwnData` / `applyOwnData` / `isPlainObject` は `snapshot_system.js` のみ。 + +## 15.24.0 safe integration / UI escape recursion fix + +- Fixed `Maximum call stack size exceeded` caused by `ui_helpers.htmlEscape()` calling the global `escapeHtml()` while `ui_selected.escapeHtml()` called back into `TarinaiUIHelpers.htmlEscape()`. +- Made `TarinaiUIHelpers.htmlEscape()` a pure escaping function and loaded `ui_helpers.js` before `ui_selected.js`. +- Added a guard in `ui_selected.escapeHtml()` so it never delegates to itself. +- Added a data URL favicon link to stop the browser's default `/favicon.ico` 404 request. +- Bumped static asset version to `15.24.0` in `app_manifest.json`, `index.html`, `service-worker.js`, and `js/version.js` to avoid stale cached scripts. +- Reduced target-label duplication: item labels now come from `TOOL_DEFINITIONS` / `toolLabel()`, with `text_catalog.js` holding only non-tool overrides. +- Removed the remaining synchronous family-tree DFS fallback blocks in `ui_family_data.js` and `ui_family_render.js`; both now use `TarinaiFamilyGraph.connectedComponents()`. +- Simplified the remaining manual ant proximity fallback in `tarinai_needs_items.js` to `world.nearbyAnts()`. +- Verification: all `js/*.js` and `service-worker.js` pass `node --check`. diff --git a/README.md b/README.md index 9466597..7a87766 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Tarinai Colony Observation Game +# 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 81909f0..fbe5b36 100644 --- a/js/ants.js +++ b/js/ants.js @@ -289,15 +289,11 @@ class AntActor { } target.target = null; if (targetWasSleeping) { - target.sleeping = true; - target.state = "sleep"; + target.startSleeping?.(null, "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"); target.sleepTimer = Math.max(target.sleepTimer || 0, 0.8); - target.thought = "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b"; } else { - target.state = "panic"; - target.sleeping = false; - target.fearTimer = Math.max(target.fearTimer || 0, 0.36); - target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b"; + if (target.enterPanic) target.enterPanic({ threat: this, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", fear: 0.36, wake: true, cause: "ant_drag" }); + else { target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(this) : null, reason: "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b", wake: true }); target.fearTimer = Math.max(target.fearTimer || 0, 0.36); } } const dHome = distXY(target.x, target.y, home.x, home.y) || 1; if (dHome < Math.max(18, home.r * 0.66)) { diff --git a/js/assets.js b/js/assets.js index f8153aa..50b7510 100644 --- a/js/assets.js +++ b/js/assets.js @@ -10,8 +10,8 @@ const stainOverlayCache = new Map(); const SPRITE_CACHE_LIMIT = 128; const STAIN_CACHE_LIMIT = 64; -const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck"]); -const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck"]); +const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); +const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]); function trimCanvasCache(cache, limit = 128) { while (cache.size > limit) { @@ -32,6 +32,13 @@ function getCachedSpriteCanvas(id, img, w, h) { const qw = quantizedCanvasSize(w); const qh = quantizedCanvasSize(h); const key = `${id}:${qw}x${qh}`; + if (window.TarinaiPhysics?.createCachedCanvas) { + return window.TarinaiPhysics.createCachedCanvas(scaledSpriteCache, key, qw, qh, (c) => { + if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; + c.clearRect(0, 0, qw, qh); + c.drawImage(img, 0, 0, qw, qh); + }, { limit: SPRITE_CACHE_LIMIT }); + } let canvas = scaledSpriteCache.get(key); if (canvas) return canvas; canvas = document.createElement("canvas"); @@ -54,16 +61,10 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8))); const seedBucket = Math.round((seed || 0) * 16); const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`; - let canvas = stainOverlayCache.get(key); - if (canvas) return canvas; - canvas = document.createElement("canvas"); - canvas.width = qw; - canvas.height = qh; - const c = canvas.getContext("2d"); - c.imageSmoothingEnabled = true; - if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; - c.clearRect(0, 0, qw, qh); - c.drawImage(img, 0, 0, qw, qh); + const buildOverlay = (c) => { + if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high"; + c.clearRect(0, 0, qw, qh); + c.drawImage(img, 0, 0, qw, qh); c.globalCompositeOperation = "source-in"; c.globalAlpha = 0.34 + bucket * 0.075; c.fillStyle = "rgba(28, 92, 22, 0.96)"; @@ -81,6 +82,16 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) { } c.globalAlpha = 1; c.globalCompositeOperation = "source-over"; + }; + if (window.TarinaiPhysics?.createCachedCanvas) return window.TarinaiPhysics.createCachedCanvas(stainOverlayCache, key, qw, qh, buildOverlay, { limit: STAIN_CACHE_LIMIT }); + let canvas = stainOverlayCache.get(key); + if (canvas) return canvas; + canvas = document.createElement("canvas"); + canvas.width = qw; + canvas.height = qh; + const c = canvas.getContext("2d"); + c.imageSmoothingEnabled = true; + buildOverlay(c); stainOverlayCache.set(key, canvas); trimCanvasCache(stainOverlayCache, STAIN_CACHE_LIMIT); return canvas; diff --git a/js/data.js b/js/data.js index 504424b..6773a1a 100644 --- a/js/data.js +++ b/js/data.js @@ -30,6 +30,9 @@ const SPRITES = [ { id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.webp", displayOnly: true }, { id: "hungry_70", label: "\u7a7a\u817970\u4ee5\u4e0a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_29_hungry_70.webp", displayOnly: true }, { id: "zunchi_slave_alt", label: "\u305a\u3093\u3061\u3069\u308c\u3044 2", path: "assets/sprites/tarinai_30_zunchi_slave_alt.webp", displayOnly: true }, + { id: "sunbath_1", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 1", path: "assets/sprites/tarinai_31_sunbath_01.webp", actionOnly: true }, + { id: "sunbath_2", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 2", path: "assets/sprites/tarinai_32_sunbath_02.webp", actionOnly: true }, + { id: "sunbath_3", label: "\u65e5\u5149\u6d74\u305f\u308a\u306a\u3044 3", path: "assets/sprites/tarinai_33_sunbath_03.webp", actionOnly: true }, ]; const DECOR_ASSETS = [ { id: "zunchi", path: "assets/objects/zunchi.webp" }, @@ -39,6 +42,8 @@ const DECOR_ASSETS = [ { id: "genkotsu", path: "assets/objects/genkotsu.webp" }, { id: "pushpin", path: "assets/objects/pushpin.webp" }, { id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" }, + { id: "oshibyo", path: "assets/objects/oshibyo.webp" }, + { id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" }, ]; const TOOL_DEFINITIONS = Object.freeze({ @@ -48,6 +53,7 @@ const TOOL_DEFINITIONS = Object.freeze({ pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, + freeze: { id: "freeze", label: "冷凍", placeable: false, scalable: false, iconText: "\u{1F9CA}", tooltip: "つまむでたりないを冷凍個体UIまで運ぶと冷凍保存する。" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, @@ -71,8 +77,10 @@ const TOOL_DEFINITIONS = Object.freeze({ 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: 24, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "\u77ed\u3044\u30c6\u30ad\u30b9\u30c8\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u3002\u305f\u308a\u306a\u3044\u306b\u5bfe\u3059\u308b\u52b9\u679c\u306f\u7121\u3057" }, 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: "おしり鋲", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/objects/oshibyo.webp", tooltip: "刺さるとずんちが出なくなる。つまんで抜くと溜まったずんちが弾け飛ぶ。" }, fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true }, @@ -82,12 +90,55 @@ const TOOL_DEFINITIONS = Object.freeze({ ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true }, }); + +const ITEM_TRAITS = Object.freeze({ + obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h"]), + food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + pin: new Set(["pushpin", "oshibyo"]), + kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), + sleepFurniture: new Set(["bed", "nest_box"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo"]), +}); + +function itemHasTrait(type = "", trait = "") { + return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || ""))); +} + +function itemTraitsFor(type = "") { + const out = []; + for (const [trait, set] of Object.entries(ITEM_TRAITS)) if (set.has(String(type || ""))) out.push(trait); + return out; +} + +function isPinType(type = "") { return itemHasTrait(type, "pin"); } +function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } +function lodgedPinFor(tarinai = null, worldRef = null) { + if (!tarinai?.stuckPushpinId) return null; + const items = worldRef?.items || tarinai.world?.items || []; + return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null; +} +function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); } +function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); } + +const PIN_BEHAVIORS = Object.freeze({ + pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, + oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true }, +}); + +function pinBehaviorFor(type = "") { + return PIN_BEHAVIORS[String(type || "")] || null; +} + + const TOOL_CATEGORIES = Object.freeze([ { id: "operate", label: "\u64cd\u4f5c\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, { id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, { id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball"] }, - { id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "fence_v", "fence_h"] }, + { id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard"] }, + { 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: "freeze", label: "冷凍", themeClass: "tool-category-freeze", order: 99, toolIds: ["freeze"] }, ]); function toolCategories() { @@ -120,6 +171,18 @@ function itemAmountFor(type = "", fallback = 80) { return itemDefinition(type)?.amount ?? fallback; } +const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); +const TOOL_SIZE_LABELS = Object.freeze({ small: "小", medium: "中", large: "大" }); +const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 }); +function normalizedToolSizeFor(worldRef = null, type = "") { + const def = itemDefinition(type); + if (!def?.scalable) return "medium"; + const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium"; + return TOOL_SIZE_ORDER.includes(value) ? value : "medium"; +} +function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; } +function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; } + function scalableToolIds() { return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); } @@ -163,6 +226,9 @@ const ALPHA_BOUNDS = { ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 }, ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 }, genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 }, + sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, + sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 }, + sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 }, }; const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"]; diff --git a/js/disease_registry.js b/js/disease_registry.js index 425694c..ca63c50 100644 --- a/js/disease_registry.js +++ b/js/disease_registry.js @@ -45,14 +45,16 @@ } }; - class DiseaseRegistry { - constructor(defs) { this.defs = new Map(Object.entries(defs || {})); } - get(id = "") { return this.defs.get(String(id || "")) || null; } + class DiseaseRegistry extends global.TarinaiRegistryBase { byFlag(flag = "") { return [...this.defs.values()].find(def => def.flag === flag) || null; } - label(id = "") { return this.get(id)?.label || id || ""; } - color(id = "") { return this.get(id)?.color || "rgba(255,242,160,0.85)"; } + label(id = "") { return super.label(id, id || ""); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } + infectionChance(id = "", fallback = 0) { + const value = this.get(id)?.baseInfectionChance; + return Number.isFinite(value) ? value : fallback; + } mysteryPool() { return [...this.defs.values()].filter(def => def.mysteryEligible).map(def => def.id); } - debugSnapshot() { return [...this.defs.values()].map(def => ({ id: def.id, label: def.label, flag: def.flag, cures: def.cureRules || [] })); } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id: def.id || id, label: def.label, flag: def.flag, cures: def.cureRules || [] })); } } const registry = new DiseaseRegistry(rawDefinitions); diff --git a/js/effect_registry.js b/js/effect_registry.js index 20c3f66..4d73aa4 100644 --- a/js/effect_registry.js +++ b/js/effect_registry.js @@ -108,7 +108,8 @@ onApply(tarinai, context = {}) { const scale = context.scale ?? 1; tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); - tarinai.stress = clamp(tarinai.stress - 8, 0, 130); + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress - (context.source === "eating" ? nutrition * 0.25 : 8), 0, 130); return true; }, }); @@ -116,12 +117,25 @@ onApply(tarinai, context = {}) { const scale = context.scale ?? 1; tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); - tarinai.stress = clamp(tarinai.stress + 10, 0, 130); + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress + (context.source === "eating" ? nutrition * 0.22 : 10), 0, 130); + if (context.source === "eating") tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); return true; }, }); Object.assign(rawDefinitions.sleep_drug, { onApply(tarinai, context = {}) { + if (context.source === "eating") { + const nutrition = Number(context.nutrition) || 0; + tarinai.stress = clamp(tarinai.stress - nutrition * 0.10, 0, 130); + tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); + tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); + tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); + const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); + const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; + if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; + return true; + } return tarinai.infectSleepDisease?.(context.item || null) || true; }, }); @@ -180,12 +194,9 @@ onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, }); - class EffectRegistry { - constructor(defs) { this.defs = new Map(Object.entries(defs || {})); } - get(id = "") { return this.defs.get(String(id || "")) || null; } - has(id = "") { return this.defs.has(String(id || "")); } - label(id = "") { return this.get(id)?.label || id || "\u4e0d\u660e"; } - color(id = "") { return this.get(id)?.color || "rgba(255,242,160,0.85)"; } + class EffectRegistry extends global.TarinaiRegistryBase { + label(id = "") { return super.label(id, id || "\u4e0d\u660e"); } + color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); } display(id = "", timer = 0, tarinai = null) { const def = this.get(id); if (!def) return ""; @@ -236,8 +247,8 @@ detail: this.display(id, this.timerValue(tarinai, id), tarinai), })); } - labels() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.label])); } - colors() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.color]).filter(([, color]) => color)); } + labels() { return super.labels(); } + colors() { return super.colors(); } randomPool() { const itemEffects = [...this.defs.entries()] .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) diff --git a/js/family_graph.js b/js/family_graph.js new file mode 100644 index 0000000..5534f89 --- /dev/null +++ b/js/family_graph.js @@ -0,0 +1,49 @@ +"use strict"; + +(function (global) { + function buildGraph(nodes = [], opts = {}) { + const idOf = opts.idOf || (n => n?.id); + const edgesOf = opts.edgesOf || ((n) => [...(n?.parents || []), ...(n?.children || [])]); + const byId = new Map(nodes.filter(Boolean).map(n => [idOf(n), n]).filter(([id]) => id)); + const graph = new Map([...byId.keys()].map(id => [id, new Set()])); + const connect = (a, b) => { + if (!a || !b || a === b || !graph.has(a) || !graph.has(b)) return; + graph.get(a).add(b); + graph.get(b).add(a); + }; + for (const node of byId.values()) { + const id = idOf(node); + for (const next of edgesOf(node, byId)) connect(id, next); + } + return { graph, byId }; + } + + function connectedComponents(nodes = [], opts = {}) { + const { graph, byId } = buildGraph(nodes, opts); + const sort = opts.sort || null; + const nodeList = [...byId.values()].sort(sort || (() => 0)); + const seen = new Set(); + const comps = []; + for (const node of nodeList) { + const start = opts.idOf ? opts.idOf(node) : node.id; + if (!start || seen.has(start)) continue; + const stack = [start]; + const comp = []; + seen.add(start); + while (stack.length) { + const id = stack.pop(); + const item = byId.get(id); + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (!opts.filter || opts.filter(comp)) comps.push(sort ? comp.sort(sort) : comp); + } + return comps; + } + + global.TarinaiFamilyGraph = { buildGraph, connectedComponents }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/food_registry.js b/js/food_registry.js index b946aff..f19667d 100644 --- a/js/food_registry.js +++ b/js/food_registry.js @@ -22,11 +22,7 @@ ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 0.62, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } }; - class FoodRegistry { - constructor(defs) { this.defs = new Map(Object.entries(defs || {})); } - get(type = "") { return this.defs.get(String(type || "")) || null; } - has(type = "") { return this.defs.has(String(type || "")); } - label(type = "") { return this.get(type)?.label || type || ""; } + class FoodRegistry extends global.TarinaiRegistryBase { servingTypes() { return [...this.defs.entries()] .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) @@ -41,9 +37,18 @@ paramEffectTypes() { return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); } + nutrition(type = "", fallback = 0) { + const value = this.get(type)?.nutrition; + return Number.isFinite(value) ? value : fallback; + } + hungerRelief(type = "", fallback = 0) { + const value = this.get(type)?.hungerRelief; + return Number.isFinite(value) ? value : fallback; + } + effectId(type = "") { return this.get(type)?.effectId || ""; } passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; } hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } - debugSnapshot() { return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } + debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); } } const registry = new FoodRegistry(rawDefinitions); diff --git a/js/freeze_system.js b/js/freeze_system.js new file mode 100644 index 0000000..506a2a6 --- /dev/null +++ b/js/freeze_system.js @@ -0,0 +1,341 @@ +"use strict"; + +(function (global) { + const STORAGE_KEY = "tarinai_frozen_storage_v1"; + const MAX_PENDING_LOSSES = 16; + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js"); + const clonePlain = Snapshot.clonePlain; + + const htmlEscape = global.TarinaiUIHelpers.htmlEscape; + + function frozenList(worldRef = global.world) { + if (!worldRef) return []; + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + return worldRef.frozenTarinai; + } + + function saveFrozenStore(worldRef = global.world) { + try { + global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef))); + } catch (_) {} + } + + function loadFrozenStore(worldRef = global.world) { + if (!worldRef) return []; + try { + const raw = global.localStorage?.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || []; + } catch (_) { + if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = []; + } + return frozenList(worldRef); + } + + function spritePathFor(type = "smile") { + const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || [])); + const meta = sprites.find(s => s.id === type) || sprites[0]; + return meta?.path || "assets/sprites/tarinai_01_smile.webp"; + } + + function ensureStyles() { + if (document.getElementById("freezeSystemStyles")) return; + const style = document.createElement("style"); + style.id = "freezeSystemStyles"; + style.textContent = ` + .frozen-panel { margin-top: 10px; padding: 10px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); } + .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:13px; color:#4f5f70; margin-bottom:8px; } + .frozen-panel-count { font-size:12px; opacity:.72; } + .frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; } + .frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } + .frozen-card:active { cursor:grabbing; } + .frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); } + .frozen-card img { width:34px; height:34px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } + .frozen-card-main { min-width:0; flex:1; } + .frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; } + canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; } + `; + document.head.appendChild(style); + } + + function ensurePanel() { + ensureStyles(); + let panel = document.getElementById("frozenPanel"); + if (panel) return panel; + panel = document.createElement("section"); + panel.id = "frozenPanel"; + panel.className = "frozen-panel"; + panel.innerHTML = ` +
冷凍個体0
+
+ `; + const anchor = global.ui?.toolPalette || document.getElementById("toolPalette"); + if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); + else document.body.appendChild(panel); + return panel; + } + + function renderFrozenPanel(worldRef = global.world) { + const panel = ensurePanel(); + const listEl = panel.querySelector("#frozenList"); + const countEl = panel.querySelector("#frozenPanelCount"); + const list = frozenList(worldRef); + if (countEl) countEl.textContent = `${list.length}`; + if (!listEl) return; + if (!list.length) { + listEl.innerHTML = `
冷凍中のたりないはいません。
`; + return; + } + listEl.innerHTML = list.map(entry => { + const data = entry.data || entry; + const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / 訃報${entry.pendingLosses.length}` : ""; + const gen = data.generation ? `第${data.generation}世代` : ""; + const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || ""); + return ``; + }).join(""); + } + + function makeFrozenEntry(t, worldRef = global.world) { + const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"])); + data.sleeping = false; + data.insideNestBoxId = ""; + data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle"); + data.targetKey = ""; + const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`); + return { + freezeId: id, + frozenAt: Date.now(), + worldTime: worldRef?.time || 0, + name: t.name || "たりない", + familyKey: t.familyKey || "", + liveId: t.id || "", + liveToken: t.liveToken || 0, + type: t.type || "smile", + data, + pendingLosses: [], + }; + } + + function findTarinaiAt(worldRef, x, y) { + const arr = worldRef?.tarinai || []; + for (let i = arr.length - 1; i >= 0; i--) { + const t = arr[i]; + if (!t || t.dead) continue; + if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t; + } + return null; + } + + function freezeTarinai(t, worldRef = global.world) { + if (!t || t.dead || !worldRef) return false; + const entry = makeFrozenEntry(t, worldRef); + worldRef.clearLiveReferencesTo?.(t); + if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id); + worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t); + if (worldRef.selected === t) worldRef.selected = null; + frozenList(worldRef).push(entry); + worldRef.drawListDirty = true; + worldRef.familyTreeDirty = true; + worldRef.updateItemCounts?.(); + worldRef.rebuildSpatial?.(true); + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + global.showToast?.(`${t.name}を冷凍保存しました。`); + worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] }); + global.renderStats?.(); + global.render?.(); + return true; + } + + function updateLiveIdAfterThaw(worldRef, t) { + if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map(); + const id = t.id || ""; + const conflict = id && worldRef.liveTarinai.has(id); + if (!id || conflict) { + worldRef.assignTarinaiLiveId?.(t); + return; + } + worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t }); + const m = /^t(\d+)$/.exec(id); + if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1); + worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0); + } + + function applyPendingLossesOnThaw(t, entry, worldRef) { + const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : []; + if (!losses.length) return; + const unique = []; + const seen = new Set(); + for (const loss of losses) { + const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss); + if (seen.has(key)) continue; + seen.add(key); + unique.push(loss); + } + const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0; + const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); + if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); + else { + t.stress = clamp((t.stress || 0) + stress, 0, 130); + t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); + } + t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)"); + t.recordChangeCause?.("冷凍中の訃報", "ストレス", { value: stress }); + t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death"); + worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] }); + } + + function thawFrozen(freezeId, x, y, worldRef = global.world) { + const list = frozenList(worldRef); + const index = list.findIndex(e => (e.freezeId || e.id) === freezeId); + if (index < 0 || !worldRef) return false; + const entry = list[index]; + const data = clonePlain(entry.data || entry) || {}; + let t = null; + try { + t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true }); + } catch (_) { + return false; + } + t.dead = false; + t.deathReason = ""; + t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); + t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); + t.vx = 0; + t.vy = 0; + t.target = null; + t.panicTarget = null; + t.insideNestBoxId = ""; + t.sleeping = false; + if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている"); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35); + t.thought = "解凍されて少し驚いている"; + t.frozenAt = 0; + updateLiveIdAfterThaw(worldRef, t); + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + worldRef.tarinai.push(t); + worldRef.recordFamily?.(t); + applyPendingLossesOnThaw(t, entry, worldRef); + list.splice(index, 1); + worldRef.selected = t; + worldRef.drawListDirty = true; + worldRef.familyTreeDirty = true; + worldRef.updateItemCounts?.(); + worldRef.rebuildSpatial?.(true); + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + global.showToast?.(`${t.name}を解凍しました。`); + worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] }); + global.renderStats?.(); + global.render?.(); + return true; + } + + function bindFreezeSystem() { + const worldRef = global.world; + loadFrozenStore(worldRef); + renderFrozenPanel(worldRef); + const panel = ensurePanel(); + if (panel.dataset.freezeBound !== "1") { + panel.dataset.freezeBound = "1"; + panel.addEventListener("dragstart", (e) => { + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + const id = card.dataset.frozenId || ""; + e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`); + e.dataTransfer?.setData("application/x-tarinai-frozen", id); + e.dataTransfer.effectAllowed = "move"; + global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready"); + }); + panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready")); + panel.addEventListener("click", (e) => { + const card = e.target.closest(".frozen-card[data-frozen-id]"); + if (!card) return; + pendingThawId = card.dataset.frozenId || ""; + for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card); + global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。"); + }); + } + const canvas = global.canvas || document.getElementById("gameCanvas"); + if (canvas && canvas.dataset.freezeDropBound !== "1") { + canvas.dataset.freezeDropBound = "1"; + canvas.addEventListener("dragover", (e) => { + const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain"); + if (!text) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + canvas.classList.add("freeze-drop-ready"); + }); + canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready")); + canvas.addEventListener("drop", (e) => { + const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, ""); + if (!id) return; + e.preventDefault(); + canvas.classList.remove("freeze-drop-ready"); + const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true }; + })(); + thawFrozen(id, p.x, p.y, worldRef); + }); + } + } + + let pendingThawId = ""; + + function tryPendingThawAt(x, y, worldRef = global.world) { + if (!pendingThawId) return false; + const id = pendingThawId; + pendingThawId = ""; + document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw")); + return thawFrozen(id, x, y, worldRef); + } + + function afterSnapshotRestored(worldRef = global.world) { + if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = []; + saveFrozenStore(worldRef); + renderFrozenPanel(worldRef); + } + + function patchWorldPrototype() { + const apply = (proto) => { + const originalHandleClick = proto.handleClick; + proto.handleClick = function (x, y) { + if (tryPendingThawAt(x, y, this)) return; + if (this.tool === "freeze") { + global.showToast?.("冷凍は『つまむ』でたりないを冷凍個体UIへ運んでください。"); + return; + } + return originalHandleClick.call(this, x, y); + }; + }; + if (global.TarinaiPatcher?.patchWorld?.("freezeSystem", apply)) return; + const World = global.World; + if (!World || World.prototype.__freezeSystemPatched) return; + World.prototype.__freezeSystemPatched = true; + apply(World.prototype); + } + + patchWorldPrototype(); + + global.TarinaiFreezeSystem = { + bindFreezeSystem, + renderFrozenPanel, + freezeTarinai, + thawFrozen, + frozenList, + loadFrozenStore, + saveFrozenStore, + afterSnapshotRestored, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/health.js b/js/health.js index d56d484..690a16c 100644 --- a/js/health.js +++ b/js/health.js @@ -95,19 +95,19 @@ const HEALTH = (() => { const recent = recentDamageCause(t, 20); const fighting = isFightingContext(t, recent); if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision; - if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 28 || t.mood <= 24 || t.stress >= 112 || t.hunger >= 100)) { + if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) { return weakenedDeathReasonFor(t.lastMajorDamageCause); } const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW); const traceCanWeaken = weakenedTrace && weakenedTrace !== CAUSES.stress && weakenedTrace !== CAUSES.hunger && weakenedTrace !== CAUSES.lifespan && weakenedTrace !== CAUSES.outOfBounds; - if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace || opts.fromDamage) && (t.energy <= 30 || t.mood <= 24 || t.stress >= 112 || t.hunger >= 104)) { + if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace || opts.fromDamage) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) { return weakenedDeathReasonFor(weakenedTrace); } if (direct && direct !== CAUSES.stress) return direct; if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease; if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger; if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight; - if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 22 || t.stress >= 116 || opts.fromDamage)) return weakenedDeathReasonFor(recent); + if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126 || opts.fromDamage)) return weakenedDeathReasonFor(recent); if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan; if (direct) return direct; if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress; diff --git a/js/item_visual_registry.js b/js/item_visual_registry.js index 23f750c..5c29984 100644 --- a/js/item_visual_registry.js +++ b/js/item_visual_registry.js @@ -29,6 +29,21 @@ "fieldScale": 1, "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] }, + "water_bowl": { + "renderer": "water_bowl", + "shape": "bowl_with_horizontal_oval_water", + "palette": { + "bowlFill": "rgba(171,120,76,0.72)", + "bowlStroke": "rgba(94,69,48,0.55)", + "waterFill": "rgba(86,157,224,0.58)", + "waterStroke": "rgba(62,113,178,0.36)", + "highlight": "rgba(255,255,255,0.54)" + }, + "scale": 1, + "uiPreviewScale": 1, + "fieldScale": 1, + "accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"] + }, "sleep_drug": { "renderer": "sleep_tablet", "shape": "moon_tablet", diff --git a/js/items.js b/js/items.js index 0784fda..1989a4b 100644 --- a/js/items.js +++ b/js/items.js @@ -27,6 +27,19 @@ function passiveFoodDecayRate(item) { return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; } +function itemVisualFor(type = "") { + return typeof itemVisualDefinition === "function" ? itemVisualDefinition(type) : null; +} + +function visualPaletteFor(type = "", fallback = {}) { + return itemVisualFor(type)?.palette || fallback || {}; +} + +function visualFieldScaleFor(type = "") { + const visual = itemVisualFor(type); + return visual?.fieldScale || visual?.scale || 1; +} + function drawOvalWaterDropSprite(ctx, r, palette = {}) { const fill = palette.fill || "rgba(86, 157, 224, 0.82)"; const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)"; @@ -48,20 +61,23 @@ function drawOvalWaterDropSprite(ctx, r, palette = {}) { ctx.restore(); } -function drawSleepTabletSprite(ctx, r) { +function drawSleepTabletSprite(ctx, r, palette = {}) { + const fill = palette.fill || "#ebe4ff"; + const stroke = palette.stroke || "rgba(74, 60, 144, 0.72)"; + const accent = palette.accent || "rgba(112, 91, 188, 0.82)"; ctx.save(); ctx.rotate(-0.18); - ctx.fillStyle = "#ebe4ff"; - ctx.strokeStyle = "rgba(74, 60, 144, 0.72)"; + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; ctx.lineWidth = Math.max(1.8, r * 0.14); ctx.beginPath(); ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(112, 91, 188, 0.82)"; + ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = "#ebe4ff"; + ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2); ctx.fill(); @@ -186,6 +202,10 @@ class Item { this.comfort = rand(0.86, 1.14); this.wear = 0; } + if (type === "signboard") { + this.text = ""; + this.textEditedAt = -999; + } if (type === "ball") { this.vx = rand(-5, 5); this.vy = rand(-5, 5); @@ -232,7 +252,7 @@ class Item { this.lingerTimer = 0; this.lingerDuration = 2.0; } - if (type === "pushpin") { + if (isPinType(type)) { this.amount = 100; this.vx = rand(-120, 120); this.vy = rand(-90, 90); @@ -322,7 +342,7 @@ class Item { } } if (this.type === "ball") this.updateBall(dt, worldRef); - if (this.type === "pushpin") this.updatePushpin(dt, worldRef); + if (isPinType(this.type)) this.updatePushpin(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); if (this.type === "grass") { this.lifecycleTimer += dt; @@ -347,20 +367,59 @@ class Item { updateZunchiMotion(dt, worldRef) { const speed = Math.hypot(this.vx || 0, this.vy || 0); if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } - this.prevX = this.x; - this.prevY = this.y; - this.x += (this.vx || 0) * dt; - this.y += (this.vy || 0) * dt; - const p = Math.max(28, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } - const slow = Math.pow(0.68, dt * 2.4); - this.vx *= slow; - this.vy *= slow; + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 }); + } else { + this.prevX = this.x; + this.prevY = this.y; + this.x += (this.vx || 0) * dt; + this.y += (this.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; } + if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; } + if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; } + if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; } + const slow = Math.pow(0.68, dt * 2.4); + this.vx *= slow; + this.vy *= slow; + worldRef.drawListDirty = true; + } + this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed); this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14); - worldRef.drawListDirty = true; + } + + resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) { + if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return; + const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18); + for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) { + if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue; + const d = distXY(this.x, this.y, t.x, t.y); + if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue; + const now = worldRef.time || 0; + if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue; + this.lastHitTarinaiAt = this.lastHitTarinaiAt || {}; + this.lastHitTarinaiAt[t.id] = now; + const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1); + const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1); + const impulse = clamp(speed * 1.15, 150, 620); + t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20); + t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10); + t.fallTimer = Math.max(t.fallTimer || 0, 0.85); + t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer); + t.fallDir = nx < 0 ? -1 : 1; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" }); + else { + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); + t.fearTimer = Math.max(t.fearTimer || 0, 0.55); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); + t.addStress ? t.addStress(7, { threshold: 8, duration: 3.2 }) : (t.stress = clamp((t.stress || 0) + 7, 0, 130)); + } + worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); + worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); + this.vx *= -0.18; + this.vy *= -0.18; + break; + } } updateBall(dt, worldRef) { @@ -571,29 +630,13 @@ class Item { this.updateLodgedPushpin(dt, worldRef); return; } - this.prevX = this.x; - this.prevY = this.y; - const moving = Math.hypot(this.vx || 0, this.vy || 0); - if (moving > 0.05) { + if (window.TarinaiPhysics?.applyKinematicItemMotion) { + window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 }); + } else { + this.prevX = this.x; + this.prevY = this.y; this.x += (this.vx || 0) * dt; this.y += (this.vy || 0) * dt; - const p = Math.max(26, CONFIG.worldPadding || 30); - if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.44; this.spinVelocity *= -0.68; } - if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.44; this.spinVelocity *= -0.68; } - if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.44; this.spinVelocity *= -0.68; } - if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.44; this.spinVelocity *= -0.68; } - const friction = Math.pow(0.18, dt * 0.85); - this.vx *= friction; - this.vy *= friction; - this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(7, this.r || 16); - this.spinVelocity *= Math.pow(0.38, dt); - if (Math.hypot(this.vx || 0, this.vy || 0) < 7.5) { this.vx = 0; this.vy = 0; this.spinVelocity *= 0.3; } - worldRef.drawListDirty = true; - } else { - this.vx = 0; - this.vy = 0; - this.spinVelocity *= Math.pow(0.08, dt); - this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt; } this.tryStickPushpin(worldRef); } @@ -617,15 +660,17 @@ class Item { attachPushpin(t, worldRef, d = null) { if (!t || t.dead) return false; if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); const dx = this.x - t.x; const dy = this.y - t.y; const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); this.pinState = "lodged"; this.deletable = false; this.pinTargetId = t.id; - this.pinAttachAngle = Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); - this.pinAttachDistance = clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - this.pinOffsetY = clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + this.pinAttachAngle = isOshibyo ? 0.62 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); + this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.58 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); this.pinDamageTick = 0; this.pinFallCheckTimer = 0; this.vx = 0; @@ -635,16 +680,25 @@ class Item { t.stuckPushpinId = this.id; t.sleeping = false; t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); - t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); - t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.8); - t.fearTimer = Math.max(t.fearTimer || 0, 1.3); - t.state = "panic"; - t.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }; - t.addStress ? t.addStress(18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + 18, 0, 130)); - if (t.damage) t.damage(6, "\u753b\u92f2"); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8); + if (isOshibyo) { + t.thought = "おしり鋲が刺さってずんちが出なくなった"; + worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] }); + worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" })); + return true; + } + if (t.enterPanic) { + t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" }); + } else { + t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); + t.fearTimer = Math.max(t.fearTimer || 0, 1.3); + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); + t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130)); + } + if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { this.pinLogAt = worldRef.time || 0; - worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] }); + worldRef.log?.(`${t.name}に画鋲が刺さった。`, "accident", { participants: [t] }); } worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" })); return true; @@ -656,30 +710,37 @@ class Item { this.detachPushpin(worldRef, "owner-lost"); return; } + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const isOshibyo = Boolean(behavior?.blocksZunchi); t.stuckPushpinId = this.id; this.deletable = false; - this.x = t.x + (t.radius || 16) * 0.36; - this.y = t.y - (t.radius || 16) * 0.04; + this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36; + this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04; this.prevX = this.x; this.prevY = this.y; - this.spin = 0.28; + this.spin = isOshibyo ? 0.52 : 0.28; this.vx = t.vx || 0; this.vy = t.vy || 0; + if (isOshibyo) { + if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "おしり鋲でずんちが溜まってつらい"; + return; + } this.pinDamageTick = (this.pinDamageTick || 0) + dt; while (this.pinDamageTick >= 0.55) { this.pinDamageTick -= 0.55; - if (t.damage) t.damage(1.4, "\u753b\u92f2"); + if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - t.addStress ? t.addStress(2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + 2.6, 0, 130)); + t.addStress ? t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressPerTick ?? 2.6), 0, 130)); if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); } t.sleeping = false; - t.state = "panic"; - t.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }; - 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); - t.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + if (t.enterPanic) t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); + else { + t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); + t.fearTimer = Math.max(t.fearTimer || 0, 0.65); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); + } this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; while (this.pinFallCheckTimer >= 1.0) { this.pinFallCheckTimer -= 1.0; @@ -692,7 +753,11 @@ class Item { detachPushpin(worldRef, reason = "released") { const t = worldRef?.tarinai?.find(o => o && o.id === this.pinTargetId) || null; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const wasOshibyo = Boolean(behavior?.blocksZunchi); + const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0; if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null; + if (t && wasOshibyo) t.oshiriByoZunchiStock = 0; this.pinState = "loose"; this.pinTargetId = ""; this.pinDamageTick = 0; @@ -714,6 +779,10 @@ class Item { worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); } + + if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) { + worldRef.spawnBurstZunchi(t || this, storedZunchi, this); + } } updateGrass(dt, worldRef) { @@ -928,7 +997,7 @@ class Item { } } } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { - const visual = typeof itemVisualDefinition === "function" ? itemVisualDefinition(this.type) : null; + const visual = itemVisualFor(this.type); const palette = visual?.palette || {}; const renderer = visual?.renderer || "split_pill"; const meta = { @@ -1008,7 +1077,7 @@ class Item { ctx.restore(); } } else if (this.type === "sleep_drug") { - drawSleepTabletSprite(ctx, this.r); + drawSleepTabletSprite(ctx, this.r * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug")); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); @@ -1045,9 +1114,10 @@ class Item { ctx.fill(); } ctx.restore(); - } else if (this.type === "pushpin") { + } else if (isPinType(this.type)) { const stuck = this.pinState === "lodged"; - const assetId = stuck ? "pushpin_stuck" : "pushpin"; + const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; + const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); ctx.save(); if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); @@ -1057,7 +1127,7 @@ class Item { if (img) { const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; const ratio = metrics?.ratio || (stuck ? 1 : 2.0); - const w = stuck ? this.r * 2.15 : this.r * 1.78; + const w = isOshibyo ? (stuck ? this.r * 3.1 : this.r * 1.55) : (stuck ? this.r * 2.15 : this.r * 1.78); const h = w * ratio; ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); } else if (stuck) { @@ -1090,26 +1160,23 @@ class Item { } ctx.restore(); } else if (this.type === "water_bowl") { - ctx.fillStyle = "rgba(171, 120, 76, 0.72)"; - ctx.strokeStyle = "rgba(94, 69, 48, 0.55)"; + const palette = visualPaletteFor("water_bowl"); + ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; + ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(86, 157, 224, 0.58)"; - ctx.strokeStyle = "rgba(62, 113, 178, 0.36)"; + ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; + ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.54)"; + ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "water") { - drawOvalWaterDropSprite(ctx, this.r, { - fill: "rgba(86, 157, 224, 0.82)", - stroke: "rgba(45, 101, 168, 0.52)", - highlight: "rgba(255,255,255,0.68)", - }); + drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); } else if (this.type === "grass") { const growth = this.growth ?? clamp(this.amount / 120, 0, 1); const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; @@ -1193,6 +1260,36 @@ class Item { ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "signboard") { + ctx.save(); + ctx.fillStyle = styleNight ? "#6d543b" : (styleWarm ? "#a67848" : "#8d633e"); + ctx.strokeStyle = styleNight ? "#34281c" : "#573c27"; + ctx.lineWidth = 2.2; + ctx.fillRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); + ctx.strokeRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); + roundedRect(ctx, -this.r * 1.18, -this.r * 1.18, this.r * 2.36, this.r * 1.18, this.r * 0.14); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.16)"; + ctx.fillRect(-this.r * 0.92, -this.r * 0.96, this.r * 1.02, this.r * 0.14); + const raw = String(this.text || "").replace(/\s+/g, " ").trim(); + if (raw) { + const charsPerLine = 7; + const lines = []; + for (let i = 0; i < raw.length && lines.length < 2; i += charsPerLine) lines.push(raw.slice(i, i + charsPerLine)); + ctx.fillStyle = styleNight ? "rgba(255,249,236,0.92)" : "rgba(51,34,19,0.92)"; + ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.38))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + lines.forEach((line, index) => ctx.fillText(line, 0, -this.r * 0.68 + index * this.r * 0.34)); + } else { + ctx.fillStyle = styleNight ? "rgba(255,249,236,0.62)" : "rgba(51,34,19,0.50)"; + ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.30))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("…", 0, -this.r * 0.58); + } + ctx.restore(); } else if (this.type === "ant_nest") { const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); diff --git a/js/main.js b/js/main.js index 85ae9cd..8003425 100644 --- a/js/main.js +++ b/js/main.js @@ -134,7 +134,7 @@ loadImages(null, (done, total) => { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { - const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.11"; + const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.16"; navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined); }); } diff --git a/js/patch_helpers.js b/js/patch_helpers.js new file mode 100644 index 0000000..db2f6c7 --- /dev/null +++ b/js/patch_helpers.js @@ -0,0 +1,16 @@ +"use strict"; + +(function (global) { + const applied = new Set(); + function patchPrototype(key, owner, patchFn) { + if (!key || !owner?.prototype || typeof patchFn !== "function") return false; + const fullKey = `${owner.name || "Owner"}:${key}`; + if (applied.has(fullKey) || owner.prototype[`__${key}Patched`]) return false; + patchFn(owner.prototype); + applied.add(fullKey); + owner.prototype[`__${key}Patched`] = true; + return true; + } + function patchWorld(key, patchFn) { return patchPrototype(key, global.World, patchFn); } + global.TarinaiPatcher = { patchPrototype, patchWorld, applied }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/physics_helpers.js b/js/physics_helpers.js new file mode 100644 index 0000000..b4f08a6 --- /dev/null +++ b/js/physics_helpers.js @@ -0,0 +1,54 @@ +"use strict"; + +(function (global) { + function bounceInsideWorld(obj, worldRef, opts = {}) { + if (!obj || !worldRef) return false; + const p = Math.max(opts.padding ?? 28, CONFIG.worldPadding || 30); + const bx = opts.bounceX ?? opts.bounce ?? 0.72; + const by = opts.bounceY ?? opts.bounce ?? 0.72; + const spinBounce = opts.spinBounce ?? -0.72; + let bounced = false; + if (obj.x < p) { obj.x = p; obj.vx = Math.abs(obj.vx || 0) * bx; bounced = true; } + if (obj.x > worldRef.w - p) { obj.x = worldRef.w - p; obj.vx = -Math.abs(obj.vx || 0) * bx; bounced = true; } + if (obj.y < p) { obj.y = p; obj.vy = Math.abs(obj.vy || 0) * by; bounced = true; } + if (obj.y > worldRef.h - p) { obj.y = worldRef.h - p; obj.vy = -Math.abs(obj.vy || 0) * by; bounced = true; } + if (bounced && Number.isFinite(obj.spinVelocity)) obj.spinVelocity *= spinBounce; + return bounced; + } + + function applyKinematicItemMotion(item, worldRef, dt, opts = {}) { + if (!item || !worldRef) return 0; + const stopSpeed = opts.stopSpeed ?? 0.05; + const speed = Math.hypot(item.vx || 0, item.vy || 0); + item.prevX = item.x; + item.prevY = item.y; + if (speed <= stopSpeed) { + item.vx = 0; + item.vy = 0; + if (Number.isFinite(item.spinVelocity)) { + item.spinVelocity *= Math.pow(opts.spinRestDamp ?? 0.08, dt); + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt; + } + return speed; + } + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + bounceInsideWorld(item, worldRef, opts); + const friction = Math.pow(opts.frictionBase ?? 0.68, dt * (opts.frictionRate ?? 1)); + item.vx *= friction; + item.vy *= friction; + if (opts.spin !== false) { + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * dt + (item.vx || 0) * dt / Math.max(7, item.r || 16); + item.spinVelocity = (item.spinVelocity || 0) * Math.pow(opts.spinFrictionBase ?? 0.38, dt); + } + if (Math.hypot(item.vx || 0, item.vy || 0) < (opts.zeroBelow ?? 0)) { + item.vx = 0; + item.vy = 0; + if (Number.isFinite(item.spinVelocity)) item.spinVelocity *= 0.3; + } + worldRef.drawListDirty = true; + return speed; + } + + global.TarinaiPhysics = { bounceInsideWorld, applyKinematicItemMotion }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/registry_base.js b/js/registry_base.js new file mode 100644 index 0000000..8ec9bb9 --- /dev/null +++ b/js/registry_base.js @@ -0,0 +1,20 @@ +"use strict"; + +(function (global) { + class TarinaiRegistryBase { + constructor(defs = {}) { this.defs = new Map(Object.entries(defs || {})); } + key(id = "") { return String(id || ""); } + get(id = "") { return this.defs.get(this.key(id)) || null; } + has(id = "") { return this.defs.has(this.key(id)); } + entries() { return [...this.defs.entries()]; } + values() { return [...this.defs.values()]; } + label(id = "", fallback = "") { return this.get(id)?.label || fallback || id || ""; } + color(id = "", fallback = "rgba(255,242,160,0.85)") { return this.get(id)?.color || fallback; } + labels() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.label])); } + colors() { return Object.fromEntries(this.entries().map(([id, def]) => [id, def.color]).filter(([, color]) => color)); } + debugSnapshot(mapper = null) { + return this.entries().map(([id, def]) => mapper ? mapper(id, def) : ({ id, label: def.label || id })); + } + } + global.TarinaiRegistryBase = TarinaiRegistryBase; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/relation_event_system.js b/js/relation_event_system.js new file mode 100644 index 0000000..fcb3249 --- /dev/null +++ b/js/relation_event_system.js @@ -0,0 +1,86 @@ +"use strict"; + +(function (global) { + const MAX_PENDING_LOSSES = 16; + + + function relationWithDead(observer, dead, worldRef) { + if (!observer || !dead || observer === dead) return ""; + const deadFamily = worldRef?.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); + const observerFamily = observer.familyKey || observer.archiveKey || observer.id || ""; + const deadId = dead.id || dead.releasedLiveId || ""; + const observerId = observer.id || observer.liveId || ""; + if (deadFamily && (observer.parents || []).includes(deadFamily)) return "親"; + if (deadFamily && (observer.children || []).includes(deadFamily)) return "子"; + if (observerFamily && (dead.parents || []).includes(observerFamily)) return "子"; + if (observerFamily && (dead.children || []).includes(observerFamily)) return "親"; + const rel = deadId ? observer.relationships?.[deadId] : null; + const reverse = observerId ? dead.relationships?.[observerId] : null; + if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達"; + return ""; + } + + function applyLiveDeathShock(observer, dead, relation, worldRef) { + const strong = relation === "親" || relation === "子"; + if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた"); + const stress = strong ? 18 : 11; + const fear = strong ? 2.1 : 1.45; + if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" }); + else { + observer.sleeping = false; + observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true }); + observer.fearTimer = Math.max(observer.fearTimer || 0, fear); + observer.stress = clamp((observer.stress || 0) + stress, 0, 130); + observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); + } + observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress }); + observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death"); + } + + function notifyDeathToRelations(dead, reason = "") { + const worldRef = this || global.world; + if (!worldRef || !dead) return; + const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || ""); + const deadId = dead.id || dead.releasedLiveId || ""; + let liveHits = 0; + for (const other of worldRef.tarinai || []) { + if (!other || other === dead || other.dead) continue; + const relation = relationWithDead(other, dead, worldRef); + if (!relation) continue; + applyLiveDeathShock(other, dead, relation, worldRef); + liveHits += 1; + } + for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) { + const data = entry.data || entry; + const relation = relationWithDead(data, dead, worldRef); + if (!relation) continue; + if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = []; + const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId)); + if (!duplicate) { + entry.pendingLosses.unshift({ + relation, + name: dead.name || "たりない", + familyKey: deadFamily, + liveId: deadId, + reason: reason || dead.deathReason || "死亡", + time: worldRef.time || 0, + }); + if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES; + } + } + if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] }); + global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef); + global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef); + } + + function patchWorldPrototype() { + const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; }; + if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return; + const World = global.World; + if (!World) return; + apply(World.prototype); + } + + global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype }; + patchWorldPrototype(); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/render.js b/js/render.js index 5328e3b..5ea4cb6 100644 --- a/js/render.js +++ b/js/render.js @@ -175,7 +175,7 @@ function drawPlacementPreview(ctx, world) { ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); } } - } else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || type === "pushpin" || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { + } else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { ctx.beginPath(); ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); ctx.fill(); @@ -742,7 +742,7 @@ function render() { } for (const it of world.items) { if (isCachedTerrainItem(it)) continue; - if (it.type === "pushpin" && it.pinState === "lodged") continue; + if (isPinType(it.type) && it.pinState === "lodged") continue; if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { @@ -770,7 +770,7 @@ function render() { if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); } for (const it of world.items) { - if (it.type === "pushpin" && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const ef of world.effects) { if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); diff --git a/js/save_system.js b/js/save_system.js new file mode 100644 index 0000000..00cab54 --- /dev/null +++ b/js/save_system.js @@ -0,0 +1,223 @@ +"use strict"; + +(function (global) { + const STORAGE_PREFIX = "tarinai_save_slot_v1_"; + const EXPORT_PREFIX = "TARINAI_SAVE_V1:"; + const SLOT_COUNT = 9; + const Snapshot = global.TarinaiSnapshot; + if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js"); + const createSnapshot = (...args) => Snapshot.createSnapshot(...args); + const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args); + + function bytesToBase64(bytes) { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + + function base64ToBytes(text) { + let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4) b64 += "="; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; + } + + function encodeSnapshot(snapshot) { + const json = JSON.stringify(snapshot); + const bytes = new TextEncoder().encode(json); + return EXPORT_PREFIX + bytesToBase64(bytes); + } + + function decodeSnapshot(text) { + const raw = String(text || "").trim(); + if (!raw) throw new Error("empty import text"); + if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw); + const bytes = base64ToBytes(raw); + const json = new TextDecoder().decode(bytes); + return JSON.parse(json); + } + + function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; } + + function saveSlot(slot) { + const snapshot = createSnapshot(global.world); + localStorage.setItem(slotKey(slot), JSON.stringify(snapshot)); + return snapshot; + } + + function loadSlot(slot) { + const raw = localStorage.getItem(slotKey(slot)); + if (!raw) throw new Error("empty slot"); + return restoreSnapshot(JSON.parse(raw), global.world); + } + + function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); } + + function readSlot(slot) { + const raw = localStorage.getItem(slotKey(slot)); + if (!raw) return null; + try { return JSON.parse(raw); } catch (_) { return null; } + } + + const htmlEscape = global.TarinaiUIHelpers.htmlEscape; + + + 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}匹`; + } + + function ensureDialog() { + let dialog = document.getElementById("saveDialog"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "saveDialog"; + dialog.className = "field-dialog hidden"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.innerHTML = ` +
+

セーブ / ロード

+

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは別端末や別ブラウザへの移動に使えます。

+
+
+
+ + +
+ +
+
+ +
+
`; + document.body.appendChild(dialog); + return dialog; + } + + function renderSlotList() { + const dialog = ensureDialog(); + const list = dialog.querySelector("#saveSlotList"); + if (!list) return; + list.innerHTML = ""; + for (let i = 1; i <= SLOT_COUNT; i++) { + const snapshot = readSlot(i); + const row = document.createElement("div"); + row.className = "save-slot-row"; + row.innerHTML = ` +
スロット ${i}
+
${htmlEscape(formatSlotSummary(snapshot))}
+
+ + + +
`; + list.appendChild(row); + } + } + + function openDialog() { + const dialog = ensureDialog(); + renderSlotList(); + dialog.classList.remove("hidden"); + } + + function closeDialog() { + document.getElementById("saveDialog")?.classList.add("hidden"); + } + + function bindSaveSystem() { + const btn = document.getElementById("saveBtn") || global.ui?.saveBtn; + if (!btn || btn.dataset.saveBound === "1") return; + btn.dataset.saveBound = "1"; + btn.addEventListener("click", () => { + global.audio?.uiClick?.(); + openDialog(); + }); + const dialog = ensureDialog(); + dialog.addEventListener("click", (e) => { + if (e.target === dialog) { closeDialog(); return; } + const saveBtn = e.target.closest("[data-save-slot]"); + const loadBtn = e.target.closest("[data-load-slot]"); + const deleteBtn = e.target.closest("[data-delete-slot]"); + if (saveBtn) { + const slot = Number(saveBtn.dataset.saveSlot); + try { + saveSlot(slot); + renderSlotList(); + global.audio?.notify?.(); + global.showToast?.(`スロット${slot}に保存しました。`); + } catch (error) { + console.warn(error); + global.showToast?.("保存に失敗しました。空き容量を確認してください。"); + } + return; + } + if (loadBtn) { + const slot = Number(loadBtn.dataset.loadSlot); + if (!global.confirm?.(`スロット${slot}を読み込みます。現在の状態は上書きされます。`)) return; + try { + loadSlot(slot); + closeDialog(); + global.audio?.notify?.(); + global.showToast?.(`スロット${slot}を読み込みました。`); + } catch (error) { + console.warn(error); + global.showToast?.("読み込みに失敗しました。"); + } + return; + } + if (deleteBtn) { + const slot = Number(deleteBtn.dataset.deleteSlot); + if (!global.confirm?.(`スロット${slot}を削除しますか?`)) return; + deleteSlot(slot); + renderSlotList(); + global.audio?.delete?.(); + global.showToast?.(`スロット${slot}を削除しました。`); + return; + } + if (e.target.closest("#saveCloseBtn")) closeDialog(); + if (e.target.closest("#saveExportBtn")) { + const area = dialog.querySelector("#saveHashText"); + if (area) { + area.value = encodeSnapshot(createSnapshot(global.world)); + area.focus(); + area.select(); + } + global.showToast?.("現在の状態をハッシュテキストに書き出しました。"); + } + if (e.target.closest("#saveImportBtn")) { + const area = dialog.querySelector("#saveHashText"); + const text = area?.value || ""; + if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } + if (!global.confirm?.("ハッシュテキストを読み込みます。現在の状態は上書きされます。")) return; + try { + restoreSnapshot(decodeSnapshot(text), global.world); + closeDialog(); + global.showToast?.("ハッシュテキストから読み込みました。"); + } catch (error) { + console.warn(error); + global.showToast?.("読み込みに失敗しました。テキストを確認してください。"); + } + } + }); + } + + global.TarinaiSaveSystem = { + createSnapshot, + restoreSnapshot, + encodeSnapshot, + decodeSnapshot, + saveSlot, + loadSlot, + deleteSlot, + readSlot, + bindSaveSystem, + openDialog, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/sim_core.js b/js/sim_core.js index fcaf5b9..2da7fdf 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -203,6 +203,13 @@ function personalityEffectValue(value) { return trait.direction * (trait.strength === "strong" ? 1 : 0.5); } +function effectivePersonalityValue(tarinai, key) { + const p = ensurePersonality(tarinai).currentPersonality; + const base = clampPersonalityValue(p[key]); + const moodDelta = clampPersonalityValue(tarinai?.world?.colonyMood?.effects?.personality?.[key] || 0); + return clampPersonalityValue(base + moodDelta); +} + function personalityReasonText(reason = "") { const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim(); const map = { @@ -239,12 +246,12 @@ function getPersonalityTraitTags(tarinai) { const p = ensurePersonality(tarinai).currentPersonality; const tags = []; for (const key of PERSONALITY_KEYS) { - const trait = getTraitStrength(p[key]); + const trait = getTraitStrength(effectivePersonalityValue(tarinai, key)); if (!trait.direction) continue; const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label); } - const cowardly = (p.neuroticism || 0) >= 0.75 && (p.aggression || 0) <= -0.5; + const cowardly = effectivePersonalityValue(tarinai, "neuroticism") >= 0.75 && effectivePersonalityValue(tarinai, "aggression") <= -0.5; if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5"); return tags; } @@ -355,8 +362,7 @@ function adjustPersonality(tarinai, key, delta, reason = "") { } function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) { - const p = ensurePersonality(tarinai).currentPersonality; - const trait = getTraitStrength(p[key]); + const trait = getTraitStrength(effectivePersonalityValue(tarinai, key)); if (trait.direction !== Math.sign(direction || 1)) return false; return trait.strength === "strong" || Math.random() < 0.5; } @@ -570,9 +576,9 @@ class SpatialGrid { if (!it || it.dead) return; this.add(this.itemCells, it); const type = it.type || ""; - if (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h") this.add(this.obstacleCells, it); - if (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))) this.add(this.foodCells, it); - if (type === "firecracker" || type === "genkotsu" || type === "pushpin" || type === "sleep_drug" || type === "zunchi" || type === "splat" || type === "mystery_drug" || type === "laxative" || type === "niteropu" || type === "mercury" || type === "giant_drug" || type === "dwarf_drug") this.add(this.hazardCells, it); + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(this.obstacleCells, it); + if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(this.foodCells, it); + if (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it); } rebuild(items, tarinai, ants = []) { diff --git a/js/snapshot_system.js b/js/snapshot_system.js new file mode 100644 index 0000000..68c1e10 --- /dev/null +++ b/js/snapshot_system.js @@ -0,0 +1,257 @@ +"use strict"; + +(function (global) { + const SNAPSHOT_VERSION = 1; + const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]); + + function isPlainObject(value) { + if (!value || typeof value !== "object") return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; + } + + function clonePlain(value, depth = 0) { + if (value == null) return value; + const type = typeof value; + if (type === "number") return Number.isFinite(value) ? value : 0; + if (type === "string" || type === "boolean") return value; + if (type === "function" || type === "symbol" || type === "undefined") return undefined; + if (depth > 8) return undefined; + if (Array.isArray(value)) { + const out = []; + for (const v of value) { + const c = clonePlain(v, depth + 1); + if (c !== undefined) out.push(c); + } + return out; + } + if (!isPlainObject(value)) return undefined; + const out = {}; + for (const [key, v] of Object.entries(value)) { + if (DEFAULT_SKIP.has(key)) continue; + const c = clonePlain(v, depth + 1); + if (c !== undefined) out[key] = c; + } + return out; + } + + function copyOwnData(obj, skip = DEFAULT_SKIP) { + const out = {}; + if (!obj) return out; + for (const key of Object.keys(obj)) { + if (skip?.has?.(key)) continue; + const cloned = clonePlain(obj[key]); + if (cloned !== undefined) out[key] = cloned; + } + return out; + } + + function applyOwnData(target, data, skip = DEFAULT_SKIP) { + if (!target || !data) return target; + for (const [key, value] of Object.entries(data)) { + if (skip?.has?.(key) || key.startsWith("__")) continue; + const c = clonePlain(value); + if (c !== undefined) target[key] = c; + } + return target; + } + + function refFor(worldRef, value) { + if (!value || typeof value !== "object") return null; + if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" }; + if ((worldRef?.items || []).includes(value)) return { kind: "item", id: value.id || "", type: value.type || "" }; + if ((worldRef?.ants || []).includes(value)) return { kind: "ant", id: value.id || "" }; + if (Number.isFinite(value.x) && Number.isFinite(value.y)) return { kind: "point", x: value.x, y: value.y, dead: Boolean(value.dead) }; + return null; + } + + function resolveRef(worldRef, ref) { + if (!ref || typeof ref !== "object") return null; + if (ref.kind === "tarinai") return (worldRef.tarinai || []).find(t => t && !t.dead && ((ref.id && t.id === ref.id) || (ref.familyKey && t.familyKey === ref.familyKey))) || null; + if (ref.kind === "item") return (worldRef.items || []).find(it => it && !it.dead && it.id === ref.id) || null; + if (ref.kind === "ant") return (worldRef.ants || []).find(a => a && !a.dead && a.id === ref.id) || null; + if (ref.kind === "point") return { x: Number(ref.x) || 0, y: Number(ref.y) || 0, dead: Boolean(ref.dead) }; + return null; + } + + function worldData(worldRef) { + const keys = [ + "w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai", + "fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount", + "liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason", + "relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents", + "lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength" + ]; + const out = {}; + for (const key of keys) { + const cloned = clonePlain(worldRef?.[key]); + if (cloned !== undefined) out[key] = cloned; + } + return out; + } + + function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) { + const data = copyOwnData(entity, skip); + const targetRef = refFor(worldRef, entity?.target); + const panicTargetRef = refFor(worldRef, entity?.panicTarget); + const targetRefObject = refFor(worldRef, entity?.targetRef); + if (targetRef) data.__targetRef = targetRef; + if (panicTargetRef) data.__panicTargetRef = panicTargetRef; + if (targetRefObject) data.__targetRefObject = targetRefObject; + return data; + } + + function createSnapshot(worldRef = global.world) { + if (!worldRef) throw new Error("world is not ready"); + const selectedRef = refFor(worldRef, worldRef.selected); + return { + version: SNAPSHOT_VERSION, + app: "tarinai_colony_game", + createdAt: Date.now(), + summary: { + day: worldRef.day || 1, + time: worldRef.clockString ? worldRef.clockString() : "", + fieldType: worldRef.fieldType || "garden", + population: (worldRef.tarinai || []).filter(t => t && !t.dead).length, + items: (worldRef.items || []).filter(it => it && !it.dead).length, + }, + world: worldData(worldRef), + tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)), + items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)), + ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)), + effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)), + logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [], + selectedRef, + }; + } + + function restoreTarinaiData(worldRef, data, opts = {}) { + const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null); + if (!TarinaiClass) throw new Error("Tarinai is not available"); + const t = new TarinaiClass(worldRef, data || {}); + applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"])); + t.world = worldRef; + if (opts.clearTransient !== false) { + t.target = null; + t.panicTarget = null; + t.sleeping = false; + t.insideNestBoxId = ""; + } + if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize(); + return t; + } + + function restoreSnapshot(snapshot, worldRef = global.world) { + if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data"); + if (!worldRef) throw new Error("world is not ready"); + const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot)); + const fieldType = data.world?.fieldType || "garden"; + if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType); + if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType); + + worldRef.tarinai = []; + worldRef.items = []; + worldRef.ants = []; + worldRef.effects = []; + worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : []; + worldRef.selected = null; + + applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"])); + worldRef.events = global.TarinaiEvents || null; + worldRef.liveTarinai = new Map(); + worldRef.liveIdFree = []; + + const items = Array.isArray(data.items) ? data.items : []; + for (const saved of items) { + if (!saved || !saved.type) continue; + const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0); + applyOwnData(item, saved, new Set(["target", "targetRef", "world"])); + 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 }); + } + + const ants = Array.isArray(data.ants) ? data.ants : []; + for (const saved of ants) { + if (!saved) continue; + const ant = new AntActor(worldRef, saved); + applyOwnData(ant, saved, new Set(["world", "targetRef"])); + ant.world = worldRef; + worldRef.ants.push(ant); + } + + const effects = Array.isArray(data.effects) ? data.effects : []; + for (const saved of effects) { + if (!saved || !saved.type) continue; + const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved); + applyOwnData(ef, saved, new Set(["world", "target", "targetRef"])); + worldRef.effects.push(ef); + } + + const itemById = new Map(worldRef.items.map(it => [it.id, it])); + const antById = new Map(worldRef.ants.map(a => [a.id, a])); + const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t])); + const resolve = (ref) => { + if (!ref) return null; + if (ref.kind === "item") return itemById.get(ref.id) || null; + if (ref.kind === "ant") return antById.get(ref.id) || null; + if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null; + return resolveRef(worldRef, ref); + }; + for (const [i, saved] of tarinaiList.entries()) { + const t = worldRef.tarinai[i]; + if (!t) continue; + t.target = resolve(saved.__targetRef); + t.panicTarget = resolve(saved.__panicTargetRef); + } + for (const [i, saved] of ants.entries()) { + const ant = worldRef.ants[i]; + if (!ant) continue; + ant.targetRef = resolve(saved.__targetRefObject); + } + + worldRef.selected = resolve(data.selectedRef); + worldRef.family = clonePlain(data.world?.family) || worldRef.family || {}; + worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {}; + worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {}; + worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {}; + worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } }; + worldRef.familyTreeDirty = true; + worldRef.drawListDirty = true; + worldRef.terrainDirty = true; + if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts(); + if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts(); + if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true); + if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load"); + if (typeof worldRef.clampCamera === "function") worldRef.clampCamera(); + if (typeof renderLog === "function") renderLog(worldRef.logs || []); + if (typeof resetArchiveRenderState === "function") resetArchiveRenderState(); + if (typeof renderArchive === "function") renderArchive(); + if (typeof renderSelected === "function") renderSelected(); + if (typeof renderStats === "function") renderStats(); + if (typeof render === "function") render(); + if (typeof syncTopButtons === "function") syncTopButtons(); + global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef); + return true; + } + + global.TarinaiSnapshot = { + version: SNAPSHOT_VERSION, + clonePlain, + copyOwnData, + applyOwnData, + refFor, + resolveRef, + worldData, + snapshotEntity, + createSnapshot, + restoreSnapshot, + restoreTarinaiData, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index 75acc60..d3cd670 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -94,6 +94,10 @@ class Tarinai { constructor(world, opts = {}) { this.foodReactTimer = opts.foodReactTimer ?? 0; this.surpriseTimer = opts.surpriseTimer ?? 0; this.fearTimer = opts.fearTimer ?? 0; + this.sunbathTimer = opts.sunbathTimer ?? 0; + this.sunbathCooldown = opts.sunbathCooldown ?? 0; + this.sunbathFrameTimer = opts.sunbathFrameTimer ?? 0; + this.sunbathSpriteVariant = opts.sunbathSpriteVariant ?? 1; this.intimidateTimer = opts.intimidateTimer ?? 0; this.intimidateTargetId = opts.intimidateTargetId || null; this.intimidatedTimer = opts.intimidatedTimer ?? 0; @@ -147,6 +151,7 @@ class Tarinai { constructor(world, opts = {}) { this.nextHeadbutt = opts.nextHeadbutt ?? 0; this.digest = opts.digest ?? rand(0, 2.4); this.poopCount = opts.poopCount ?? 0; + this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0; this.stretchTimer = opts.stretchTimer ?? 0; this.lastMealColor = opts.lastMealColor || "#f7cf67"; this.grassEatTimer = opts.grassEatTimer ?? 0; diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js new file mode 100644 index 0000000..c5665cb --- /dev/null +++ b/js/tarinai_action_state.js @@ -0,0 +1,90 @@ +"use strict"; + +(function (global) { + const Tarinai = global.Tarinai; + if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_action_state.js"); + + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ + setActionState(state, opts = {}) { + if (this.dead) return false; + this.state = state || "idle"; + if ("target" in opts) this.target = opts.target; + if (opts.reason != null) this.thought = String(opts.reason || ""); + if (opts.clearTarget) this.target = null; + if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping); + if (opts.wake) { + this.sleeping = false; + if (this.insideNestBoxId && this.leaveNestBox) this.leaveNestBox(this.world?.nestBoxById?.(this.insideNestBoxId)); + this.insideNestBoxId = ""; + } + return true; + }, + + beginEating(item = null, reason = "") { + return this.setActionState("eat", { + target: item || null, + reason: reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている"), + sleeping: false, + }); + }, + + currentLodgedPin() { + return typeof lodgedPinFor === "function" ? lodgedPinFor(this, this.world) : null; + }, + + currentLodgedPinBehavior() { + const pin = this.currentLodgedPin ? this.currentLodgedPin() : null; + return typeof pinBehaviorFor === "function" ? pinBehaviorFor(pin?.type) : null; + }, + + hasLodgedPinEffect(effectKey = "") { + return Boolean(this.currentLodgedPinBehavior?.()?.[effectKey]); + }, + + goIdle(reason = "") { + return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false }); + }, + + startSleeping(target = null, reason = "") { + return this.setActionState("sleep", { target, reason: reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている"), sleeping: true }); + }, + + seekTarget(state, target = null, reason = "") { + return this.setActionState(state, { target, reason, sleeping: false }); + }, + + enterPanic(opts = {}) { + if (this.dead) return false; + const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 }; + const fearMul = opts.fearMultiplier ?? profile.fear ?? 1; + const threat = opts.threat ?? opts.target ?? null; + let target = null; + if ("target" in opts) target = opts.target; + else if (opts.destination) target = opts.destination; + else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); + this.setActionState("panic", { + target, + reason: opts.reason || this.thought || "パニックになっている", + wake: opts.wake !== false, + sleeping: false, + }); + if (Number.isFinite(opts.fearTimer)) this.fearTimer = Math.max(this.fearTimer || 0, opts.fearTimer * fearMul); + else this.fearTimer = Math.max(this.fearTimer || 0, (opts.fear ?? 0.55) * fearMul); + if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); + if (Number.isFinite(opts.hurtTimer)) this.hurtTimer = Math.max(this.hurtTimer || 0, opts.hurtTimer); + if (Number.isFinite(opts.awakeLockTimer)) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, opts.awakeLockTimer); + if (Number.isFinite(opts.fallTimer)) { + this.fallTimer = Math.max(this.fallTimer || 0, opts.fallTimer); + this.fallMax = Math.max(this.fallMax || 0, this.fallTimer); + } + if (Number.isFinite(opts.stress) && opts.stress > 0) { + if (this.addStress) this.addStress(opts.stress, { threshold: opts.stressThreshold ?? 8, duration: opts.stressDuration ?? 4.2 }); + else this.stress = clamp((this.stress || 0) + opts.stress, 0, 130); + } + if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)"); + this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic"; + this.lastPanicAt = this.world?.time || 0; + return true; + }, + })); +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 14b4d7d..5bbec40 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -85,9 +85,7 @@ this.sleepDiseaseCooldown = 8; this.sleepDiseaseAnchorX = this.x; this.sleepDiseaseAnchorY = this.y; - this.state = "sleep"; - this.target = null; - this.sleeping = true; + this.startSleeping(null, "ねむり病で眠り続けている"); this.fightTimer = 0; this.intimidateTimer = 0; this.fightTargetIds = []; @@ -106,10 +104,9 @@ this.sleepDiseaseCooldown = 14; this.sleepDiseaseAnchorX = null; this.sleepDiseaseAnchorY = null; - this.state = "idle"; + this.goIdle(reason || "ねむり病が治った"); this.sleeping = false; this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); - this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); @@ -184,9 +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.state = "sleep"; - this.sleeping = true; - this.target = null; + this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている"); this.vx = 0; this.vy = 0; if (beingDraggedByAnt) { @@ -233,7 +228,7 @@ } this.insideNestBoxId = box.id; this.target = box; - if (this.state === "seek_bed") this.state = "sleep"; + if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている"); if (this.state === "sleep") this.sleeping = true; const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); @@ -284,7 +279,7 @@ if (currentBox) { if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { - this.state = "sleep"; + this.startSleeping(box, "巣箱の中で眠っている"); this.sleeping = true; this.target = currentBox; } diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index c54c756..6d0776b 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -103,12 +103,11 @@ personalityProfile() { const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; - const p = ensurePersonality(this).currentPersonality; const e = { - neuroticism: personalityEffectValue(p.neuroticism), - aggression: personalityEffectValue(p.aggression), - sociability: personalityEffectValue(p.sociability), - openness: personalityEffectValue(p.openness), + neuroticism: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (ensurePersonality(this).currentPersonality.neuroticism || 0)), + aggression: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "aggression") : (ensurePersonality(this).currentPersonality.aggression || 0)), + sociability: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "sociability") : (ensurePersonality(this).currentPersonality.sociability || 0)), + openness: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "openness") : (ensurePersonality(this).currentPersonality.openness || 0)), }; return { ...base, @@ -285,10 +284,8 @@ if (!wasSleeping) return false; const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; this.sleeping = false; - this.state = "panic"; - this.target = null; - this.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f"; - this.fearTimer = Math.max(this.fearTimer || 0, 0.85); + if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f", fear: 0.85, wake: false, cause: "wake_from_damage" }); + else { this.setActionState?.("panic", { target: null, reason: "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f", wake: false }); this.fearTimer = Math.max(this.fearTimer || 0, 0.85); } this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); this.nestBoxStayUntil = -Infinity; if (this.insideNestBoxId) this.leaveNestBox(box); @@ -368,7 +365,7 @@ this.targetGiveupKey = key; this.targetGiveupUntil = this.world.time + 8; this.target = null; - this.state = "idle"; + this.goIdle?.("柵に阻まれて行き先を変えている"); this.wanderAngle += rand(-1.4, 1.4); return; } @@ -376,9 +373,8 @@ this.targetGiveupKey = key; this.targetGiveupUntil = this.world.time + 12; this.target = null; - this.state = "idle"; + this.goIdle?.("辿り着けず、あきらめた"); this.wanderAngle += rand(-2.2, 2.2); - this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f"; } }, diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index 386da11..20ab5e9 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -169,7 +169,7 @@ this.bubble("???", 1.2, ITEM_EFFECT_COLORS.mystery_drug); this.spawnPowerItemEffect("mystery_drug", 1.0); applied = picks; - } else if (EFFECT_REGISTRY?.apply?.(type, this, { item, scale, source: "item" })) { + } else if (EFFECT_REGISTRY?.apply?.(type, this, { item, scale, source: options.source || "item", nutrition: options.nutrition })) { applied.push(type); if (type === "laxative") { this.spawnPowerItemEffect("laxative", 0.75); @@ -257,6 +257,11 @@ forceLaxativePoop() { if (!this.world || this.dead) return; + if (this.hasLodgedPinEffect?.("blocksZunchi")) { + this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; + return; + } const side = this.facingDir ? this.facingDir() : (this.facing || 1); const x = this.x - side * this.radius * rand(0.72, 0.96); const y = this.y + this.radius * rand(0.25, 0.48); diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index f8ed366..fc9170d 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -50,9 +50,11 @@ rawSpriteId() { if (this.dead) return this.variantSprite("fear"); + if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) return this.sunbathSpriteId ? this.sunbathSpriteId() : "sunbath_1"; if (this.sleepDisease) return this.variantSprite("sleep"); if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan"; if (this.fightDisease) return "stress_dizzy"; + if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak"; if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; @@ -106,6 +108,70 @@ return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); }, + sunbathSpriteId() { + if (!this.sunbathSpriteVariant) this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + return `sunbath_${this.sunbathSpriteVariant}`; + }, + + isSunbathTime() { + const weatherOk = (this.world?.weather || "") === "sunny"; + const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24); + return weatherOk && hour >= 6 && hour <= 15.5; + }, + + canStartSunbath() { + if (this.dead || this.sleepDisease || this.sleeping) return false; + if (!this.isSunbathTime()) return false; + if ((this.sunbathCooldown || 0) > 0) return false; + if (this.hasLodgedPinEffect?.("blocksZunchi")) return false; + if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; + if (this.hunger > 68 || this.energy < 18) return false; + return true; + }, + + tryRecoverBySunbath() { + if (this.sunbathRecoveryChecked) return false; + this.sunbathRecoveryChecked = true; + const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; + if (!sick || Math.random() >= 0.75) return false; + let healed = false; + if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("日光浴でずんち病が治まった") || healed; + if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("日光浴でねむり病が治った") || healed; + if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("日光浴で爆発病が治った") || healed; + if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("日光浴できずつき病が治った") || healed; + if (healed) this.world?.log?.(`${this.name}は日光浴で病気が軽くなった。`, "event", { participants: [this] }); + return healed; + }, + + startSunbath() { + if (!this.canStartSunbath()) return false; + this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false }); + this.sunbathTimer = rand(6, 14); + this.sunbathCooldown = rand(16, 34); + this.sunbathFrameTimer = 0; + this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + this.sunbathRecoveryChecked = false; + this.tryRecoverBySunbath(); + if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("ぽかぽか", 4.0, "rgba(112,86,36,0.78)"); + return true; + }, + + updateSunbath(dt) { + if ((this.sunbathTimer || 0) <= 0) return; + this.sunbathTimer = Math.max(0, (this.sunbathTimer || 0) - dt); + this.sunbathFrameTimer = Math.max(0, (this.sunbathFrameTimer || 0) - dt); + if ((this.sunbathFrameTimer || 0) <= 0) { + this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); + this.sunbathFrameTimer = rand(0.55, 1.15); + } + this.stress = clamp((this.stress || 0) - dt * 1.15, 0, 130); + this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); + if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0; + if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { + this.goIdle(""); + } + }, + applyCursorContactCare(dt) { const pointer = this.world?.pointer; if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; @@ -172,11 +238,23 @@ this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); + this.pinchThoughtTimer = Math.max(0, (this.pinchThoughtTimer || 0) - dt); + this.sunbathCooldown = Math.max(0, (this.sunbathCooldown || 0) - dt); if (this.freezeSleepDiseaseMotion(dt)) { this.checkLife(dt); return; } this.applyCursorContactCare(dt); + const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; + if (colonyMoodId === "weaklings" && !this.isZunchiSlave) this.stress = clamp((this.stress || 0) - dt * 0.28, 0, 130); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") this.stress = clamp((this.stress || 0) - dt * 0.34, 0, 130); + if (colonyMoodId === "ant_overrun" && this.state !== "panic") { + const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); + if (antSeen) { + if (this.enterPanic) this.enterPanic({ target: null, reason: "アリだ!", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); + else { this.setActionState?.("panic", { target: null, reason: "アリだ!", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } + } + } const wasFighting = this.fightTimer > 0.04; this.fightTimer = Math.max(0, this.fightTimer - dt); this.fightCooldown = Math.max(0, this.fightCooldown - dt); @@ -279,6 +357,7 @@ this.envTimer = 0; this.applyEnvironment(envDt); } + this.updateSunbath(dt); this.maintainTargetProgress(dt); this.move(dt); if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); @@ -339,7 +418,7 @@ }, isSleepFurniture(target) { - return Boolean(target && (target.type === "bed" || target.type === "nest_box")); + return Boolean(target && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))); }, sleepFurnitureLabel(target) { @@ -373,10 +452,16 @@ consumeFoodServing(it, nutrition) { this.ensureFoodServings(it); if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; + const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0); it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); it.amount = it.foodServingsRemaining; - this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition }); - return nutrition; + this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value }); + return value; + }, + + foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) { + const relief = window.TarinaiFoodRegistry?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier; + return (Number(nutrition) || 0) * relief; }, sleepSpotFor(bed) { @@ -432,6 +517,7 @@ if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; + if (push > 0.35 && (this.state === "idle" || !this.thought)) this.thought = "新鮮なずんちを避けている"; }, resolveNeeds(dt) { @@ -441,22 +527,18 @@ this.sleeping = false; if (this.birthRitualTimer > 0.04) { - this.state = "birth_ritual"; - this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; - this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; + this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "近くで体をゆすっている" }); return; } if (this.intimidateTimer > 0.04) { - this.state = "intimidate"; - this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; + this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "相手を威嚇している" }); this.bubble("!", 1.2); - this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; return; } if (this.fightTimer > 0.04) { - this.state = "fight"; + this.setActionState?.("fight", { reason: "喧嘩している" }); this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; let best = null, bestD = Infinity; @@ -466,17 +548,15 @@ const d = dist(this, t); if (d < bestD) { best = t; bestD = d; } } - this.target = best; + this.setActionState?.("fight", { target: best, reason: "喧嘩している" }); this.bubble("!", 1.6); - this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b"; return; } if (this.defeatedTimer > 0.04) { const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); - this.state = "panic"; - this.target = this.panicDestination(opponent); - this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b"; + if (this.enterPanic) this.enterPanic({ threat: opponent, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.0, wake: true, cause: "defeated_escape" }); + else this.setActionState?.("panic", { target: this.panicDestination(opponent), reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; return; } else if (this.defeatedById && this.fightTimer <= 0.04) { @@ -485,12 +565,11 @@ } if (this.stuckPushpinId) { - const pin = this.world.items?.find(it => it && !it.dead && it.id === this.stuckPushpinId && it.type === "pushpin" && it.pinState === "lodged") || null; - if (pin) { - this.state = "panic"; - this.sleeping = false; - this.target = this.panicDestination(pin); - this.fearTimer = Math.max(this.fearTimer || 0, 0.60); + const pin = this.currentLodgedPin?.() || null; + const pinBehavior = this.currentLodgedPinBehavior?.() || null; + if (pin && pinBehavior?.panicOnAttach) { + if (this.enterPanic) this.enterPanic({ threat: pin, reason: "画鋲が刺さってパニックになっている", fear: 0.60, hurtTimer: 0.24, awakeLockTimer: 1.8, bubble: "!!", bubbleColor: "rgba(168,72,72,0.82)", cause: "pushpin_stuck" }); + else { this.setActionState?.("panic", { target: this.panicDestination(pin), reason: "画鋲が刺さってパニックになっている", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.60); } 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)"); @@ -501,15 +580,22 @@ } const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; - if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { - this.state = "panic"; - this.target = this.panicDestination(friendNearby.target); - this.fearTimer = Math.max(this.fearTimer, 0.32); - this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; - return; + const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || []) + .find(o => o && o !== this && !o.dead && !this.world?.isTarinaiHiddenInNestBox?.(o) && o.state === "panic" && (o.fearTimer || 0) > 0.12); + if (panicSource && !["panic", "sleep", "seek_bed", "fight", "birth_ritual", "eat", "sunbath"].includes(this.state)) { + const d = distXY(this.x, this.y, panicSource.x, panicSource.y); + const neuro = typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (this.currentPersonality?.neuroticism || 0); + const distFactor = clamp(1 - d / 128, 0, 1); + const stressFactor = clamp(((this.stress || 0) - 35) / 80, 0, 0.22); + const chance = clamp(0.10 + Math.max(0, neuro) * 0.36 + distFactor * 0.18 + stressFactor, 0.05, 0.76); + if (Math.random() < chance) { + if (this.enterPanic) this.enterPanic({ threat: panicSource.target || panicSource, reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, fear: 0.75, stress: 3 + distFactor * 5, stressThreshold: 6, cause: "nearby_panic_chain" }); + else { this.setActionState?.("panic", { target: this.panicDestination(panicSource.target || panicSource), reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.75 * this.personalityProfile().fear); this.addStress ? this.addStress(3 + distFactor * 5, { threshold: 6 }) : (this.stress = clamp((this.stress || 0) + 3 + distFactor * 5, 0, 130)); } + return; + } } - const nearbyAnts = (this.world.ants || []).filter(a => a && !a.dead && distXY(this.x, this.y, a.x, a.y) <= (a.kind === "queen" ? 112 : 82)); + const nearbyAnts = this.world.nearbyAnts?.(this.x, this.y, 112, true) || []; let nearestAnt = null; let nearestAntDist = Infinity; for (const ant of nearbyAnts) { @@ -524,22 +610,16 @@ if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { // Intimidation has no effect on ants. This is only a visible reaction. - this.state = "ant_intimidate"; - this.target = nearestAnt; + this.setActionState?.("ant_intimidate", { target: nearestAnt, reason: "アリを威嚇している" }); this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); - this.thought = "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; return; } - this.state = "ant_attack"; - this.target = nearestAnt; + this.setActionState?.("ant_attack", { target: nearestAnt, reason: "アリを攻撃している" }); this.goodMode = "angry"; - this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; return; } - this.state = "panic"; - this.target = this.panicDestination(nearestAnt); - this.fearTimer = Math.max(this.fearTimer, 0.34); - this.thought = "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b"; + if (this.enterPanic) this.enterPanic({ threat: nearestAnt, reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", fear: 0.34, wake: true, cause: "ant_seen" }); + else this.setActionState?.("panic", { target: this.panicDestination(nearestAnt), reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", wake: true }); return; } @@ -555,10 +635,8 @@ if (d <= Math.max(48, this.radius + opponent.radius + 18)) { if (this.world.startForcedFight?.(this, opponent)) return; } - this.state = "seek_friend"; - this.target = opponent; + this.setActionState?.("seek_friend", { target: opponent, reason: "けんか餅で相手を探している" }); this.goodMode = "angry"; - this.thought = "\u3051\u3093\u304b\u9905\u3067\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b"; if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); return; } @@ -569,10 +647,8 @@ if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); if (rival && dist(this, rival) < 150) { - this.state = "panic"; - this.target = this.panicDestination(rival); - this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear); - this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`; + if (this.enterPanic) this.enterPanic({ threat: rival, reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, fear: 0.35, wake: true, cause: "avoid_rival" }); + else this.setActionState?.("panic", { target: this.panicDestination(rival), reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, wake: true }); this.world.relationNotice(this.id, rival.id, "avoid", 22); return; } @@ -581,15 +657,11 @@ if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { const water = this.world.nearest(this, ["water", "water_bowl"], 760); if (water && !water.dead) { - this.state = "seek_water"; - this.target = water; - this.thought = this.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; + this.setActionState?.("seek_water", { target: water, reason: this.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している" }); return; } if (this.zunchiDisease) { - this.state = "zunchi_sick"; - this.target = null; - this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + this.setActionState?.("zunchi_sick", { target: null, reason: "ずんち病で当てもなく彷徨っている" }); if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); return; } @@ -598,18 +670,14 @@ if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { const cureFood = this.world.nearest(this, ["sweet"], 700); if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { - this.state = "seek_food"; - this.target = cureFood; + this.setActionState?.("seek_food", { target: cureFood, reason: this.fightDisease ? "きずつき病でずんだ餅を探している" : "爆発病でずんだ餅を探している" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); - this.thought = this.fightDisease ? "\u304d\u305a\u3064\u304d\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - this.state = "fight_sick"; - this.target = null; - this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + this.setActionState?.("fight_sick", { target: null, reason: "きずつき病で当てもなく彷徨っている" }); if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); return; } @@ -620,19 +688,15 @@ const specialDist = specialMochi ? distXY(this.x, this.y, specialMochi.x, specialMochi.y) : Infinity; const weakInterest = this.hunger > (wasSleeping ? 68 : 76) && (specialDist < 72 || Math.random() < dt * 0.028); if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) { - this.state = "seek_food"; - this.target = specialMochi; + this.setActionState?.("seek_food", { target: specialMochi, reason: `${toolLabel(specialMochi.type)}が少し気になっている` }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); - this.thought = `${toolLabel(specialMochi.type)}\u304c\u5c11\u3057\u6c17\u306b\u306a\u3063\u3066\u3044\u308b`; return; } } else if (this.hunger > 20) { const zOnly = this.world.nearest(this, ["zunchi"], 520); if (zOnly && zOnly.amount > 1.2) { - this.state = "seek_food"; - this.target = zOnly; + this.setActionState?.("seek_food", { target: zOnly, reason: "ずんちを探している" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "\u305a\u3093\u3061\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } @@ -642,17 +706,14 @@ const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); const zundaReward = this.affection > 8 && this.hunger > 7; if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { - this.state = "seek_food"; - this.target = zunda; + this.setActionState?.("seek_food", { target: zunda, reason: "ずんだ餅を探している" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); - this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { - this.state = "panic"; - this.target = this.panicDestination(); - this.thought = "\u9003\u3052\u3066\u3044\u308b"; + if (this.enterPanic) this.enterPanic({ reason: "\u9003\u3052\u3066\u3044\u308b", fear: 0.65, wake: true, cause: "fear_or_stress" }); + else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u9003\u3052\u3066\u3044\u308b", wake: true }); return; } @@ -666,15 +727,11 @@ if (bed && !wasSleeping && this.energy > 18) { const spot = this.sleepSpotFor(bed); if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { - this.state = "seek_bed"; - this.target = bed; - this.thought = `${this.sleepFurnitureLabel(bed)}\u3078\u5411\u304b\u3063\u3066\u3044\u308b`; + this.setActionState?.("seek_bed", { target: bed, reason: `${this.sleepFurnitureLabel(bed)}へ向かっている` }); return; } } - this.state = "sleep"; - this.target = bed; - this.sleeping = true; + this.startSleeping?.(bed, bed ? (bed.type === "nest_box" ? "巣箱の中で眠っている" : "ベッドの近くで眠っている") : "眠っている"); if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); @@ -690,8 +747,7 @@ const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; const friendly = !hostile && this.isAwakeCursorFriendly(); if (hostile) { - this.state = "cursor_enemy"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; + this.setActionState?.("cursor_enemy", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルを避けている" }); this.fearTimer = Math.max(this.fearTimer, 0.18); if (this.world.time > this.nextFearBubbleAt) { this.nextFearBubbleAt = this.world.time + 2.4; @@ -701,8 +757,7 @@ return; } if (friendly) { - this.state = "cursor_friend"; - this.target = { x: pointer.x, y: pointer.y, dead: false }; + this.setActionState?.("cursor_friend", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルについていく" }); this.goodMode = "smile"; this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; return; @@ -722,10 +777,8 @@ const movingBonus = clamp(ballSpeed / 120, 0, 0.42); const socialBonus = this.loneliness > 48 ? 0.10 : 0; if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { - this.state = "play_ball"; - this.target = ball; + this.setActionState?.("play_ball", { target: ball, reason: openPlay ? "ボールに引き寄せられている" : "転がるものが気になっている" }); this.goodMode = "smile"; - this.thought = openPlay ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; return; } } @@ -734,19 +787,15 @@ if (parent && !this.shouldAvoidTarget(parent)) { const d = dist(this, parent); if (d > 34 || (this.hunger < 78 && this.energy > 24)) { - this.state = "follow_parent"; - this.target = parent; + this.setActionState?.("follow_parent", { target: parent, reason: "親についていく" }); this.bubble("?", 4.0); - this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f"; return; } } if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { - this.state = "seek_food"; - this.target = friendNearby.target; + this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; return; } @@ -754,10 +803,8 @@ const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; const item = this.world.nearest(this, itemTypes, 420); if (item) { - this.state = "seek_food"; - this.target = item; + this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" }); this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } @@ -767,27 +814,31 @@ if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); if (other) { - this.state = "seek_friend"; - this.target = other; - this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b"; + this.setActionState?.("seek_friend", { target: other, reason: "仲間を探している" }); return; } } if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { - this.state = "panic"; - this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + if (this.enterPanic) this.enterPanic({ reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.55, wake: true, cause: "stress_panic" }); + else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); return; } if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); - this.state = "idle"; - this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`; + this.goIdle(`${this.need}がたりない`); }, makePoop(force = 1) { this.digest += force; if (this.digest < 12.4) return; + if (this.hasLodgedPinEffect?.("blocksZunchi")) { + this.digest = rand(0.4, 1.6); + this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; + if ((this.oshiriByoZunchiStock || 0) >= 6) this.stress = clamp((this.stress || 0) + 1.8, 0, 130); + return; + } this.digest = rand(0.4, 1.6); this.poopCount += 1; const side = this.facingDir(); @@ -811,13 +862,13 @@ if (d > this.radius + it.r + 12) continue; if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it, it.type === "sweet" ? 7.2 : 8.5); + const eating = this.consumeFoodServing(it); if (eating > 0) { - this.state = "eat"; - this.target = it; + this.beginEating(it, `${toolLabel(it.type)}を食べている`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(eating * (it.type === "sweet" ? 0.75 : 0.95)) : (this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95)); + const hungerRelief = this.foodHungerReliefFor(it.type, eating, it.type === "sweet" ? 0.75 : 0.95); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; if (it.type === "sweet") { @@ -846,40 +897,32 @@ } if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { - const eating = this.consumeFoodServing(it, 6.8); + const eating = this.consumeFoodServing(it); if (eating <= 0) continue; - this.state = "eat"; - this.target = it; + this.beginEating(it, `${toolLabel(it.type)}を食べている`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - this.applyFoodHungerRelief ? this.applyFoodHungerRelief(eating * 0.82) : (this.hunger -= eating * 0.82); + const hungerRelief = this.foodHungerReliefFor(it.type, eating, 0.82); + this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); this.recoverHealth(eating * 0.24); this.affection += eating * 0.04; - if (it.type === "love_mochi") { - this.stress = clamp(this.stress - eating * 0.25, 0, 130); - this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); - this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); - this.lastMealColor = "#ff7bab"; - } else if (it.type === "fight_mochi") { - this.stress = clamp(this.stress + eating * 0.22, 0, 130); - this.fearTimer = Math.max(this.fearTimer || 0, 0.32); - this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); - this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); - this.lastMealColor = "#e07c43"; - } else if (it.type === "sleep_drug") { - this.stress = clamp(this.stress - eating * 0.10, 0, 130); - this.energy = clamp(this.energy - eating * 0.20, 0, 100); - this.fearTimer = Math.max(0, (this.fearTimer || 0) - 0.18); - this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - 2.5); - this.bubble("Zzz...", 2.3, "rgba(104,84,168,0.78)"); - this.lastMealColor = "#b89cff"; - const sleepChance = this.diseaseChance ? this.diseaseChance(Math.min(0.98, 0.35 + eating * 0.075)) : Math.min(0.98, 0.35 + eating * 0.075); - if (!this.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) this.infectSleepDisease(it); - } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type)) { - if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it); - const color = it.type === "protein" ? "#f28d3d" : it.type === "niteropu" ? "#6255a8" : it.type === "ammo" ? "#d0942f" : it.type === "laxative" ? "#788c3b" : it.type === "mercury" ? "#6e95a8" : it.type === "giant_drug" ? "#e47b3a" : it.type === "dwarf_drug" ? "#7773c8" : it.type === "zunda_juice" ? "#61b94f" : "#41aa66"; - this.lastMealColor = color; - } + const mealColors = { + love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", + protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", + mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f", + }; + const mealBubbles = { + love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"], + fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"], + sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"], + }; + if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it, { + source: "eating", + nutrition: eating, + silentLog: it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug", + }); + if (mealBubbles[it.type]) this.bubble(...mealBubbles[it.type]); + this.lastMealColor = mealColors[it.type] || "#41aa66"; this.eatTimer = Math.max(this.eatTimer, 0.30); this.eatCooldown = rand(0.38, 0.68); this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); @@ -899,8 +942,7 @@ if (canBite && it.type === "grass") { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); - this.state = "eat"; - this.target = it; + this.beginEating(it, "草を食べている"); this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.vx *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60); @@ -926,8 +968,7 @@ if (canBite && it.type === "ant_corpse") { const bite = Math.min(it.amount, rand(3.2, 5.4)); if (bite > 0) { - this.state = "eat"; - this.target = it; + this.beginEating(it, "アリの死骸を食べている"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; @@ -956,8 +997,7 @@ if (canZunchiBite) { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - this.state = "eat"; - this.target = it; + this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; @@ -978,6 +1018,8 @@ } if (it.type === "water" || it.type === "water_bowl") { + this.thought = it.type === "water_bowl" ? "水の皿で水を飲んでいる" : "水を浴びて落ち着いている"; + this.target = it; this.applyWaterEffect(dt, it.type); if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); @@ -988,6 +1030,7 @@ } if (it.type === "stone") { + if (this.state === "idle" && (this.need === "あたたかさ" || this.stress > 48)) this.thought = "石の近くで落ち着いている"; this.stress -= dt * 0.85; this.energy += dt * 0.18; if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; @@ -997,9 +1040,7 @@ if (this.energy < 74 + 6 * this.personalityProfile().sleep) { const occ = this.world.bedOccupancy(it); const comfort = this.world.bedComfort(it); - this.state = "sleep"; - this.target = it; - this.sleeping = true; + this.startSleeping?.(it, it.type === "nest_box" ? "巣箱の中で休んでいる" : "寝床の近くで休んでいる"); if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; const crowded = occ > 5; diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index e6b1b0d..c8af767 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -34,9 +34,14 @@ const ny = (nonSlave.y - slave.y) / d; nonSlave.vx += nx * dt * 34; nonSlave.vy += ny * dt * 34; - nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); - nonSlave.target = slave; - if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") nonSlave.state = "panic"; + if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") { + if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "ずんちどれいを怖がっている", fear: 0.38, wake: false, cause: "zunchi_slave_nearby" }); + else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; nonSlave.thought = "ずんちどれいを怖がっている"; } + } else { + nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); + nonSlave.target = slave; + nonSlave.thought = "ずんちどれいを怖がっている"; + } nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); if (Math.random() < dt * 0.35) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); @@ -156,6 +161,14 @@ const itemSpeedMul = this.itemSpeedMultiplier ? this.itemSpeedMultiplier() : 1; const baseSpeed = 46.5 * 1.5 * this.trait.speed * itemSpeedMul * (this.zunchiDisease ? 0.5 : 1) * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1); + if (this.state === "sunbath") { + this.target = null; + this.vx *= Math.pow(0.90, dt * 60); + this.vy *= Math.pow(0.90, dt * 60); + this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + return; + } if (this.state === "sleep") { const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72; const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; @@ -246,8 +259,7 @@ ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); if (reachedSleepFurniture) { - this.state = "sleep"; - this.sleeping = true; + this.startSleeping?.(this.target, this.target.type === "nest_box" ? "巣箱の中で休んでいる" : "眠る場所に着いたので休んでいる"); if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); return; } diff --git a/js/text_catalog.js b/js/text_catalog.js index 51aa4b2..eb561a4 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -51,4 +51,97 @@ 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: "冷凍中", +}); + +TEXT_CATALOG.targetLabelOverrides = Object.freeze({ + system: "ずんち病", + zunchi_sick: "ずんち病", + fight_sick: "きずつき病", +}); +// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel(). +TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides; + +TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) { + return TEXT_CATALOG.stateLabels?.[s] || s || ""; +}; + +TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) { + if (!target) return "なし"; + if (target.name) return target.name; + if (target.kind === "queen") return "女王アリ"; + if (target.kind === "worker") return "働きアリ"; + if (target.type) { + const type = String(target.type || ""); + if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type]; + const label = typeof toolLabel === "function" ? toolLabel(type) : ""; + return label || type; + } + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置"; + return "不明"; +}; + +TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { + if (!t) return "なし"; + if ((t.pinchThoughtTimer || 0) > 0) return "つままれている"; + const thought = String(t.thought || "").trim(); + if (thought) return thought; + const target = TEXT_CATALOG.targetLabel(t.target); + if (t.state === "eat") return target !== "なし" ? `${target}を食べている` : "食べている"; + if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している"; + if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている"; + if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している"; + if (t.state === "ant_intimidate") return "アリを威嚇している"; + if (t.state === "ant_attack") return "アリを攻撃している"; + if (t.state === "birth_ritual") return "子が現れる前に体をゆすっている"; + if (t.state === "sunbath") return "晴れた日なたでストレスを下げている"; + if (t.state === "frozen") return "フィールド外で冷凍保存されている"; + if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている"; + if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している"; + if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している"; + if (t.state === "play_ball") return "ボールが気になっている"; + if (t.state === "seek_friend") return "仲間を探している"; + if (t.state === "follow_parent") return "親についていく"; + if (t.state === "cursor_friend") return "カーソルについていく"; + if (t.state === "cursor_enemy") return "カーソルを避けている"; + if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している"; + if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている"; + if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い"; + if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している"; + if ((t.fearTimer || 0) > 0.08) return "怖がっている"; + if ((t.grassEatTimer || 0) > 0.04) return "草を食べている"; + return "周囲を見ている"; +}; + +function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); } +function targetLabel(target) { return TEXT_CATALOG.targetLabel(target); } +function reasonLabel(t) { return TEXT_CATALOG.reasonLabel(t); } + +window.stateLabel = stateLabel; +window.targetLabel = targetLabel; +window.reasonLabel = reasonLabel; + window.TEXT_CATALOG = TEXT_CATALOG; diff --git a/js/ui.js b/js/ui.js index 026fbf5..8cc8fbc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -11,6 +11,7 @@ const ui = { soundPanel: document.getElementById("soundPanel"), soundMasterToggle: document.getElementById("soundMasterToggle"), creditsBtn: document.getElementById("creditsBtn"), + saveBtn: document.getElementById("saveBtn"), resetBtn: document.getElementById("resetBtn"), creditsDialog: document.getElementById("creditsDialog"), creditsCloseBtn: document.getElementById("creditsCloseBtn"), @@ -29,6 +30,7 @@ const ui = { toast: document.getElementById("toast"), statPop: document.getElementById("statPop"), statDead: document.getElementById("statDead"), + statColonyMood: document.getElementById("statColonyMood"), colonyChart: document.getElementById("colonyChart"), colonyChartLegend: document.getElementById("colonyChartLegend"), archiveContent: document.getElementById("archiveContent"), diff --git a/js/ui_bind.js b/js/ui_bind.js index 509f955..62cfbce 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -7,6 +7,8 @@ function bindUI() { syncAudioControls(); applyToolTips(); renderEcologyCards(); + window.TarinaiSaveSystem?.bindSaveSystem?.(); + window.TarinaiFreezeSystem?.bindFreezeSystem?.(); uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); syncToolSizeBadges(); for (const toggle of document.querySelectorAll(".panel-card-toggle")) { @@ -266,7 +268,6 @@ function bindUI() { transferEdgeScroll(ui.log, rightPanel); const inputModeManager = window.TarinaiInputMode; - const isTouchDevice = () => Boolean(inputModeManager?.snapshot?.().isTouchDevice || matchMedia?.("(pointer: coarse)")?.matches || navigator.maxTouchPoints > 0); const setMobileInputMode = (mode = "auto") => { mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto"); uiCache.mobileInputMode = mode; @@ -317,6 +318,16 @@ function bindUI() { const sy = clientY - rect.top; return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 }; }; + const clientToWorldRaw = (clientX, clientY) => { + const rect = canvas.getBoundingClientRect(); + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const scale = world.viewScale ? world.viewScale() : 1; + const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 }; + const rawX = (sx - off.x) / scale + (world.cameraX || 0); + const rawY = (sy - off.y) / scale + (world.cameraY || 0); + return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h }; + }; const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => { if (!world.setFieldZoom) return false; const rect = canvas.getBoundingClientRect(); @@ -331,6 +342,41 @@ function bindUI() { world.clampCamera?.(); return true; }; + + function isClientOverFrozenPanel(clientX, clientY) { + const panel = document.getElementById("frozenPanel"); + if (!panel) return false; + const r = panel.getBoundingClientRect(); + return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom; + } + + function rememberGrabOrigin(target) { + if (!target || uiCache.grabKind !== "tarinai") return; + uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 }; + } + + function finishTarinaiGrab(target, clientX, clientY) { + if (!target || uiCache.grabKind !== "tarinai") return false; + if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) { + window.TarinaiFreezeSystem.freezeTarinai(target, world); + uiCache.grabOrigin = null; + return true; + } + const p = screenToWorldClient(clientX, clientY); + if (!p.inside && uiCache.grabOrigin) { + target.x = uiCache.grabOrigin.x; + target.y = uiCache.grabOrigin.y; + target.vx = uiCache.grabOrigin.vx || 0; + target.vy = uiCache.grabOrigin.vy || 0; + target.thought = "フィールドに戻された"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); + showToast("フィールド外なので元の位置に戻しました。"); + uiCache.grabOrigin = null; + return true; + } + uiCache.grabOrigin = null; + return false; + } const beginTouchGrab = (p, e) => { const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; if (!target) return false; @@ -339,16 +385,19 @@ function bindUI() { uiCache.grabMoved = false; uiCache.grabTarget = target; uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; + rememberGrabOrigin(target); uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldY = p.y; uiCache.grabStartX = e.touches?.[0]?.clientX || 0; uiCache.grabStartY = e.touches?.[0]?.clientY || 0; if (uiCache.grabKind === "tarinai") { target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.state = "idle"; + if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + target.thought = "つままれている"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); } else { - if (target.type === "pushpin" && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); + if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); target.dropTimer = 0; target.dropMax = 0; target.dropImpactDone = true; @@ -468,14 +517,19 @@ function bindUI() { uiCache.grabLastWorldY = p.y; if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } const pad = target.radius || target.r || 16; - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + if (uiCache.grabKind === "tarinai" && !p.inside) { + const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0); + target.x = raw.x; + target.y = raw.y; + } else { + target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); + target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + } target.vx = clamp(dx * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160); if (uiCache.grabKind === "tarinai") { - target.state = "idle"; - target.target = null; - target.sleeping = false; + target.goIdle?.("つままれている"); + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); } else if (target.type === "ball") { target.prevX = target.x - dx; target.prevY = target.y - dy; @@ -513,6 +567,7 @@ function bindUI() { target.target = null; target.targetKey = ""; target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); + finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); } else if (target.type === "ball") { target.prevX = target.x; target.prevY = target.y; @@ -585,16 +640,19 @@ function bindUI() { uiCache.grabMoved = false; uiCache.grabTarget = target; uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; + rememberGrabOrigin(target); uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldY = p.y; uiCache.grabStartX = e.clientX; uiCache.grabStartY = e.clientY; if (uiCache.grabKind === "tarinai") { target.sleeping = false; - if (target.state === "sleep" || target.state === "seek_bed") target.state = "idle"; + if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている"); target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18); + target.thought = "つままれている"; + target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2); } else { - if (target.type === "pushpin" && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); + if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch"); target.dropTimer = 0; target.dropMax = 0; target.dropImpactDone = true; @@ -628,6 +686,7 @@ function bindUI() { target.target = null; target.targetKey = ""; target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); + finishTarinaiGrab(target, e.clientX, e.clientY); } else if (target.type === "ball") { target.prevX = target.x; target.prevY = target.y; @@ -679,14 +738,18 @@ function bindUI() { const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); if (totalMove > 3) uiCache.grabMoved = true; const pad = target.radius || target.r || 16; - target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); - target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + if (uiCache.grabKind === "tarinai" && !p.inside) { + const raw = clientToWorldRaw(e.clientX, e.clientY); + target.x = raw.x; + target.y = raw.y; + } else { + target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); + target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15); + } target.vx = clamp(dx * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160); if (uiCache.grabKind === "tarinai") { - target.state = "idle"; - target.target = null; - target.sleeping = false; + target.goIdle?.("つままれている"); } 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 c5297c8..d2217a4 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -18,6 +18,7 @@ function renderStats() { }; setTextIfChanged(ui.statPop, "pop", values.pop); setTextIfChanged(ui.statDead, "dead", values.dead); + setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "のんびり"); updateColonyHistory(values); drawColonyChart(values); renderSelected(); diff --git a/js/ui_family_data.js b/js/ui_family_data.js index 99f6e7a..c7f883b 100644 --- a/js/ui_family_data.js +++ b/js/ui_family_data.js @@ -112,35 +112,14 @@ function liveFamilyComponents(family) { const relatedIds = lineageRelatedIdSet(family); const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(nodes.map(n => [n.id, new Set()])); - const addEdge = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - for (const p of lineageMutualParentIds(n, family, ids)) addEdge(n.id, p); - for (const c of lineageMutualChildIds(n, family, ids)) addEdge(n.id, c); - } - const seen = new Set(); - const comps = []; - for (const n of nodes.sort(lineageNodeSort)) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - const item = family[id]; - if (item) comp.push(item); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (comp.some(x => x.alive)) comps.push(comp); - } + const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, { + sort: lineageNodeSort, + edgesOf: (n) => [ + ...lineageMutualParentIds(n, family, ids), + ...lineageMutualChildIds(n, family, ids), + ], + filter: comp => comp.some(x => x.alive), + }); comps.sort((a, b) => { const ag = Math.min(...a.map(n => n.generation || 1)); const bg = Math.min(...b.map(n => n.generation || 1)); diff --git a/js/ui_family_render.js b/js/ui_family_render.js index 6cc275c..227e3cf 100644 --- a/js/ui_family_render.js +++ b/js/ui_family_render.js @@ -4,36 +4,14 @@ function lineageSplitByActualEdges(component) { const family = lineageEdgeFamily(world.family || {}); const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); const idSet = new Set(nodes.map(n => n.id)); - const byId = new Map(nodes.map(n => [n.id, n])); - const graph = new Map(nodes.map(n => [n.id, new Set()])); - const connect = (a, b) => { - if (!a || !b || !idSet.has(a) || !idSet.has(b)) return; - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - for (const p of lineageMutualParentIds(n, family, idSet)) connect(n.id, p); - for (const c of lineageMutualChildIds(n, family, idSet)) connect(n.id, c); - } - const seen = new Set(); - const parts = []; - for (const n of nodes.sort(lineageNodeSort)) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const part = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - const node = byId.get(id); - if (node) part.push(node); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (part.length && part.some(x => x.alive)) parts.push(part.sort(lineageNodeSort)); - } + const parts = window.TarinaiFamilyGraph.connectedComponents(nodes, { + sort: lineageNodeSort, + edgesOf: (n) => [ + ...lineageMutualParentIds(n, family, idSet), + ...lineageMutualChildIds(n, family, idSet), + ], + filter: part => part.length && part.some(x => x.alive), + }); return parts; } diff --git a/js/ui_helpers.js b/js/ui_helpers.js new file mode 100644 index 0000000..af3e660 --- /dev/null +++ b/js/ui_helpers.js @@ -0,0 +1,33 @@ +"use strict"; + +(function (global) { + function htmlEscape(value = "") { + return String(value ?? "").replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch] || ch)); + } + + function ensureStyle(id, cssText = "") { + if (!id) return null; + let style = document.getElementById(id); + if (style) return style; + style = document.createElement("style"); + style.id = id; + style.textContent = cssText; + document.head.appendChild(style); + return style; + } + + function createPanel({ id, className = "", html = "", after = null, parent = document.body } = {}) { + if (!id) return null; + let panel = document.getElementById(id); + if (panel) return panel; + panel = document.createElement("section"); + panel.id = id; + if (className) panel.className = className; + panel.innerHTML = html; + if (after?.parentNode) after.parentNode.insertBefore(panel, after.nextSibling); + else parent.appendChild(panel); + return panel; + } + + global.TarinaiUIHelpers = { htmlEscape, ensureStyle, createPanel }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/ui_selected.js b/js/ui_selected.js index b53a748..1faf3f5 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -140,6 +140,18 @@ function personalityDailySnapshot(t) { return `${keyPart}#${causePart}`; } +function recentChangeCausesHtml(t) { + const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : []; + if (!rows.length) return `
  • -\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057
  • `; + return rows.map(entry => { + const timeText = world.clockStringFromTime ? world.clockStringFromTime(entry.time || 0) : formatRecordTime(entry.time || 0); + const reason = String(entry.reason || "\u5909\u5316"); + const target = String(entry.target || "\u72b6\u614b"); + const value = entry.value === "" || entry.value == null ? "" : ` ${entry.value}`; + return `
  • ${escapeHtml(timeText)}${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}
  • `; + }).join(""); +} + function selectedDataCategoryHtml(id, label, bodyHtml) { if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); const collapsed = uiCache.selectedCollapsedCategories.has(id); @@ -192,6 +204,7 @@ function renderSelected() { personalitySnapshot, personalityDailySnapshot(t), ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), + (t.recentChangeCauses || []).slice(0, 8).map(c => `${Math.round(c.time || 0)}:${c.reason || ""}:${c.target || ""}:${c.value ?? ""}`).join("~"), (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") ].join("|"); if (uiCache.selectedSnapshot === snapshot) return; @@ -222,6 +235,7 @@ function renderSelected() { const recordText = quoteNameInRecordText(r.text || "", t.name); return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; + const recentCauseHtml = recentChangeCausesHtml(t); const basicHtml = `
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    @@ -265,7 +279,7 @@ function renderSelected() { ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} - ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

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

      ${recordHtml}
    `)} + ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

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

      ${recentCauseHtml}

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

      ${recordHtml}
    `)}
    `; @@ -279,49 +293,15 @@ function formatRecordTime(time) { } function stateLabel(s) { - return { - idle: "\u5f85\u6a5f", - seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046", - seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046", - play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", - seek_water: "\u6c34\u3092\u63a2\u3059", - zunchi_sick: "\u305a\u3093\u3061\u75c5", - fight_sick: "\u304d\u305a\u3064\u304d\u75c5", - system: "\u305a\u3093\u3061\u75c5", - seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", - follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", - cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f", - cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b", - fight: "\u55a7\u5629\u4e2d", - sleep: "\u7761\u7720\u4e2d", - eat: "\u98df\u4e8b\u4e2d", - panic: "\u30d1\u30cb\u30c3\u30af", - intimidate: "\u5a01\u5687\u4e2d", - birth_ritual: "\u8a95\u751f\u524d", - }[s] || s; + return window.TEXT_CATALOG?.stateLabel?.(s) || s || ""; } function reasonLabel(t) { - if (!t) return "\u306a\u3057"; - const thought = String(t.thought || "").trim(); - if (thought) return thought; - if (t.state === "sleep") return "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b"; - if (t.state === "panic") return "\u6016\u304c\u3063\u3066\u3044\u308b"; - if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; - if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; - if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b"; - if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; - if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; - if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5"; - return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; + return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし"; } function targetLabel(target) { - if (!target) return "\u306a\u3057"; - if (target.name) return target.name; - if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; - if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb"; - return "\u4e0d\u660e"; + return window.TEXT_CATALOG?.targetLabel?.(target) || "なし"; } function fightWinRateLabel(t) { @@ -348,13 +328,15 @@ function setValueIfChanged(el, key, value) { } function escapeHtml(value) { + const helper = window.TarinaiUIHelpers?.htmlEscape; + if (typeof helper === "function" && helper !== escapeHtml) return helper(value); return String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" - }[c])); + }[c] || c)); } function escapeRegExp(value) { diff --git a/js/ui_tools.js b/js/ui_tools.js index f15e2a3..a320d1e 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -2,9 +2,9 @@ const SCALABLE_TOOLS = new Set(scalableToolIds()); -const SIZE_ORDER = ["small", "medium", "large"]; +const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"]; -const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; +const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" }; function renderToolPalette() { if (!ui?.toolPalette || typeof toolCategories !== "function") return; @@ -39,7 +39,7 @@ function renderToolPalette() { function toolSizeFor(tool) { if (!SCALABLE_TOOLS.has(tool)) return ""; - return (world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium"; + return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(world, tool) : ((world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium"); } function cycleToolSize(tool) { diff --git a/js/version.js b/js/version.js index 56122cf..ec564a1 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.20.16"; - const APP_BUILD = "registry-ui"; + const APP_VERSION = "15.24.0"; + const APP_BUILD = "safe-refactor-ui-fix"; 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 79b03f5..a37b0be 100644 --- a/js/world.js +++ b/js/world.js @@ -13,6 +13,7 @@ class World { constructor() { this.toolSize = "medium"; this.toolSizes = {}; this.tarinai = []; + this.frozenTarinai = []; this.items = []; this.ants = []; this.effects = []; @@ -66,6 +67,8 @@ class World { constructor() { this.drawList = []; this.itemCounts = {}; this.effectCounts = {}; + this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } }; + this.lastColonyMoodDay = 0; this.foodSpoilagePenalty = 0; this.foodSpoilageEvents = 0; this.updateItemCounts(); diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 60c9f49..843e9b0 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -65,11 +65,8 @@ const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed"; t.sleeping = false; if (wasSleeping) { - t.state = "panic"; - t.target = { x, y, dead: false }; - t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); - t.fearTimer = Math.max(t.fearTimer, 1.2); - t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f"; + if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); + else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } } t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); t.fearTimer = Math.max(t.fearTimer, 0.58); @@ -82,9 +79,8 @@ t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); - t.fearTimer = Math.max(t.fearTimer, 1.7 + p); - t.state = "panic"; - t.target = { x, y }; + if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); + else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); } let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { @@ -98,6 +94,7 @@ panic: true, target: { x, y }, fearTimer: 1.7 + p, + thought: "爆竹の爆風でパニックになっている", }); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); @@ -105,7 +102,8 @@ t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); - if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(0.15) : 0.15)) t.infectExplosionDisease?.(it); + const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; + if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) t.infectExplosionDisease?.(it); if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { @@ -182,19 +180,20 @@ panic: true, target: { x, y }, fearTimer: 1.7 + q, + thought: "爆発病の爆発に巻き込まれている", }); o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8); o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85); - o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); - o.state = "panic"; - o.target = { x, y }; + if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" }); + else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); } o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70); o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer); this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q); - if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(0.15) : 0.15)) o.infectExplosionDisease?.(t); + const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; + if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance)) o.infectExplosionDisease?.(t); if (!o.dead && Math.random() < 0.45) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { @@ -267,6 +266,27 @@ } }, + spawnBurstZunchi(source, count = 1, pin = null) { + const n = Math.max(1, Math.min(28, Math.floor(count || 1))); + const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5); + const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5); + for (let i = 0; i < n; i++) { + const a = -Math.PI * 0.15 + rand(-0.92, 0.92) + (i / Math.max(1, n - 1) - 0.5) * 1.2; + const it = new Item("zunchi", clamp(sx + rand(-8, 8), 40, this.w - 40), clamp(sy + rand(-8, 8), 40, this.h - 40)); + it.amount = 240; + it.vx = Math.cos(a) * rand(260, 560) + rand(-40, 40); + it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160); + it.stage = "fresh"; + it.burstFromOshibyo = true; + this.items.push(it); + this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1; + } + this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" })); + this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] }); + audio.place?.("zunchi"); + this.rebuildSpatial?.(true); + }, + spawnZunchi(x, y) { const zunchiLimit = CONFIG.zunchiLimit ?? 56; if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { @@ -345,7 +365,7 @@ }, isSleepFurniture(it) { - return Boolean(it && (it.type === "bed" || it.type === "nest_box")); + return Boolean(it && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box"))); }, isTarinaiHiddenInNestBox(t) { @@ -416,9 +436,8 @@ entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; } if (options.panic && entity instanceof Tarinai) { - entity.state = "panic"; - entity.target = options.target || null; - entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); + if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" }); + else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); } } return true; }, @@ -470,9 +489,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 }); - victim.state = "panic"; - victim.target = attacker; - victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); + if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" }); + else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } const dx = victim.x - attacker.x; const dy = victim.y - attacker.y; const d = Math.hypot(dx, dy) || 1; @@ -520,7 +538,7 @@ const total = wins + losses; if (total < 5) return false; const winRate = total > 0 ? wins / total : 0; - if (winRate > 0.5) return false; + if (winRate > 0.30) return false; const oldName = tarinai.formerName || tarinai.name; if (!tarinai.becomeZunchiSlave?.({ fightRecord: true, locked: true })) return false; this.spawnBubble?.(tarinai.x, tarinai.y - tarinai.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); @@ -558,7 +576,9 @@ t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); - t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044"; + t.thought = "げんこつがこわい"; + } else { + t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している"; } t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25)); t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); @@ -617,9 +637,7 @@ return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { - t.state = "idle"; - t.target = null; - t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"; + t.goIdle("ボールが混み合っているので眺めている"); t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); } } @@ -794,13 +812,14 @@ panic: true, target: { x: ball.x, y: ball.y }, fearTimer: 0.95, + thought: "高速のボールにぶつかってパニックになっている", }); this.applyImpactDamage(t, damage, "\u885d\u7a81"); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); - t.state = "panic"; - t.target = { x: ball.x, y: ball.y }; + if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); + else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true }); t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); @@ -906,6 +925,7 @@ panic: true, target: { x: impactor.x, y: impactor.y }, fearTimer: 0.55, + thought: "高速でぶつかって混乱している", }); impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); @@ -913,8 +933,8 @@ if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); for (const t of [target, impactor]) { if (!t || t.dead) continue; - t.state = "panic"; - t.target = { x: impactor.x, y: impactor.y }; + if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); + else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true }); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035); t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); diff --git a/js/world_family_social.js b/js/world_family_social.js index 87b6c04..c210a5a 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -344,7 +344,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.state = "idle"; + if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった"); changed = true; } if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } @@ -374,6 +374,7 @@ this.markFamilyTreeDirty("death"); } } + this.notifyDeathToRelations?.(t, finalReason); this.familyPrunePending = true; }, @@ -382,38 +383,12 @@ const family = this.family || {}; const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); if (!nodes.length) return; - const ids = new Set(nodes.map(n => n.id)); - const graph = new Map(); - const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); }; - const link = (a, b) => { - if (!a || !b || !ids.has(a) || !ids.has(b)) return; - add(a); add(b); - graph.get(a).add(b); - graph.get(b).add(a); - }; - for (const n of nodes) { - add(n.id); - for (const p of n.parents || []) link(n.id, p); - for (const c of n.children || []) link(n.id, c); - } - const seen = new Set(); - const remove = []; - for (const n of nodes) { - if (seen.has(n.id)) continue; - const stack = [n.id]; - const comp = []; - seen.add(n.id); - while (stack.length) { - const id = stack.pop(); - comp.push(id); - for (const next of graph.get(id) || []) { - if (seen.has(next)) continue; - seen.add(next); - stack.push(next); - } - } - if (!comp.some(id => family[id]?.alive)) remove.push(...comp); - } + const components = globalThis.TarinaiFamilyGraph?.connectedComponents + ? globalThis.TarinaiFamilyGraph.connectedComponents(nodes) + : []; + const remove = components.length + ? components.filter(comp => !comp.some(n => family[n.id]?.alive)).flat().map(n => n.id) + : []; if (!remove.length) return; for (const id of remove) delete family[id]; this.familyVersion = (this.familyVersion || 0) + 1; @@ -455,9 +430,8 @@ b.birthRitualRole = 1; a.birthRitualLeader = true; b.birthRitualLeader = false; - a.state = b.state = "birth_ritual"; - a.target = b; - b.target = a; + a.setActionState("birth_ritual", { target: b, reason: "近くで体をゆすっている" }); + b.setActionState("birth_ritual", { target: a, reason: "近くで体をゆすっている" }); a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); @@ -471,7 +445,8 @@ a.birthRitualTimer = b.birthRitualTimer = 0; a.birthPartnerId = b.birthPartnerId = null; a.birthRitualLeader = b.birthRitualLeader = false; - a.state = b.state = "idle"; + a.goIdle("誕生の前ぶれが終わった"); + b.goIdle("誕生の前ぶれが終わった"); if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) { a.birthRitualRole = b.birthRitualRole = 0; return null; @@ -621,8 +596,7 @@ t.defeatedTimer = 0; t.defeatedById = null; t.fightWinnerId = null; - t.target = null; - t.state = "idle"; + t.goIdle(""); } this.startFightMochiIntimidation(a, b); this.startFight(a, b); @@ -695,7 +669,7 @@ target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); actor.intimidateTargetId = target.id; - actor.state = "intimidate"; + actor.setActionState("intimidate", { target, reason: "相手を威嚇している" }); this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { life: 0.42, size: actor.radius * 0.55, @@ -710,8 +684,8 @@ actor.intimidateTimer = 0; actor.intimidateTargetId = null; target.intimidatedTimer = 0; - if (actor.state === "intimidate") actor.state = "idle"; - if (target.state === "panic") target.state = "idle"; + if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した"); + if (target.state === "panic") target.goIdle("威嚇から戻った"); actor.spriteLockUntil = 0; target.spriteLockUntil = 0; this.startFight(actor, target); @@ -721,10 +695,9 @@ target.defeatedById = actor.id; target.fightWinnerId = actor.id; target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); - target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4)); - target.state = "panic"; - target.target = actor; + if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" }); + else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); } this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); target.vy += rand(-18, 18); @@ -741,7 +714,8 @@ if (!a || !b || a.dead || b.dead) return; if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return; if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; - a.state = "fight"; b.state = "fight"; + a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` }); + b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` }); a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); a.nextHeadbutt = this.time + rand(0.08, 0.18); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 1a781b8..45aaf38 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -197,7 +197,7 @@ return null; } if (dropped) { - item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : (item.type === "pushpin" ? 0.48 : 0.58)); + item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : (isPinType(item.type) ? 0.48 : 0.58)); item.dropTimer = item.dropMax; item.dropImpactDone = false; } @@ -218,6 +218,27 @@ for (let i = this.tarinai.length - 1; i >= 0; i--) { if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } } + if (!found) { + let sign = null; + let bestD = Infinity; + for (let i = this.items.length - 1; i >= 0; i--) { + const it = this.items[i]; + if (!it || it.dead || it.type !== "signboard") continue; + const d = distXY(x, y, it.x, it.y); + if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { sign = it; bestD = d; } + } + if (sign) { + const current = String(sign.text || ""); + const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current; + if (next !== null) { + sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 24).trim(); + sign.textEditedAt = this.time || 0; + this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe"); + } + this.selected = null; + return; + } + } this.selected = found; return; } @@ -262,7 +283,7 @@ if (d < hit && d < bestD) { best = it; bestD = d; } } if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; } - if (best.type === "pushpin" && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u753b\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; } + if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; } best.amount = 0; audio.delete?.(); this.compactItems(); @@ -281,7 +302,7 @@ const placed = this.placeItem(rawItem, true); if (!placed) return; const label = toolLabel(itemType); - this.log(itemType === "genkotsu" || itemType === "pushpin" ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); + this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); } }, diff --git a/js/world_update.js b/js/world_update.js index 8ab961d..d7fb326 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -37,6 +37,49 @@ this.foodSpoilageEvents = (this.foodSpoilageEvents || 0) + 1; }, + hourOfDay(timeValue = this.time || 0) { + return ((((timeValue || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24 + 24) % 24; + }, + + colonyMoodDefinition(id = "relaxed") { + const defs = { + calm: { id: "calm", label: "おだやか", description: "社交性プラス", effects: { personality: { sociability: 0.42 } } }, + prickly: { id: "prickly", label: "ピリピリ", description: "攻撃性プラス", effects: { personality: { aggression: 0.46 } } }, + stinky: { id: "stinky", label: "くさい", description: "開放性マイナス", effects: { personality: { openness: -0.44 } } }, + weaklings: { id: "weaklings", label: "ざこだらけ", description: "ずんちどれい以外のストレス減少", effects: { personality: {} } }, + zunda_party: { id: "zunda_party", label: "ずんだぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + ball_party: { id: "ball_party", label: "ぼーるぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } }, + sun_party: { id: "sun_party", label: "おひさまぱーてぃー", description: "日光浴頻度増加", effects: { personality: {} } }, + ant_overrun: { id: "ant_overrun", label: "ありだらけ", description: "アリを見るとパニック", effects: { personality: {} } }, + relaxed: { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } }, + }; + return defs[id] || defs.relaxed; + }, + + evaluateColonyMood() { + const alive = (this.tarinai || []).filter(t => t && !t.dead); + const avgStress = alive.length ? alive.reduce((s, t) => s + (t.stress || 0), 0) / alive.length : 0; + const avgHunger = alive.length ? alive.reduce((s, t) => s + (t.hunger || 0), 0) / alive.length : 0; + const environment = this.environmentScores ? this.environmentScores() : { hygiene: 100 }; + const fights = alive.filter(t => t.state === "fight" || (t.fightTimer || 0) > 0.04 || t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04).length; + const corpseCount = (this.itemCounts?.ant_corpse || 0) + Math.max(0, Math.round((this.deadCount || 0) * 0.5)); + const slaveRatio = alive.length ? alive.filter(t => t.isZunchiSlave).length / alive.length : 0; + const candidates = []; + if (alive.length && avgStress <= 28 && avgHunger <= 34) candidates.push("calm"); + if (fights >= 2 || corpseCount >= 4) candidates.push("prickly"); + if ((environment.hygiene || 0) <= 42) candidates.push("stinky"); + if (alive.length && slaveRatio >= 0.45) candidates.push("weaklings"); + if ((this.itemCounts?.sweet || 0) >= 30) candidates.push("zunda_party"); + if ((this.itemCounts?.ball || 0) >= 10) candidates.push("ball_party"); + if (alive.some(t => t.state === "sunbath" || (t.sunbathTimer || 0) > 0.1)) candidates.push("sun_party"); + if ((this.itemCounts?.ant_nest || 0) >= 1) candidates.push("ant_overrun"); + const nextId = candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : "relaxed"; + this.colonyMood = this.colonyMoodDefinition(nextId); + this.lastColonyMoodDay = this.day || 1; + this.log?.(`今日のコロニーの雰囲気は「${this.colonyMood.label}」。`, "event"); + return this.colonyMood; + }, + observeHighlightKind(t) { const s = this.selected; if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; @@ -112,9 +155,14 @@ dt *= this.speed; dt = Math.min(dt, 0.12 * this.speed); + const prevTime = this.time; this.time += dt; const previousDay = this.day || 1; this.day = Math.floor(this.time / CONFIG.dayLength) + 1; + const prevHour = this.hourOfDay ? this.hourOfDay(prevTime) : 0; + const nowHour = this.hourOfDay ? this.hourOfDay(this.time) : 0; + const crossedColonyEvalHour = (this.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6); + const pendingColonyMoodEvaluation = crossedColonyEvalHour && this.lastColonyMoodDay !== this.day; if (this.day !== previousDay) { for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} }; } diff --git a/js/world_view.js b/js/world_view.js index d18eece..b97db53 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -285,15 +285,11 @@ }, toolSizeFor(type = "") { - const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; - if (!def?.scalable) return "medium"; - return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; + return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(this, type) : ((this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"); }, toolSizeScale(type = "") { - const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; - if (!def?.scalable) return 1; - return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; + return typeof toolSizeScaleFor === "function" ? toolSizeScaleFor(this, type) : ({ small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1); }, applyToolSize(item) { diff --git a/scripts/generate_item_icons.py b/scripts/generate_item_icons.py index 50577df..a124765 100644 --- a/scripts/generate_item_icons.py +++ b/scripts/generate_item_icons.py @@ -37,6 +37,18 @@ def draw_drop(fill, stroke, highlight): d.ellipse([24, 38, 45, 48], fill=highlight) return fn +def draw_water_bowl(palette): + bowl_fill = rgba(palette.get("bowlFill"), (171, 120, 76, 184)) + bowl_stroke = rgba(palette.get("bowlStroke"), (94, 69, 48, 140)) + water_fill = rgba(palette.get("waterFill"), (86, 157, 224, 148)) + water_stroke = rgba(palette.get("waterStroke"), (62, 113, 178, 92)) + highlight = rgba(palette.get("highlight"), (255, 255, 255, 138)) + def fn(d: ImageDraw.ImageDraw) -> None: + d.ellipse([9, 29, 87, 72], fill=bowl_fill, outline=bowl_stroke, width=4) + d.ellipse([19, 35, 77, 62], fill=water_fill, outline=water_stroke, width=3) + d.ellipse([34, 38, 44, 47], fill=highlight) + return fn + def rgba(value, fallback): if not value: @@ -133,6 +145,8 @@ def main() -> None: highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150)) if renderer == "oval_droplet": draw_fn = draw_drop(fill, stroke, highlight) + elif renderer == "water_bowl": + draw_fn = draw_water_bowl(palette) elif renderer == "powder_pile": draw_fn = draw_powder(fill, stroke, highlight) elif renderer == "split_pill": diff --git a/service-worker.js b/service-worker.js index a994443..a062aa1 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,11 +1,11 @@ "use strict"; -const APP_VERSION = "15.20.16"; +const APP_VERSION = "15.24.0"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. const coreScriptNames = [ - "version", "event_bus", "performance_manager", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_selected", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" + "version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools" ]; const CORE_ASSETS = [ "./",