This commit is contained in:
33333-33333 2026-06-22 02:03:19 +09:00
commit e8f1d1db1e
60 changed files with 2417 additions and 612 deletions

View file

@ -1,23 +1,27 @@
# Patch notes # 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. - 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`. - 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 `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. - 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 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`. - 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. - 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 ## 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. - 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 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. - 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 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. - 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. - 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. - 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. - 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`.

View file

@ -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 `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.20.9`. Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.20.16`.
## Edit contract ## Edit contract

View file

@ -1,5 +1,5 @@
{ {
"version": "15.20.16", "version": "15.24.0",
"css": [ "css": [
"css/base.css", "css/base.css",
"css/layout.css", "css/layout.css",
@ -11,6 +11,7 @@
"js/version.js", "js/version.js",
"js/event_bus.js", "js/event_bus.js",
"js/performance_manager.js", "js/performance_manager.js",
"js/registry_base.js",
"js/disease_registry.js", "js/disease_registry.js",
"js/effect_registry.js", "js/effect_registry.js",
"js/sound_pack.js", "js/sound_pack.js",
@ -19,6 +20,7 @@
"js/food_registry.js", "js/food_registry.js",
"js/input_mode_manager.js", "js/input_mode_manager.js",
"js/math.js", "js/math.js",
"js/physics_helpers.js",
"js/assets.js", "js/assets.js",
"js/audio.js", "js/audio.js",
"js/render.js", "js/render.js",
@ -28,6 +30,7 @@
"js/health.js", "js/health.js",
"js/tarinai.js", "js/tarinai.js",
"js/tarinai_identity_social.js", "js/tarinai_identity_social.js",
"js/tarinai_action_state.js",
"js/tarinai_disease_nest.js", "js/tarinai_disease_nest.js",
"js/tarinai_item_effects.js", "js/tarinai_item_effects.js",
"js/tarinai_needs_items.js", "js/tarinai_needs_items.js",
@ -35,6 +38,7 @@
"js/tarinai_render.js", "js/tarinai_render.js",
"js/world.js", "js/world.js",
"js/world_view.js", "js/world_view.js",
"js/family_graph.js",
"js/world_family_social.js", "js/world_family_social.js",
"js/world_combat_effects.js", "js/world_combat_effects.js",
"js/world_environment.js", "js/world_environment.js",
@ -47,7 +51,13 @@
"js/text_catalog.js", "js/text_catalog.js",
"js/ui.js", "js/ui.js",
"js/ui_log.js", "js/ui_log.js",
"js/ui_helpers.js",
"js/ui_selected.js", "js/ui_selected.js",
"js/snapshot_system.js",
"js/save_system.js",
"js/patch_helpers.js",
"js/freeze_system.js",
"js/relation_event_system.js",
"js/ui_layout_dialogs.js", "js/ui_layout_dialogs.js",
"js/ui_tools.js", "js/ui_tools.js",
"js/ui_bind.js", "js/ui_bind.js",
@ -69,4 +79,3 @@
], ],
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list." "notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list."
} }

BIN
assets/objects/oshibyo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,4 +1,4 @@
/* CSS split file; preserve manifest load order. */ /* CSS split file; preserve manifest load order. */
@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=M+PLUS+Rounded+1c:wght@400;500;700&family=Yomogi&display=swap");
:root { :root {
@ -399,14 +399,22 @@ h1 {
.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); } .tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); }
.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); } .tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); }
/* v15.20.11: updated parameter item tool icons */ /* Generated parameter item tool icons. */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.11"); } .tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.11"); } .tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.11"); } .tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.11"); } .tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.11"); } .tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.11"); } .tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.11"); } .tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.11"); } .tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.11"); } .tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
.tool[data-tool="freeze"] { --tool-icon: none; }
.tool[data-tool="freeze"]::before {
content: "🧊";
background: none;
font-size: 24px;
line-height: 1;
text-align: center;
}
.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; }

View file

@ -469,7 +469,7 @@
.selected-category[data-selected-category="family"] { background: rgba(246, 226, 236, 0.28); } .selected-category[data-selected-category="family"] { background: rgba(246, 226, 236, 0.28); }
.selected-category[data-selected-category="records"] { background: rgba(242, 238, 226, 0.30); } .selected-category[data-selected-category="records"] { background: rgba(242, 238, 226, 0.30); }
/* v15.20.6: color-blind safe panel themes and alternating inner tones. */ /* Color-blind safe panel themes and alternating inner tones. */
.card.tool-card, .card.tool-card,
.card.selected-card, .card.selected-card,
.card.colony-card, .card.colony-card,
@ -550,3 +550,74 @@
background: rgba(var(--ui-theme-rgb), 0.11); background: rgba(var(--ui-theme-rgb), 0.11);
border-color: rgba(var(--ui-theme-rgb), 0.28); border-color: rgba(var(--ui-theme-rgb), 0.28);
} }
.save-dialog-panel {
width: min(760px, calc(100vw - 32px));
max-height: min(86vh, 760px);
overflow: auto;
}
.save-slot-list {
display: grid;
gap: 8px;
margin: 14px 0;
}
.save-slot-row {
display: grid;
grid-template-columns: minmax(86px, 0.7fr) minmax(180px, 2fr) auto;
gap: 10px;
align-items: center;
padding: 9px 10px;
border: 1px solid rgba(115, 91, 62, 0.20);
border-radius: 12px;
background: rgba(255, 255, 255, 0.56);
}
.save-slot-title {
font-weight: 800;
color: #4f3928;
}
.save-slot-meta {
color: #6d5a46;
font-size: 13px;
}
.save-slot-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
flex-wrap: wrap;
}
.save-slot-actions .btn[disabled] {
opacity: 0.42;
pointer-events: none;
}
.save-export-panel {
display: grid;
gap: 8px;
margin-top: 14px;
}
.save-export-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.save-hash-text {
width: 100%;
min-height: 118px;
resize: vertical;
padding: 10px;
border: 1px solid rgba(115, 91, 62, 0.26);
border-radius: 12px;
background: rgba(255,255,255,0.78);
color: #3e3023;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
@media (max-width: 640px) {
.save-slot-row {
grid-template-columns: 1fr;
}
.save-slot-actions {
justify-content: flex-start;
}
}

View file

@ -1,4 +1,4 @@
/* CSS split file; preserve manifest load order. */ /* CSS split file; preserve manifest load order. */
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); } .tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); }
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); } .tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); } .tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); }
@ -365,7 +365,7 @@ meter {
} }
/* v15.20.4: clearer tool category grouping. */ /* Clearer tool category grouping. */
.tool-category[data-tool-category="operate"] { --tool-cat-a: rgba(224,239,255,0.82); --tool-cat-b: rgba(203,226,255,0.74); --tool-cat-line: rgba(82,128,190,0.26); } .tool-category[data-tool-category="operate"] { --tool-cat-a: rgba(224,239,255,0.82); --tool-cat-b: rgba(203,226,255,0.74); --tool-cat-line: rgba(82,128,190,0.26); }
.tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); } .tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); }
.tool-category[data-tool-category="medicine"] { --tool-cat-a: rgba(243,231,255,0.84); --tool-cat-b: rgba(230,214,252,0.78); --tool-cat-line: rgba(136,94,184,0.28); } .tool-category[data-tool-category="medicine"] { --tool-cat-a: rgba(243,231,255,0.84); --tool-cat-b: rgba(230,214,252,0.78); --tool-cat-line: rgba(136,94,184,0.28); }

View file

@ -1,4 +1,4 @@
/* CSS split file; preserve manifest load order. */ /* CSS split file; preserve manifest load order. */
.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; } .loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.loading-kicker { font-size: 12px; letter-spacing: .14em; color: rgba(116,96,76,.82); margin-bottom: 6px; } .loading-kicker { font-size: 12px; letter-spacing: .14em; color: rgba(116,96,76,.82); margin-bottom: 6px; }
.loading-title { margin: 0 0 8px; font-size: 28px; color: #3f3429; } .loading-title { margin: 0 0 8px; font-size: 28px; color: #3f3429; }
@ -179,16 +179,16 @@
} }
} }
/* v15.18.0: item icons and mobile input mode */ /* Item icons and mobile input mode */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.11"); } .tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.11"); } .tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.11"); } .tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.11"); } .tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.11"); } .tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.11"); } .tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.11"); } .tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.11"); } .tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.11"); } .tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
.mobile-mode-controls { .mobile-mode-controls {
display: none; display: none;
position: absolute; position: absolute;
@ -225,8 +225,17 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; } body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; }
/* v15.20.11: keep panel themes on narrow layouts. */ /* Keep panel themes on narrow layouts. */
.card.tool-card { --ui-theme-rgb: 0, 114, 178; background: linear-gradient(180deg, rgba(242, 248, 252, 0.94), rgba(232, 243, 250, 0.91)); border-color: rgba(0, 114, 178, 0.24); } .card.tool-card { --ui-theme-rgb: 0, 114, 178; background: linear-gradient(180deg, rgba(242, 248, 252, 0.94), rgba(232, 243, 250, 0.91)); border-color: rgba(0, 114, 178, 0.24); }
.card.selected-card { --ui-theme-rgb: 204, 121, 167; background: linear-gradient(180deg, rgba(253, 245, 250, 0.94), rgba(249, 235, 244, 0.91)); border-color: rgba(204, 121, 167, 0.24); } .card.selected-card { --ui-theme-rgb: 204, 121, 167; background: linear-gradient(180deg, rgba(253, 245, 250, 0.94), rgba(249, 235, 244, 0.91)); border-color: rgba(204, 121, 167, 0.24); }
.card.colony-card { --ui-theme-rgb: 0, 158, 115; background: linear-gradient(180deg, rgba(240, 250, 247, 0.94), rgba(229, 245, 239, 0.91)); border-color: rgba(0, 158, 115, 0.24); } .card.colony-card { --ui-theme-rgb: 0, 158, 115; background: linear-gradient(180deg, rgba(240, 250, 247, 0.94), rgba(229, 245, 239, 0.91)); border-color: rgba(0, 158, 115, 0.24); }
.card.log-card { --ui-theme-rgb: 230, 159, 0; background: linear-gradient(180deg, rgba(255, 249, 236, 0.94), rgba(253, 242, 215, 0.91)); border-color: rgba(230, 159, 0, 0.24); } .card.log-card { --ui-theme-rgb: 230, 159, 0; background: linear-gradient(180deg, rgba(255, 249, 236, 0.94), rgba(253, 242, 215, 0.91)); border-color: rgba(230, 159, 0, 0.24); }
.tool[data-tool="freeze"] { --tool-icon: none; }
.tool[data-tool="freeze"]::before {
content: "🧊";
background: none;
font-size: 24px;
line-height: 1;
text-align: center;
}
.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; }

View file

@ -1,6 +1,6 @@
# Architecture # Architecture
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.20.11`. Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.20.16`.
## Load order ## Load order
@ -38,7 +38,7 @@ The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker h
- `js/input_mode_manager.js`: touch/mobile classification, mobile mode persistence, and gesture capability policy. - `js/input_mode_manager.js`: touch/mobile classification, mobile mode persistence, and gesture capability policy.
- `js/weather_system.js`: weather state transitions and rain-water item spawning. - `js/weather_system.js`: weather state transitions and rain-water item spawning.
- `js/text_catalog.js`: Japanese UI copy/tooltips. Keep JS strings escaped where existing style uses escapes. - `js/text_catalog.js`: Japanese UI copy/tooltips. Keep JS strings escaped where existing style uses escapes.
- `js/effect_registry.js`: item-effect labels, colors, modifiers, exclusivity/removal metadata, behavior callbacks (`onApply`, `onTick`, `onRemove`), and random-effect candidates. - `js/effect_registry.js`: item-effect labels, colors, modifiers, exclusivity/removal metadata, behavior callbacks (`onApply`, `onTick`, `onRemove`), direct eating context, and random-effect candidates.
- `js/sound_pack.js`: canonical sound metadata: IDs, categories, gain multipliers, cooldowns, optional sample paths, and ID-to-category mapping. - `js/sound_pack.js`: canonical sound metadata: IDs, categories, gain multipliers, cooldowns, optional sample paths, and ID-to-category mapping.
- `FIELD_TYPES` in `js/data.js`: cage/garden/park field labels, responsive heights, world scale, starting population, and initial grass. - `FIELD_TYPES` in `js/data.js`: cage/garden/park field labels, responsive heights, world scale, starting population, and initial grass.
@ -46,7 +46,7 @@ The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker h
`main.js` starts asset preload, world bootstrap, animation loop, deferred image loading, service-worker registration. `World.update(dt)` advances time, weather, items, ants, Tarinai, effects, budgets, terrain dirtiness. `render()` draws cached background/terrain, visible entities, weather overlay, cards, HUD, and debug overlays. `main.js` starts asset preload, world bootstrap, animation loop, deferred image loading, service-worker registration. `World.update(dt)` advances time, weather, items, ants, Tarinai, effects, budgets, terrain dirtiness. `render()` draws cached background/terrain, visible entities, weather overlay, cards, HUD, and debug overlays.
World time is `CONFIG.dayLength` seconds per day. Version `15.20.11` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`. World time is `CONFIG.dayLength` seconds per day. Version `15.20.16` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events. Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events.

View file

@ -8,6 +8,7 @@ Canonical helper mapping:
- Weather rain overlay: `drawRain` in `render.js`; diagonal blue line streaks only, never item droplets. - Weather rain overlay: `drawRain` in `render.js`; diagonal blue line streaks only, never item droplets.
- Horizontal item droplet: `drawOvalWaterDropSprite`; used by fallen water items, zunda juice, mercury. Same silhouette; recolor only. - Horizontal item droplet: `drawOvalWaterDropSprite`; used by fallen water items, zunda juice, mercury. Same silhouette; recolor only.
- Water bowl: `water_bowl` descriptor in `js/item_visual_registry.js`; bowl and inner horizontal water palette are registry-owned.
- Sleep drug: `drawSleepTabletSprite`. - Sleep drug: `drawSleepTabletSprite`.
- Laxative: `drawLaxativeTabletSprite`; zunchi-motif tablet. - Laxative: `drawLaxativeTabletSprite`; zunchi-motif tablet.
- Mystery drug: `drawMysteryTabletSprite`; question-mark tablet. - Mystery drug: `drawMysteryTabletSprite`; question-mark tablet.
@ -29,25 +30,25 @@ When adding or renaming a tool, update `TOOL_DEFINITIONS`, `TOOL_CATEGORIES`, `T
## Gameplay data registries ## Gameplay data registries
- Item effects: `TarinaiEffectRegistry` owns labels, display colors, modifiers, exclusivity/removal metadata, behavior callbacks, and mystery-drug candidates. Tarinai item-effect methods are the integration layer and should route apply/tick/remove behavior through the registry. - Item effects: `TarinaiEffectRegistry` owns labels, display colors, modifiers, exclusivity/removal metadata, behavior callbacks, and mystery-drug candidates. Tarinai item-effect methods are the integration layer and should route apply/tick/remove behavior through the registry.
- Diseases: `TarinaiDiseaseRegistry` owns disease flags, labels, symptoms/display text, cure-rule notes, and mystery-drug eligibility. - Diseases: `TarinaiDiseaseRegistry` owns disease flags, labels, symptoms/display text, base infection chances, cure-rule notes, and mystery-drug eligibility.
- Food: `TarinaiFoodRegistry` owns labels, serving classes, decay multipliers, hygiene penalties, and special effect IDs. - Food: `TarinaiFoodRegistry` owns labels, serving classes, nutrition, hunger relief, decay multipliers, hygiene penalties, and special effect IDs. Serving-food eating paths must read nutrition and hunger relief from this registry.
- Weather: `TarinaiWeatherSystem` owns weather transitions and rain-spawned water items. Rain visuals are diagonal line streaks only. Rain-spawned water items, water bowls, zunda juice, and mercury are separate item/object concepts. - Weather: `TarinaiWeatherSystem` owns weather transitions and rain-spawned water items. Rain visuals are diagonal line streaks only. Rain-spawned water items, water bowls, zunda juice, and mercury are separate item/object concepts.
- Ants: `ANT_EXTENSION_HOOKS` documents future ecology slots only. Do not change worker count, queen founding, carrying, or nest removal balance as part of registry/event cleanup. - Ants: `ANT_EXTENSION_HOOKS` documents future ecology slots only. Do not change worker count, queen founding, carrying, or nest removal balance as part of registry/event cleanup.
## UI copy and labels ## UI copy and labels
Most visible UI remains Japanese. Keep user-facing naming consistent: `生活` has been renamed to `精神`; selected Tarinai combat summary shows fight win rate, not cumulative defeats. Most visible UI remains Japanese. Keep user-facing naming consistent: the former lifestyle chart label is now the mental-state label; selected Tarinai combat summary shows fight win rate, not cumulative defeats. The selected Tarinai data panel must show both daily personality-change causes and the rolling `recentChangeCauses` history.
## Bubble text inventory ## Bubble text inventory
Active Tarinai/world bubble texts: Active Tarinai/world bubble text classes:
- Symbols: `!`, `?`, `♡`, `...`. - Symbols: `!`, `?`, `♡`, `...`.
- Sleep: `Zzz...`. - Sleep: `Zzz...`.
- Eating/poop: `もぐ`, `ぶりゅっ`, `ぶりゅ…`. - Eating/poop: short munching and poop sounds.
- Fear/fight: `はぁぁぁ!`, `はうー!`, `はっ...はっ...`. - Fear/fight: short panic, shout, and breathing sounds.
- Disease/recovery: `うぅ…`, `はう`. - Disease/recovery: short weak or relief sounds.
- Random small voice: `はう`, `はうぅ`, `はうっ`, `はぅ`. - Random small voice: short Tarinai voice variants.
- Item effects: `???`. - Item effects: `???`.
When adding bubbles, keep them short; every bubble may also trigger `audio.bubble`. When adding bubbles, keep them short; every bubble may also trigger `audio.bubble`.

View file

@ -1,14 +1,15 @@
<!doctype html> <!doctype html>
<html lang="ja"> <html lang="ja">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title> <title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="stylesheet" href="css/base.css?v=15.20.11" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Ctext y=%2752%27 font-size=%2752%27%3E%E3%81%9F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/layout.css?v=15.20.11" /> <link rel="stylesheet" href="css/base.css?v=15.24.0" />
<link rel="stylesheet" href="css/panel.css?v=15.20.11" /> <link rel="stylesheet" href="css/layout.css?v=15.24.0" />
<link rel="stylesheet" href="css/components.css?v=15.20.11" /> <link rel="stylesheet" href="css/panel.css?v=15.24.0" />
<link rel="stylesheet" href="css/mobile.css?v=15.20.11" /> <link rel="stylesheet" href="css/components.css?v=15.24.0" />
<link rel="stylesheet" href="css/mobile.css?v=15.24.0" />
</head> </head>
<body> <body>
<div id="loadingScreen" class="loading-screen" aria-live="polite"> <div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -45,6 +46,7 @@
</div> </div>
</div> </div>
<button id="creditsBtn" class="btn">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button> <button id="creditsBtn" class="btn">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</button>
<button id="saveBtn" class="btn">セーブ</button>
<button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button> <button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div> </div>
</header> </header>
@ -86,6 +88,7 @@
<div class="stats colony-stats"> <div class="stats colony-stats">
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div> <div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x7D2F;&#x8A08;&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div> <div class="stat"><span>&#x7D2F;&#x8A08;&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
<div class="stat"><span>雰囲気</span><strong id="statColonyMood">のんびり</strong></div>
</div> </div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">精神</button><button data-chart="environment">&#x74B0;&#x5883;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div> <div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">精神</button><button data-chart="environment">&#x74B0;&#x5883;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div> <div id="colonyChartLegend" class="colony-chart-legend"></div>
@ -180,57 +183,66 @@
</div> </div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div> <div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=15.20.11" defer></script> <script src="js/version.js?v=15.24.0" defer></script>
<script src="js/event_bus.js?v=15.20.11" defer></script> <script src="js/event_bus.js?v=15.24.0" defer></script>
<script src="js/performance_manager.js?v=15.20.11" defer></script> <script src="js/performance_manager.js?v=15.24.0" defer></script>
<script src="js/disease_registry.js?v=15.20.11" defer></script> <script src="js/registry_base.js?v=15.24.0" defer></script>
<script src="js/effect_registry.js?v=15.20.11" defer></script> <script src="js/disease_registry.js?v=15.24.0" defer></script>
<script src="js/sound_pack.js?v=15.20.11" defer></script> <script src="js/effect_registry.js?v=15.24.0" defer></script>
<script src="js/data.js?v=15.20.11" defer></script> <script src="js/sound_pack.js?v=15.24.0" defer></script>
<script src="js/item_visual_registry.js?v=15.20.11" defer></script> <script src="js/data.js?v=15.24.0" defer></script>
<script src="js/food_registry.js?v=15.20.11" defer></script> <script src="js/item_visual_registry.js?v=15.24.0" defer></script>
<script src="js/input_mode_manager.js?v=15.20.11" defer></script> <script src="js/food_registry.js?v=15.24.0" defer></script>
<script src="js/math.js?v=15.20.11" defer></script> <script src="js/input_mode_manager.js?v=15.24.0" defer></script>
<script src="js/assets.js?v=15.20.11" defer></script> <script src="js/math.js?v=15.24.0" defer></script>
<script src="js/audio.js?v=15.20.11" defer></script> <script src="js/physics_helpers.js?v=15.24.0" defer></script>
<script src="js/render.js?v=15.20.11" defer></script> <script src="js/assets.js?v=15.24.0" defer></script>
<script src="js/sim_core.js?v=15.20.11" defer></script> <script src="js/audio.js?v=15.24.0" defer></script>
<script src="js/items.js?v=15.20.11" defer></script> <script src="js/render.js?v=15.24.0" defer></script>
<script src="js/ants.js?v=15.20.11" defer></script> <script src="js/sim_core.js?v=15.24.0" defer></script>
<script src="js/health.js?v=15.20.11" defer></script> <script src="js/items.js?v=15.24.0" defer></script>
<script src="js/tarinai.js?v=15.20.11" defer></script> <script src="js/ants.js?v=15.24.0" defer></script>
<script src="js/tarinai_identity_social.js?v=15.20.11" defer></script> <script src="js/health.js?v=15.24.0" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.20.11" defer></script> <script src="js/tarinai.js?v=15.24.0" defer></script>
<script src="js/tarinai_item_effects.js?v=15.20.11" defer></script> <script src="js/tarinai_identity_social.js?v=15.24.0" defer></script>
<script src="js/tarinai_needs_items.js?v=15.20.11" defer></script> <script src="js/tarinai_action_state.js?v=15.24.0" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.20.11" defer></script> <script src="js/tarinai_disease_nest.js?v=15.24.0" defer></script>
<script src="js/tarinai_render.js?v=15.20.11" defer></script> <script src="js/tarinai_item_effects.js?v=15.24.0" defer></script>
<script src="js/world.js?v=15.20.11" defer></script> <script src="js/tarinai_needs_items.js?v=15.24.0" defer></script>
<script src="js/world_view.js?v=15.20.11" defer></script> <script src="js/tarinai_social_move_life.js?v=15.24.0" defer></script>
<script src="js/world_family_social.js?v=15.20.11" defer></script> <script src="js/tarinai_render.js?v=15.24.0" defer></script>
<script src="js/world_combat_effects.js?v=15.20.11" defer></script> <script src="js/world.js?v=15.24.0" defer></script>
<script src="js/world_environment.js?v=15.20.11" defer></script> <script src="js/world_view.js?v=15.24.0" defer></script>
<script src="js/world_spatial_budget.js?v=15.20.11" defer></script> <script src="js/family_graph.js?v=15.24.0" defer></script>
<script src="js/world_ants_system.js?v=15.20.11" defer></script> <script src="js/world_family_social.js?v=15.24.0" defer></script>
<script src="js/weather_system.js?v=15.20.11" defer></script> <script src="js/world_combat_effects.js?v=15.24.0" defer></script>
<script src="js/world_update.js?v=15.20.11" defer></script> <script src="js/world_environment.js?v=15.24.0" defer></script>
<script src="js/world_placement_log.js?v=15.20.11" defer></script> <script src="js/world_spatial_budget.js?v=15.24.0" defer></script>
<script src="js/world_bootstrap.js?v=15.20.11" defer></script> <script src="js/world_ants_system.js?v=15.24.0" defer></script>
<script src="js/text_catalog.js?v=15.20.11" defer></script> <script src="js/weather_system.js?v=15.24.0" defer></script>
<script src="js/ui.js?v=15.20.11" defer></script> <script src="js/world_update.js?v=15.24.0" defer></script>
<script src="js/ui_log.js?v=15.20.11" defer></script> <script src="js/world_placement_log.js?v=15.24.0" defer></script>
<script src="js/ui_selected.js?v=15.20.11" defer></script> <script src="js/world_bootstrap.js?v=15.24.0" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.20.11" defer></script> <script src="js/text_catalog.js?v=15.24.0" defer></script>
<script src="js/ui_tools.js?v=15.20.11" defer></script> <script src="js/ui.js?v=15.24.0" defer></script>
<script src="js/ui_bind.js?v=15.20.11" defer></script> <script src="js/ui_log.js?v=15.24.0" defer></script>
<script src="js/ui_charts.js?v=15.20.11" defer></script> <script src="js/ui_helpers.js?v=15.24.0" defer></script>
<script src="js/ui_family_data.js?v=15.20.11" defer></script> <script src="js/ui_selected.js?v=15.24.0" defer></script>
<script src="js/ui_family_async.js?v=15.20.11" defer></script> <script src="js/snapshot_system.js?v=15.24.0" defer></script>
<script src="js/ui_family_layout.js?v=15.20.11" defer></script> <script src="js/save_system.js?v=15.24.0" defer></script>
<script src="js/ui_family_paths.js?v=15.20.11" defer></script> <script src="js/patch_helpers.js?v=15.24.0" defer></script>
<script src="js/ui_family_render.js?v=15.20.11" defer></script> <script src="js/freeze_system.js?v=15.24.0" defer></script>
<script src="js/main.js?v=15.20.11" defer></script> <script src="js/relation_event_system.js?v=15.24.0" defer></script>
<script src="js/debug_tools.js?v=15.20.11" defer></script> <script src="js/ui_layout_dialogs.js?v=15.24.0" defer></script>
<script src="js/ui_tools.js?v=15.24.0" defer></script>
<script src="js/ui_bind.js?v=15.24.0" defer></script>
<script src="js/ui_charts.js?v=15.24.0" defer></script>
<script src="js/ui_family_data.js?v=15.24.0" defer></script>
<script src="js/ui_family_async.js?v=15.24.0" defer></script>
<script src="js/ui_family_layout.js?v=15.24.0" defer></script>
<script src="js/ui_family_paths.js?v=15.24.0" defer></script>
<script src="js/ui_family_render.js?v=15.24.0" defer></script>
<script src="js/main.js?v=15.24.0" defer></script>
<script src="js/debug_tools.js?v=15.24.0" defer></script>
</body> </body>
</html> </html>

View file

@ -289,15 +289,11 @@ class AntActor {
} }
target.target = null; target.target = null;
if (targetWasSleeping) { if (targetWasSleeping) {
target.sleeping = true; target.startSleeping?.(null, "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b");
target.state = "sleep";
target.sleepTimer = Math.max(target.sleepTimer || 0, 0.8); 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 { } else {
target.state = "panic"; 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" });
target.sleeping = false; 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); }
target.fearTimer = Math.max(target.fearTimer || 0, 0.36);
target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b";
} }
const dHome = distXY(target.x, target.y, home.x, home.y) || 1; const dHome = distXY(target.x, target.y, home.x, home.y) || 1;
if (dHome < Math.max(18, home.r * 0.66)) { if (dHome < Math.max(18, home.r * 0.66)) {

View file

@ -10,8 +10,8 @@ const stainOverlayCache = new Map();
const SPRITE_CACHE_LIMIT = 128; const SPRITE_CACHE_LIMIT = 128;
const STAIN_CACHE_LIMIT = 64; 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 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"]); 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) { function trimCanvasCache(cache, limit = 128) {
while (cache.size > limit) { while (cache.size > limit) {
@ -32,6 +32,13 @@ function getCachedSpriteCanvas(id, img, w, h) {
const qw = quantizedCanvasSize(w); const qw = quantizedCanvasSize(w);
const qh = quantizedCanvasSize(h); const qh = quantizedCanvasSize(h);
const key = `${id}:${qw}x${qh}`; 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); let canvas = scaledSpriteCache.get(key);
if (canvas) return canvas; if (canvas) return canvas;
canvas = document.createElement("canvas"); canvas = document.createElement("canvas");
@ -54,13 +61,7 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) {
const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8))); const bucket = Math.max(1, Math.min(8, Math.ceil(stain * 8)));
const seedBucket = Math.round((seed || 0) * 16); const seedBucket = Math.round((seed || 0) * 16);
const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`; const key = `${id}:${qw}x${qh}:s${bucket}:r${seedBucket}`;
let canvas = stainOverlayCache.get(key); const buildOverlay = (c) => {
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"; if ("imageSmoothingQuality" in c) c.imageSmoothingQuality = "high";
c.clearRect(0, 0, qw, qh); c.clearRect(0, 0, qw, qh);
c.drawImage(img, 0, 0, qw, qh); c.drawImage(img, 0, 0, qw, qh);
@ -81,6 +82,16 @@ function getCachedStainOverlayCanvas(id, img, w, h, stain = 0, seed = 0.5) {
} }
c.globalAlpha = 1; c.globalAlpha = 1;
c.globalCompositeOperation = "source-over"; 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); stainOverlayCache.set(key, canvas);
trimCanvasCache(stainOverlayCache, STAIN_CACHE_LIMIT); trimCanvasCache(stainOverlayCache, STAIN_CACHE_LIMIT);
return canvas; return canvas;

View file

@ -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: "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: "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: "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 = [ const DECOR_ASSETS = [
{ id: "zunchi", path: "assets/objects/zunchi.webp" }, { id: "zunchi", path: "assets/objects/zunchi.webp" },
@ -39,6 +42,8 @@ const DECOR_ASSETS = [
{ id: "genkotsu", path: "assets/objects/genkotsu.webp" }, { id: "genkotsu", path: "assets/objects/genkotsu.webp" },
{ id: "pushpin", path: "assets/objects/pushpin.webp" }, { id: "pushpin", path: "assets/objects/pushpin.webp" },
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.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({ 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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" }, 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_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" }, 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 }, 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 }, 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([ 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: "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: "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: "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: "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", "fence_v", "fence_h"] }, { 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() { function toolCategories() {
@ -120,6 +171,18 @@ function itemAmountFor(type = "", fallback = 80) {
return itemDefinition(type)?.amount ?? fallback; 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() { function scalableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); 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_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 }, 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 }, 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"]; 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"];

View file

@ -45,14 +45,16 @@
} }
}; };
class DiseaseRegistry { class DiseaseRegistry extends global.TarinaiRegistryBase {
constructor(defs) { this.defs = new Map(Object.entries(defs || {})); }
get(id = "") { return this.defs.get(String(id || "")) || null; }
byFlag(flag = "") { return [...this.defs.values()].find(def => def.flag === flag) || null; } byFlag(flag = "") { return [...this.defs.values()].find(def => def.flag === flag) || null; }
label(id = "") { return this.get(id)?.label || id || ""; } label(id = "") { return super.label(id, id || ""); }
color(id = "") { return this.get(id)?.color || "rgba(255,242,160,0.85)"; } 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); } 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); const registry = new DiseaseRegistry(rawDefinitions);

View file

@ -108,7 +108,8 @@
onApply(tarinai, context = {}) { onApply(tarinai, context = {}) {
const scale = context.scale ?? 1; const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); 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; return true;
}, },
}); });
@ -116,12 +117,25 @@
onApply(tarinai, context = {}) { onApply(tarinai, context = {}) {
const scale = context.scale ?? 1; const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); 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; return true;
}, },
}); });
Object.assign(rawDefinitions.sleep_drug, { Object.assign(rawDefinitions.sleep_drug, {
onApply(tarinai, context = {}) { 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; return tarinai.infectSleepDisease?.(context.item || null) || true;
}, },
}); });
@ -180,12 +194,9 @@
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; }, onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
}); });
class EffectRegistry { class EffectRegistry extends global.TarinaiRegistryBase {
constructor(defs) { this.defs = new Map(Object.entries(defs || {})); } label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
get(id = "") { return this.defs.get(String(id || "")) || null; } color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
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)"; }
display(id = "", timer = 0, tarinai = null) { display(id = "", timer = 0, tarinai = null) {
const def = this.get(id); const def = this.get(id);
if (!def) return ""; if (!def) return "";
@ -236,8 +247,8 @@
detail: this.display(id, this.timerValue(tarinai, id), tarinai), detail: this.display(id, this.timerValue(tarinai, id), tarinai),
})); }));
} }
labels() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.label])); } labels() { return super.labels(); }
colors() { return Object.fromEntries([...this.defs.entries()].map(([id, def]) => [id, def.color]).filter(([, color]) => color)); } colors() { return super.colors(); }
randomPool() { randomPool() {
const itemEffects = [...this.defs.entries()] const itemEffects = [...this.defs.entries()]
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease"))) .filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))

49
js/family_graph.js Normal file
View file

@ -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);

View file

@ -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 } ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 0.62, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
}; };
class FoodRegistry { class FoodRegistry extends global.TarinaiRegistryBase {
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 || ""; }
servingTypes() { servingTypes() {
return [...this.defs.entries()] return [...this.defs.entries()]
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest)) .filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
@ -41,9 +37,18 @@
paramEffectTypes() { paramEffectTypes() {
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id); 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; } passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; } 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); const registry = new FoodRegistry(rawDefinitions);

341
js/freeze_system.js Normal file
View file

@ -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 = `
<div class="frozen-panel-title"><span>冷凍個体</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></div>
<div id="frozenList" class="frozen-list"></div>
`;
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 = `<div class="frozen-empty">冷凍中のたりないはいません。</div>`;
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 `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="ドラッグしてフィールドに戻す">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "たりない")}</span><span class="frozen-card-meta">${htmlEscape(gen)}${htmlEscape(losses)}</span></span>
</button>`;
}).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);

View file

@ -95,19 +95,19 @@ const HEALTH = (() => {
const recent = recentDamageCause(t, 20); const recent = recentDamageCause(t, 20);
const fighting = isFightingContext(t, recent); const fighting = isFightingContext(t, recent);
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision; 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); return weakenedDeathReasonFor(t.lastMajorDamageCause);
} }
const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW); const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW);
const traceCanWeaken = weakenedTrace && weakenedTrace !== CAUSES.stress && weakenedTrace !== CAUSES.hunger && weakenedTrace !== CAUSES.lifespan && weakenedTrace !== CAUSES.outOfBounds; 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); return weakenedDeathReasonFor(weakenedTrace);
} }
if (direct && direct !== CAUSES.stress) return direct; if (direct && direct !== CAUSES.stress) return direct;
if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease; 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 (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 (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 (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan;
if (direct) return direct; if (direct) return direct;
if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress; if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress;

View file

@ -29,6 +29,21 @@
"fieldScale": 1, "fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] "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": { "sleep_drug": {
"renderer": "sleep_tablet", "renderer": "sleep_tablet",
"shape": "moon_tablet", "shape": "moon_tablet",

View file

@ -27,6 +27,19 @@ function passiveFoodDecayRate(item) {
return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul; 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 = {}) { function drawOvalWaterDropSprite(ctx, r, palette = {}) {
const fill = palette.fill || "rgba(86, 157, 224, 0.82)"; const fill = palette.fill || "rgba(86, 157, 224, 0.82)";
const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)"; const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)";
@ -48,20 +61,23 @@ function drawOvalWaterDropSprite(ctx, r, palette = {}) {
ctx.restore(); 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.save();
ctx.rotate(-0.18); ctx.rotate(-0.18);
ctx.fillStyle = "#ebe4ff"; ctx.fillStyle = fill;
ctx.strokeStyle = "rgba(74, 60, 144, 0.72)"; ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(1.8, r * 0.14); ctx.lineWidth = Math.max(1.8, r * 0.14);
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2); ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(112, 91, 188, 0.82)"; ctx.fillStyle = accent;
ctx.beginPath(); ctx.beginPath();
ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2); ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.fillStyle = "#ebe4ff"; ctx.fillStyle = fill;
ctx.beginPath(); ctx.beginPath();
ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2); ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@ -186,6 +202,10 @@ class Item {
this.comfort = rand(0.86, 1.14); this.comfort = rand(0.86, 1.14);
this.wear = 0; this.wear = 0;
} }
if (type === "signboard") {
this.text = "";
this.textEditedAt = -999;
}
if (type === "ball") { if (type === "ball") {
this.vx = rand(-5, 5); this.vx = rand(-5, 5);
this.vy = rand(-5, 5); this.vy = rand(-5, 5);
@ -232,7 +252,7 @@ class Item {
this.lingerTimer = 0; this.lingerTimer = 0;
this.lingerDuration = 2.0; this.lingerDuration = 2.0;
} }
if (type === "pushpin") { if (isPinType(type)) {
this.amount = 100; this.amount = 100;
this.vx = rand(-120, 120); this.vx = rand(-120, 120);
this.vy = rand(-90, 90); this.vy = rand(-90, 90);
@ -322,7 +342,7 @@ class Item {
} }
} }
if (this.type === "ball") this.updateBall(dt, worldRef); 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 === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.type === "grass") { if (this.type === "grass") {
this.lifecycleTimer += dt; this.lifecycleTimer += dt;
@ -347,6 +367,9 @@ class Item {
updateZunchiMotion(dt, worldRef) { updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0); const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
this.prevX = this.x; this.prevX = this.x;
this.prevY = this.y; this.prevY = this.y;
this.x += (this.vx || 0) * dt; this.x += (this.vx || 0) * dt;
@ -359,9 +382,45 @@ class Item {
const slow = Math.pow(0.68, dt * 2.4); const slow = Math.pow(0.68, dt * 2.4);
this.vx *= slow; this.vx *= slow;
this.vy *= slow; this.vy *= slow;
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
} }
this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed);
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
}
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) {
if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return;
const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue;
const now = worldRef.time || 0;
if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
this.lastHitTarinaiAt = this.lastHitTarinaiAt || {};
this.lastHitTarinaiAt[t.id] = now;
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
const impulse = clamp(speed * 1.15, 150, 620);
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
else {
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true });
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) { updateBall(dt, worldRef) {
this.prevX = this.x; this.prevX = this.x;
@ -571,29 +630,13 @@ class Item {
this.updateLodgedPushpin(dt, worldRef); this.updateLodgedPushpin(dt, worldRef);
return; return;
} }
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
} else {
this.prevX = this.x; this.prevX = this.x;
this.prevY = this.y; this.prevY = this.y;
const moving = Math.hypot(this.vx || 0, this.vy || 0);
if (moving > 0.05) {
this.x += (this.vx || 0) * dt; this.x += (this.vx || 0) * dt;
this.y += (this.vy || 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); this.tryStickPushpin(worldRef);
} }
@ -617,15 +660,17 @@ class Item {
attachPushpin(t, worldRef, d = null) { attachPushpin(t, worldRef, d = null) {
if (!t || t.dead) return false; if (!t || t.dead) return false;
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) 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 dx = this.x - t.x;
const dy = this.y - t.y; const dy = this.y - t.y;
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy); const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
this.pinState = "lodged"; this.pinState = "lodged";
this.deletable = false; this.deletable = false;
this.pinTargetId = t.id; this.pinTargetId = t.id;
this.pinAttachAngle = Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); this.pinAttachAngle = isOshibyo ? 0.62 : 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.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 = clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinDamageTick = 0; this.pinDamageTick = 0;
this.pinFallCheckTimer = 0; this.pinFallCheckTimer = 0;
this.vx = 0; this.vx = 0;
@ -635,16 +680,25 @@ class Item {
t.stuckPushpinId = this.id; t.stuckPushpinId = this.id;
t.sleeping = false; t.sleeping = false;
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7); t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
if (isOshibyo) {
t.thought = "おしり鋲が刺さってずんちが出なくなった";
worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] });
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
return true;
}
if (t.enterPanic) {
t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
} else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); t.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.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.state = "panic"; 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.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }; t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130));
t.addStress ? t.addStress(18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + 18, 0, 130)); }
if (t.damage) t.damage(6, "\u753b\u92f2"); if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲");
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
this.pinLogAt = worldRef.time || 0; 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)" })); 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; return true;
@ -656,30 +710,37 @@ class Item {
this.detachPushpin(worldRef, "owner-lost"); this.detachPushpin(worldRef, "owner-lost");
return; return;
} }
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id; t.stuckPushpinId = this.id;
this.deletable = false; this.deletable = false;
this.x = t.x + (t.radius || 16) * 0.36; this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36;
this.y = t.y - (t.radius || 16) * 0.04; this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x; this.prevX = this.x;
this.prevY = this.y; this.prevY = this.y;
this.spin = 0.28; this.spin = isOshibyo ? 0.52 : 0.28;
this.vx = t.vx || 0; this.vx = t.vx || 0;
this.vy = t.vy || 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; this.pinDamageTick = (this.pinDamageTick || 0) + dt;
while (this.pinDamageTick >= 0.55) { while (this.pinDamageTick >= 0.55) {
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.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)"); if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
} }
t.sleeping = false; t.sleeping = false;
t.state = "panic"; if (t.enterPanic) t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
t.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }; 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.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); 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"; }
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt; this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
while (this.pinFallCheckTimer >= 1.0) { while (this.pinFallCheckTimer >= 1.0) {
this.pinFallCheckTimer -= 1.0; this.pinFallCheckTimer -= 1.0;
@ -692,7 +753,11 @@ class Item {
detachPushpin(worldRef, reason = "released") { detachPushpin(worldRef, reason = "released") {
const t = worldRef?.tarinai?.find(o => o && o.id === this.pinTargetId) || null; 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 && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
this.pinState = "loose"; this.pinState = "loose";
this.pinTargetId = ""; this.pinTargetId = "";
this.pinDamageTick = 0; 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)" })); 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 (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) { updateGrass(dt, worldRef) {
@ -928,7 +997,7 @@ class Item {
} }
} }
} else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { } 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 palette = visual?.palette || {};
const renderer = visual?.renderer || "split_pill"; const renderer = visual?.renderer || "split_pill";
const meta = { const meta = {
@ -1008,7 +1077,7 @@ class Item {
ctx.restore(); ctx.restore();
} }
} else if (this.type === "sleep_drug") { } 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") { } else if (this.type === "genkotsu") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
const impact = clamp(this.impactFlash || 0, 0, 1); const impact = clamp(this.impactFlash || 0, 0, 1);
@ -1045,9 +1114,10 @@ class Item {
ctx.fill(); ctx.fill();
} }
ctx.restore(); ctx.restore();
} else if (this.type === "pushpin") { } else if (isPinType(this.type)) {
const stuck = this.pinState === "lodged"; 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); const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save(); ctx.save();
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5); if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
@ -1057,7 +1127,7 @@ class Item {
if (img) { if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || (stuck ? 1 : 2.0); 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; const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else if (stuck) { } else if (stuck) {
@ -1090,26 +1160,23 @@ class Item {
} }
ctx.restore(); ctx.restore();
} else if (this.type === "water_bowl") { } else if (this.type === "water_bowl") {
ctx.fillStyle = "rgba(171, 120, 76, 0.72)"; const palette = visualPaletteFor("water_bowl");
ctx.strokeStyle = "rgba(94, 69, 48, 0.55)"; 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.lineWidth = 2.2;
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(86, 157, 224, 0.58)"; ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = "rgba(62, 113, 178, 0.36)"; ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)";
ctx.lineWidth = 1.6; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke(); 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(); 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") { } else if (this.type === "water") {
drawOvalWaterDropSprite(ctx, this.r, { drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
fill: "rgba(86, 157, 224, 0.82)",
stroke: "rgba(45, 101, 168, 0.52)",
highlight: "rgba(255,255,255,0.68)",
});
} else if (this.type === "grass") { } else if (this.type === "grass") {
const growth = this.growth ?? clamp(this.amount / 120, 0, 1); const growth = this.growth ?? clamp(this.amount / 120, 0, 1);
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature"; const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
@ -1193,6 +1260,36 @@ class Item {
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill(); 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") { } else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10); const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a"); ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");

View file

@ -134,7 +134,7 @@ loadImages(null, (done, total) => {
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { 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); navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined);
}); });
} }

16
js/patch_helpers.js Normal file
View file

@ -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);

54
js/physics_helpers.js Normal file
View file

@ -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);

20
js/registry_base.js Normal file
View file

@ -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);

View file

@ -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);

View file

@ -175,7 +175,7 @@ function drawPlacementPreview(ctx, world) {
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); 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.beginPath();
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
ctx.fill(); ctx.fill();
@ -742,7 +742,7 @@ function render() {
} }
for (const it of world.items) { for (const it of world.items) {
if (isCachedTerrainItem(it)) continue; 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); if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
} }
for (const it of world.items) { for (const it of world.items) {
@ -770,7 +770,7 @@ function render() {
if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting);
} }
for (const it of world.items) { 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) { for (const ef of world.effects) {
if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);

223
js/save_system.js Normal file
View file

@ -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 = `
<div class="field-dialog-panel save-dialog-panel">
<h2 id="saveDialogTitle">セーブ / ロード</h2>
<p class="hint">9つのスロットに現在のコロニーを保存できます。ハッシュテキストは別端末や別ブラウザへの移動に使えます。</p>
<div id="saveSlotList" class="save-slot-list"></div>
<div class="save-export-panel">
<div class="save-export-actions">
<button id="saveExportBtn" class="btn" type="button">現在の状態を書き出し</button>
<button id="saveImportBtn" class="btn primary" type="button">ハッシュテキストを読み込み</button>
</div>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="ここにハッシュテキストが表示されます。読み込む場合はここに貼り付けてください。"></textarea>
</div>
<div class="field-dialog-actions">
<button id="saveCloseBtn" class="btn" type="button">閉じる</button>
</div>
</div>`;
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 = `
<div class="save-slot-title">スロット ${i}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot))}</div>
<div class="save-slot-actions">
<button class="btn mini" data-save-slot="${i}" type="button">保存</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>読込</button>
<button class="btn mini danger" data-delete-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>削除</button>
</div>`;
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);

View file

@ -203,6 +203,13 @@ function personalityEffectValue(value) {
return trait.direction * (trait.strength === "strong" ? 1 : 0.5); 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 = "") { function personalityReasonText(reason = "") {
const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim(); const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim();
const map = { const map = {
@ -239,12 +246,12 @@ function getPersonalityTraitTags(tarinai) {
const p = ensurePersonality(tarinai).currentPersonality; const p = ensurePersonality(tarinai).currentPersonality;
const tags = []; const tags = [];
for (const key of PERSONALITY_KEYS) { for (const key of PERSONALITY_KEYS) {
const trait = getTraitStrength(p[key]); const trait = getTraitStrength(effectivePersonalityValue(tarinai, key));
if (!trait.direction) continue; if (!trait.direction) continue;
const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key;
tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label); 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"); if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5");
return tags; return tags;
} }
@ -355,8 +362,7 @@ function adjustPersonality(tarinai, key, delta, reason = "") {
} }
function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) { function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) {
const p = ensurePersonality(tarinai).currentPersonality; const trait = getTraitStrength(effectivePersonalityValue(tarinai, key));
const trait = getTraitStrength(p[key]);
if (trait.direction !== Math.sign(direction || 1)) return false; if (trait.direction !== Math.sign(direction || 1)) return false;
return trait.strength === "strong" || Math.random() < 0.5; return trait.strength === "strong" || Math.random() < 0.5;
} }
@ -570,9 +576,9 @@ class SpatialGrid {
if (!it || it.dead) return; if (!it || it.dead) return;
this.add(this.itemCells, it); this.add(this.itemCells, it);
const type = it.type || ""; 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 (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 (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 (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 (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 (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it);
} }
rebuild(items, tarinai, ants = []) { rebuild(items, tarinai, ants = []) {

257
js/snapshot_system.js Normal file
View file

@ -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);

View file

@ -94,6 +94,10 @@ class Tarinai { constructor(world, opts = {}) {
this.foodReactTimer = opts.foodReactTimer ?? 0; this.foodReactTimer = opts.foodReactTimer ?? 0;
this.surpriseTimer = opts.surpriseTimer ?? 0; this.surpriseTimer = opts.surpriseTimer ?? 0;
this.fearTimer = opts.fearTimer ?? 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.intimidateTimer = opts.intimidateTimer ?? 0;
this.intimidateTargetId = opts.intimidateTargetId || null; this.intimidateTargetId = opts.intimidateTargetId || null;
this.intimidatedTimer = opts.intimidatedTimer ?? 0; this.intimidatedTimer = opts.intimidatedTimer ?? 0;
@ -147,6 +151,7 @@ class Tarinai { constructor(world, opts = {}) {
this.nextHeadbutt = opts.nextHeadbutt ?? 0; this.nextHeadbutt = opts.nextHeadbutt ?? 0;
this.digest = opts.digest ?? rand(0, 2.4); this.digest = opts.digest ?? rand(0, 2.4);
this.poopCount = opts.poopCount ?? 0; this.poopCount = opts.poopCount ?? 0;
this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0;
this.stretchTimer = opts.stretchTimer ?? 0; this.stretchTimer = opts.stretchTimer ?? 0;
this.lastMealColor = opts.lastMealColor || "#f7cf67"; this.lastMealColor = opts.lastMealColor || "#f7cf67";
this.grassEatTimer = opts.grassEatTimer ?? 0; this.grassEatTimer = opts.grassEatTimer ?? 0;

View file

@ -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);

View file

@ -85,9 +85,7 @@
this.sleepDiseaseCooldown = 8; this.sleepDiseaseCooldown = 8;
this.sleepDiseaseAnchorX = this.x; this.sleepDiseaseAnchorX = this.x;
this.sleepDiseaseAnchorY = this.y; this.sleepDiseaseAnchorY = this.y;
this.state = "sleep"; this.startSleeping(null, "ねむり病で眠り続けている");
this.target = null;
this.sleeping = true;
this.fightTimer = 0; this.fightTimer = 0;
this.intimidateTimer = 0; this.intimidateTimer = 0;
this.fightTargetIds = []; this.fightTargetIds = [];
@ -106,10 +104,9 @@
this.sleepDiseaseCooldown = 14; this.sleepDiseaseCooldown = 14;
this.sleepDiseaseAnchorX = null; this.sleepDiseaseAnchorX = null;
this.sleepDiseaseAnchorY = null; this.sleepDiseaseAnchorY = null;
this.state = "idle"; this.goIdle(reason || "ねむり病が治った");
this.sleeping = false; this.sleeping = false;
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10); this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
this.thought = reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f";
audio.heal?.(); audio.heal?.();
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65); this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.65);
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "!", "rgba(72,76,120,0.76)"); this.world?.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); const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy);
if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x;
if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y;
this.state = "sleep"; this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている");
this.sleeping = true;
this.target = null;
this.vx = 0; this.vx = 0;
this.vy = 0; this.vy = 0;
if (beingDraggedByAnt) { if (beingDraggedByAnt) {
@ -233,7 +228,7 @@
} }
this.insideNestBoxId = box.id; this.insideNestBoxId = box.id;
this.target = box; 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; if (this.state === "sleep") this.sleeping = true;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; 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); const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1);
@ -284,7 +279,7 @@
if (currentBox) { if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) {
this.state = "sleep"; this.startSleeping(box, "巣箱の中で眠っている");
this.sleeping = true; this.sleeping = true;
this.target = currentBox; this.target = currentBox;
} }

View file

@ -103,12 +103,11 @@
personalityProfile() { personalityProfile() {
const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; 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 = { const e = {
neuroticism: personalityEffectValue(p.neuroticism), neuroticism: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (ensurePersonality(this).currentPersonality.neuroticism || 0)),
aggression: personalityEffectValue(p.aggression), aggression: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "aggression") : (ensurePersonality(this).currentPersonality.aggression || 0)),
sociability: personalityEffectValue(p.sociability), sociability: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "sociability") : (ensurePersonality(this).currentPersonality.sociability || 0)),
openness: personalityEffectValue(p.openness), openness: personalityEffectValue(typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "openness") : (ensurePersonality(this).currentPersonality.openness || 0)),
}; };
return { return {
...base, ...base,
@ -285,10 +284,8 @@
if (!wasSleeping) return false; if (!wasSleeping) return false;
const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null;
this.sleeping = false; this.sleeping = false;
this.state = "panic"; 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" });
this.target = null; 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.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f";
this.fearTimer = Math.max(this.fearTimer || 0, 0.85);
this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2);
this.nestBoxStayUntil = -Infinity; this.nestBoxStayUntil = -Infinity;
if (this.insideNestBoxId) this.leaveNestBox(box); if (this.insideNestBoxId) this.leaveNestBox(box);
@ -368,7 +365,7 @@
this.targetGiveupKey = key; this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 8; this.targetGiveupUntil = this.world.time + 8;
this.target = null; this.target = null;
this.state = "idle"; this.goIdle?.("柵に阻まれて行き先を変えている");
this.wanderAngle += rand(-1.4, 1.4); this.wanderAngle += rand(-1.4, 1.4);
return; return;
} }
@ -376,9 +373,8 @@
this.targetGiveupKey = key; this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 12; this.targetGiveupUntil = this.world.time + 12;
this.target = null; this.target = null;
this.state = "idle"; this.goIdle?.("辿り着けず、あきらめた");
this.wanderAngle += rand(-2.2, 2.2); this.wanderAngle += rand(-2.2, 2.2);
this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f";
} }
}, },

View file

@ -169,7 +169,7 @@
this.bubble("???", 1.2, ITEM_EFFECT_COLORS.mystery_drug); this.bubble("???", 1.2, ITEM_EFFECT_COLORS.mystery_drug);
this.spawnPowerItemEffect("mystery_drug", 1.0); this.spawnPowerItemEffect("mystery_drug", 1.0);
applied = picks; 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); applied.push(type);
if (type === "laxative") { if (type === "laxative") {
this.spawnPowerItemEffect("laxative", 0.75); this.spawnPowerItemEffect("laxative", 0.75);
@ -257,6 +257,11 @@
forceLaxativePoop() { forceLaxativePoop() {
if (!this.world || this.dead) return; 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 side = this.facingDir ? this.facingDir() : (this.facing || 1);
const x = this.x - side * this.radius * rand(0.72, 0.96); const x = this.x - side * this.radius * rand(0.72, 0.96);
const y = this.y + this.radius * rand(0.25, 0.48); const y = this.y + this.radius * rand(0.25, 0.48);

View file

@ -50,9 +50,11 @@
rawSpriteId() { rawSpriteId() {
if (this.dead) return this.variantSprite("fear"); 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.sleepDisease) return this.variantSprite("sleep");
if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan"; if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan";
if (this.fightDisease) return "stress_dizzy"; 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.isZunchiSlave) return this.zunchiSlaveSpriteId();
if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry";
if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; if ((this.state === "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); 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) { applyCursorContactCare(dt) {
const pointer = this.world?.pointer; const pointer = this.world?.pointer;
if (!pointer?.inside || !this.isAwakeCursorFriendly()) return; if (!pointer?.inside || !this.isAwakeCursorFriendly()) return;
@ -172,11 +238,23 @@
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt);
this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); 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)) { if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt); this.checkLife(dt);
return; return;
} }
this.applyCursorContactCare(dt); 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; const wasFighting = this.fightTimer > 0.04;
this.fightTimer = Math.max(0, this.fightTimer - dt); this.fightTimer = Math.max(0, this.fightTimer - dt);
this.fightCooldown = Math.max(0, this.fightCooldown - dt); this.fightCooldown = Math.max(0, this.fightCooldown - dt);
@ -279,6 +357,7 @@
this.envTimer = 0; this.envTimer = 0;
this.applyEnvironment(envDt); this.applyEnvironment(envDt);
} }
this.updateSunbath(dt);
this.maintainTargetProgress(dt); this.maintainTargetProgress(dt);
this.move(dt); this.move(dt);
if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt); if (this.ammoCollisionKnockback) this.ammoCollisionKnockback(dt);
@ -339,7 +418,7 @@
}, },
isSleepFurniture(target) { 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) { sleepFurnitureLabel(target) {
@ -373,10 +452,16 @@
consumeFoodServing(it, nutrition) { consumeFoodServing(it, nutrition) {
this.ensureFoodServings(it); this.ensureFoodServings(it);
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; 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.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
it.amount = it.foodServingsRemaining; it.amount = it.foodServingsRemaining;
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition }); this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
return nutrition; return value;
},
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {
const relief = window.TarinaiFoodRegistry?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier;
return (Number(nutrition) || 0) * relief;
}, },
sleepSpotFor(bed) { sleepSpotFor(bed) {
@ -432,6 +517,7 @@
if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); 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.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; 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) { resolveNeeds(dt) {
@ -441,22 +527,18 @@
this.sleeping = false; this.sleeping = false;
if (this.birthRitualTimer > 0.04) { if (this.birthRitualTimer > 0.04) {
this.state = "birth_ritual"; this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "近くで体をゆすっている" });
this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null;
this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
return; return;
} }
if (this.intimidateTimer > 0.04) { if (this.intimidateTimer > 0.04) {
this.state = "intimidate"; this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "相手を威嚇している" });
this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null;
this.bubble("!", 1.2); this.bubble("!", 1.2);
this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
return; return;
} }
if (this.fightTimer > 0.04) { 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.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead));
this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
let best = null, bestD = Infinity; let best = null, bestD = Infinity;
@ -466,17 +548,15 @@
const d = dist(this, t); const d = dist(this, t);
if (d < bestD) { best = t; bestD = d; } if (d < bestD) { best = t; bestD = d; }
} }
this.target = best; this.setActionState?.("fight", { target: best, reason: "喧嘩している" });
this.bubble("!", 1.6); this.bubble("!", 1.6);
this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b";
return; return;
} }
if (this.defeatedTimer > 0.04) { if (this.defeatedTimer > 0.04) {
const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead);
this.state = "panic"; 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" });
this.target = this.panicDestination(opponent); else this.setActionState?.("panic", { target: this.panicDestination(opponent), reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", wake: true });
this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b";
if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
return; return;
} else if (this.defeatedById && this.fightTimer <= 0.04) { } else if (this.defeatedById && this.fightTimer <= 0.04) {
@ -485,12 +565,11 @@
} }
if (this.stuckPushpinId) { if (this.stuckPushpinId) {
const pin = this.world.items?.find(it => it && !it.dead && it.id === this.stuckPushpinId && it.type === "pushpin" && it.pinState === "lodged") || null; const pin = this.currentLodgedPin?.() || null;
if (pin) { const pinBehavior = this.currentLodgedPinBehavior?.() || null;
this.state = "panic"; if (pin && pinBehavior?.panicOnAttach) {
this.sleeping = false; 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" });
this.target = this.panicDestination(pin); else { this.setActionState?.("panic", { target: this.panicDestination(pin), reason: "画鋲が刺さってパニックになっている", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.60); }
this.fearTimer = Math.max(this.fearTimer || 0, 0.60);
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24);
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8);
this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); 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; const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null;
if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || [])
this.state = "panic"; .find(o => o && o !== this && !o.dead && !this.world?.isTarinaiHiddenInNestBox?.(o) && o.state === "panic" && (o.fearTimer || 0) > 0.12);
this.target = this.panicDestination(friendNearby.target); if (panicSource && !["panic", "sleep", "seek_bed", "fight", "birth_ritual", "eat", "sunbath"].includes(this.state)) {
this.fearTimer = Math.max(this.fearTimer, 0.32); const d = distXY(this.x, this.y, panicSource.x, panicSource.y);
this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; 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; 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 nearestAnt = null;
let nearestAntDist = Infinity; let nearestAntDist = Infinity;
for (const ant of nearbyAnts) { for (const ant of nearbyAnts) {
@ -524,22 +610,16 @@
if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { 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) { 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. // Intimidation has no effect on ants. This is only a visible reaction.
this.state = "ant_intimidate"; this.setActionState?.("ant_intimidate", { target: nearestAnt, reason: "アリを威嚇している" });
this.target = nearestAnt;
this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); this.bubble("!", 0.9, "rgba(145,68,52,0.78)");
this.thought = "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
return; return;
} }
this.state = "ant_attack"; this.setActionState?.("ant_attack", { target: nearestAnt, reason: "アリを攻撃している" });
this.target = nearestAnt;
this.goodMode = "angry"; this.goodMode = "angry";
this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
return; return;
} }
this.state = "panic"; 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" });
this.target = this.panicDestination(nearestAnt); else this.setActionState?.("panic", { target: this.panicDestination(nearestAnt), reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", wake: true });
this.fearTimer = Math.max(this.fearTimer, 0.34);
this.thought = "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
return; return;
} }
@ -555,10 +635,8 @@
if (d <= Math.max(48, this.radius + opponent.radius + 18)) { if (d <= Math.max(48, this.radius + opponent.radius + 18)) {
if (this.world.startForcedFight?.(this, opponent)) return; if (this.world.startForcedFight?.(this, opponent)) return;
} }
this.state = "seek_friend"; this.setActionState?.("seek_friend", { target: opponent, reason: "けんか餅で相手を探している" });
this.target = opponent;
this.goodMode = "angry"; 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)"); if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)");
return; return;
} }
@ -569,10 +647,8 @@
if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { if (!wasSleeping && (recentRival || (feared && feared.score > 12))) {
const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead);
if (rival && dist(this, rival) < 150) { if (rival && dist(this, rival) < 150) {
this.state = "panic"; 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" });
this.target = this.panicDestination(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.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear);
this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`;
this.world.relationNotice(this.id, rival.id, "avoid", 22); this.world.relationNotice(this.id, rival.id, "avoid", 22);
return; return;
} }
@ -581,15 +657,11 @@
if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { 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); const water = this.world.nearest(this, ["water", "water_bowl"], 760);
if (water && !water.dead) { if (water && !water.dead) {
this.state = "seek_water"; this.setActionState?.("seek_water", { target: water, reason: this.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している" });
this.target = water;
this.thought = this.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
return; return;
} }
if (this.zunchiDisease) { if (this.zunchiDisease) {
this.state = "zunchi_sick"; this.setActionState?.("zunchi_sick", { target: null, reason: "ずんち病で当てもなく彷徨っている" });
this.target = null;
this.thought = "\u305a\u3093\u3061\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4);
return; return;
} }
@ -598,18 +670,14 @@
if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const cureFood = this.world.nearest(this, ["sweet"], 700); const cureFood = this.world.nearest(this, ["sweet"], 700);
if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) {
this.state = "seek_food"; this.setActionState?.("seek_food", { target: cureFood, reason: this.fightDisease ? "きずつき病でずんだ餅を探している" : "爆発病でずんだ餅を探している" });
this.target = cureFood;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); 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; return;
} }
} }
if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
this.state = "fight_sick"; this.setActionState?.("fight_sick", { target: null, reason: "きずつき病で当てもなく彷徨っている" });
this.target = null;
this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4);
return; return;
} }
@ -620,19 +688,15 @@
const specialDist = specialMochi ? distXY(this.x, this.y, specialMochi.x, specialMochi.y) : Infinity; 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); const weakInterest = this.hunger > (wasSleeping ? 68 : 76) && (specialDist < 72 || Math.random() < dt * 0.028);
if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) { if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) {
this.state = "seek_food"; this.setActionState?.("seek_food", { target: specialMochi, reason: `${toolLabel(specialMochi.type)}が少し気になっている` });
this.target = specialMochi;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); this.foodReactTimer = Math.max(this.foodReactTimer, 0.18);
this.thought = `${toolLabel(specialMochi.type)}\u304c\u5c11\u3057\u6c17\u306b\u306a\u3063\u3066\u3044\u308b`;
return; return;
} }
} else if (this.hunger > 20) { } else if (this.hunger > 20) {
const zOnly = this.world.nearest(this, ["zunchi"], 520); const zOnly = this.world.nearest(this, ["zunchi"], 520);
if (zOnly && zOnly.amount > 1.2) { if (zOnly && zOnly.amount > 1.2) {
this.state = "seek_food"; this.setActionState?.("seek_food", { target: zOnly, reason: "ずんちを探している" });
this.target = zOnly;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); this.foodReactTimer = Math.max(this.foodReactTimer, 0.30);
this.thought = "\u305a\u3093\u3061\u3092\u63a2\u3057\u3066\u3044\u308b";
return; return;
} }
} }
@ -642,17 +706,14 @@
const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1));
const zundaReward = this.affection > 8 && this.hunger > 7; const zundaReward = this.affection > 8 && this.hunger > 7;
if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) {
this.state = "seek_food"; this.setActionState?.("seek_food", { target: zunda, reason: "ずんだ餅を探している" });
this.target = zunda;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); this.foodReactTimer = Math.max(this.foodReactTimer, 0.36);
this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b";
return; return;
} }
if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) {
this.state = "panic"; if (this.enterPanic) this.enterPanic({ reason: "\u9003\u3052\u3066\u3044\u308b", fear: 0.65, wake: true, cause: "fear_or_stress" });
this.target = this.panicDestination(); else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u9003\u3052\u3066\u3044\u308b", wake: true });
this.thought = "\u9003\u3052\u3066\u3044\u308b";
return; return;
} }
@ -666,15 +727,11 @@
if (bed && !wasSleeping && this.energy > 18) { if (bed && !wasSleeping && this.energy > 18) {
const spot = this.sleepSpotFor(bed); const spot = this.sleepSpotFor(bed);
if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) {
this.state = "seek_bed"; this.setActionState?.("seek_bed", { target: bed, reason: `${this.sleepFurnitureLabel(bed)}へ向かっている` });
this.target = bed;
this.thought = `${this.sleepFurnitureLabel(bed)}\u3078\u5411\u304b\u3063\u3066\u3044\u308b`;
return; return;
} }
} }
this.state = "sleep"; this.startSleeping?.(bed, bed ? (bed.type === "nest_box" ? "巣箱の中で眠っている" : "ベッドの近くで眠っている") : "眠っている");
this.target = bed;
this.sleeping = true;
if (this.world.time > this.nextSleepBubbleAt) { if (this.world.time > this.nextSleepBubbleAt) {
this.nextSleepBubbleAt = this.world.time + 4.5; this.nextSleepBubbleAt = this.world.time + 4.5;
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); this.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 hostile = this.mood < 38 || this.stress > 58 || this.type === "angry";
const friendly = !hostile && this.isAwakeCursorFriendly(); const friendly = !hostile && this.isAwakeCursorFriendly();
if (hostile) { if (hostile) {
this.state = "cursor_enemy"; this.setActionState?.("cursor_enemy", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルを避けている" });
this.target = { x: pointer.x, y: pointer.y, dead: false };
this.fearTimer = Math.max(this.fearTimer, 0.18); this.fearTimer = Math.max(this.fearTimer, 0.18);
if (this.world.time > this.nextFearBubbleAt) { if (this.world.time > this.nextFearBubbleAt) {
this.nextFearBubbleAt = this.world.time + 2.4; this.nextFearBubbleAt = this.world.time + 2.4;
@ -701,8 +757,7 @@
return; return;
} }
if (friendly) { if (friendly) {
this.state = "cursor_friend"; this.setActionState?.("cursor_friend", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルについていく" });
this.target = { x: pointer.x, y: pointer.y, dead: false };
this.goodMode = "smile"; this.goodMode = "smile";
this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
return; return;
@ -722,10 +777,8 @@
const movingBonus = clamp(ballSpeed / 120, 0, 0.42); const movingBonus = clamp(ballSpeed / 120, 0, 0.42);
const socialBonus = this.loneliness > 48 ? 0.10 : 0; const socialBonus = this.loneliness > 48 ? 0.10 : 0;
if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) {
this.state = "play_ball"; this.setActionState?.("play_ball", { target: ball, reason: openPlay ? "ボールに引き寄せられている" : "転がるものが気になっている" });
this.target = ball;
this.goodMode = "smile"; 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; return;
} }
} }
@ -734,19 +787,15 @@
if (parent && !this.shouldAvoidTarget(parent)) { if (parent && !this.shouldAvoidTarget(parent)) {
const d = dist(this, parent); const d = dist(this, parent);
if (d > 34 || (this.hunger < 78 && this.energy > 24)) { if (d > 34 || (this.hunger < 78 && this.energy > 24)) {
this.state = "follow_parent"; this.setActionState?.("follow_parent", { target: parent, reason: "親についていく" });
this.target = parent;
this.bubble("?", 4.0); this.bubble("?", 4.0);
this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
return; 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)) { 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.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" });
this.target = friendNearby.target;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f";
return; return;
} }
@ -754,10 +803,8 @@
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])];
const item = this.world.nearest(this, itemTypes, 420); const item = this.world.nearest(this, itemTypes, 420);
if (item) { if (item) {
this.state = "seek_food"; this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" });
this.target = item;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); this.foodReactTimer = Math.max(this.foodReactTimer, 0.30);
this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b";
return; return;
} }
} }
@ -767,27 +814,31 @@
if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) {
const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300));
if (other) { if (other) {
this.state = "seek_friend"; this.setActionState?.("seek_friend", { target: other, reason: "仲間を探している" });
this.target = other;
this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
return; return;
} }
} }
if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) {
this.state = "panic"; if (this.enterPanic) this.enterPanic({ reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.55, wake: true, cause: "stress_panic" });
this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
return; return;
} }
if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7);
this.state = "idle"; this.goIdle(`${this.need}がたりない`);
this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`;
}, },
makePoop(force = 1) { makePoop(force = 1) {
this.digest += force; this.digest += force;
if (this.digest < 12.4) return; 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.digest = rand(0.4, 1.6);
this.poopCount += 1; this.poopCount += 1;
const side = this.facingDir(); const side = this.facingDir();
@ -811,13 +862,13 @@
if (d > this.radius + it.r + 12) continue; if (d > this.radius + it.r + 12) continue;
if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { 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) { if (eating > 0) {
this.state = "eat"; this.beginEating(it, `${toolLabel(it.type)}を食べている`);
this.target = it;
this.vx *= Math.pow(0.25, dt * 60); this.vx *= Math.pow(0.25, dt * 60);
this.vy *= 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.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46));
this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012;
if (it.type === "sweet") { 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)) { 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; if (eating <= 0) continue;
this.state = "eat"; this.beginEating(it, `${toolLabel(it.type)}を食べている`);
this.target = it;
this.vx *= Math.pow(0.25, dt * 60); this.vx *= Math.pow(0.25, dt * 60);
this.vy *= 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.recoverHealth(eating * 0.24);
this.affection += eating * 0.04; this.affection += eating * 0.04;
if (it.type === "love_mochi") { const mealColors = {
this.stress = clamp(this.stress - eating * 0.25, 0, 130); love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff",
this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b",
this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f",
this.lastMealColor = "#ff7bab"; };
} else if (it.type === "fight_mochi") { const mealBubbles = {
this.stress = clamp(this.stress + eating * 0.22, 0, 130); love_mochi: ["\u2661", 2.4, "rgba(190,82,132,0.78)"],
this.fearTimer = Math.max(this.fearTimer || 0, 0.32); fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"],
this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"],
this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); };
this.lastMealColor = "#e07c43"; if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it, {
} else if (it.type === "sleep_drug") { source: "eating",
this.stress = clamp(this.stress - eating * 0.10, 0, 130); nutrition: eating,
this.energy = clamp(this.energy - eating * 0.20, 0, 100); silentLog: it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug",
this.fearTimer = Math.max(0, (this.fearTimer || 0) - 0.18); });
this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - 2.5); if (mealBubbles[it.type]) this.bubble(...mealBubbles[it.type]);
this.bubble("Zzz...", 2.3, "rgba(104,84,168,0.78)"); this.lastMealColor = mealColors[it.type] || "#41aa66";
this.lastMealColor = "#b89cff";
const sleepChance = this.diseaseChance ? this.diseaseChance(Math.min(0.98, 0.35 + eating * 0.075)) : Math.min(0.98, 0.35 + eating * 0.075);
if (!this.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) this.infectSleepDisease(it);
} else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type)) {
if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it);
const color = it.type === "protein" ? "#f28d3d" : it.type === "niteropu" ? "#6255a8" : it.type === "ammo" ? "#d0942f" : it.type === "laxative" ? "#788c3b" : it.type === "mercury" ? "#6e95a8" : it.type === "giant_drug" ? "#e47b3a" : it.type === "dwarf_drug" ? "#7773c8" : it.type === "zunda_juice" ? "#61b94f" : "#41aa66";
this.lastMealColor = color;
}
this.eatTimer = Math.max(this.eatTimer, 0.30); this.eatTimer = Math.max(this.eatTimer, 0.30);
this.eatCooldown = rand(0.38, 0.68); this.eatCooldown = rand(0.38, 0.68);
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
@ -899,8 +942,7 @@
if (canBite && it.type === "grass") { 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 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); const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale);
this.state = "eat"; this.beginEating(it, "草を食べている");
this.target = it;
this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.grassEatTimer = Math.max(this.grassEatTimer, 0.22);
this.vx *= Math.pow(0.20, dt * 60); this.vx *= Math.pow(0.20, dt * 60);
this.vy *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60);
@ -926,8 +968,7 @@
if (canBite && it.type === "ant_corpse") { if (canBite && it.type === "ant_corpse") {
const bite = Math.min(it.amount, rand(3.2, 5.4)); const bite = Math.min(it.amount, rand(3.2, 5.4));
if (bite > 0) { if (bite > 0) {
this.state = "eat"; this.beginEating(it, "アリの死骸を食べている");
this.target = it;
this.vx *= Math.pow(0.18, dt * 60); this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite; it.amount -= bite;
@ -956,8 +997,7 @@
if (canZunchiBite) { if (canZunchiBite) {
const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); 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); const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
this.state = "eat"; this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている");
this.target = it;
this.vx *= Math.pow(0.18, dt * 60); this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite; it.amount -= bite;
@ -978,6 +1018,8 @@
} }
if (it.type === "water" || it.type === "water_bowl") { if (it.type === "water" || it.type === "water_bowl") {
this.thought = it.type === "water_bowl" ? "水の皿で水を飲んでいる" : "水を浴びて落ち着いている";
this.target = it;
this.applyWaterEffect(dt, it.type); this.applyWaterEffect(dt, it.type);
if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) {
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 }); audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
@ -988,6 +1030,7 @@
} }
if (it.type === "stone") { if (it.type === "stone") {
if (this.state === "idle" && (this.need === "あたたかさ" || this.stress > 48)) this.thought = "石の近くで落ち着いている";
this.stress -= dt * 0.85; this.stress -= dt * 0.85;
this.energy += dt * 0.18; this.energy += dt * 0.18;
if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; 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) { if (this.energy < 74 + 6 * this.personalityProfile().sleep) {
const occ = this.world.bedOccupancy(it); const occ = this.world.bedOccupancy(it);
const comfort = this.world.bedComfort(it); const comfort = this.world.bedComfort(it);
this.state = "sleep"; this.startSleeping?.(it, it.type === "nest_box" ? "巣箱の中で休んでいる" : "寝床の近くで休んでいる");
this.target = it;
this.sleeping = true;
if (this.world.time > this.nextSleepBubbleAt) { if (this.world.time > this.nextSleepBubbleAt) {
this.nextSleepBubbleAt = this.world.time + 4.5; this.nextSleepBubbleAt = this.world.time + 4.5;
const crowded = occ > 5; const crowded = occ > 5;

View file

@ -34,9 +34,14 @@
const ny = (nonSlave.y - slave.y) / d; const ny = (nonSlave.y - slave.y) / d;
nonSlave.vx += nx * dt * 34; nonSlave.vx += nx * dt * 34;
nonSlave.vy += ny * dt * 34; nonSlave.vy += ny * dt * 34;
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.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear);
nonSlave.target = slave; nonSlave.target = slave;
if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") nonSlave.state = "panic"; nonSlave.thought = "ずんちどれいを怖がっている";
}
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "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); 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 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); 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") { 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 bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) * (this.target?.type === "nest_box" ? 1.55 : 1) : 0.72;
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const 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)) ? (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)); : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20));
if (reachedSleepFurniture) { if (reachedSleepFurniture) {
this.state = "sleep"; this.startSleeping?.(this.target, this.target.type === "nest_box" ? "巣箱の中で休んでいる" : "眠る場所に着いたので休んでいる");
this.sleeping = true;
if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); if (this.target.type === "nest_box") this.enterNestBox(this.target, dt);
return; return;
} }

View file

@ -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; window.TEXT_CATALOG = TEXT_CATALOG;

View file

@ -11,6 +11,7 @@ const ui = {
soundPanel: document.getElementById("soundPanel"), soundPanel: document.getElementById("soundPanel"),
soundMasterToggle: document.getElementById("soundMasterToggle"), soundMasterToggle: document.getElementById("soundMasterToggle"),
creditsBtn: document.getElementById("creditsBtn"), creditsBtn: document.getElementById("creditsBtn"),
saveBtn: document.getElementById("saveBtn"),
resetBtn: document.getElementById("resetBtn"), resetBtn: document.getElementById("resetBtn"),
creditsDialog: document.getElementById("creditsDialog"), creditsDialog: document.getElementById("creditsDialog"),
creditsCloseBtn: document.getElementById("creditsCloseBtn"), creditsCloseBtn: document.getElementById("creditsCloseBtn"),
@ -29,6 +30,7 @@ const ui = {
toast: document.getElementById("toast"), toast: document.getElementById("toast"),
statPop: document.getElementById("statPop"), statPop: document.getElementById("statPop"),
statDead: document.getElementById("statDead"), statDead: document.getElementById("statDead"),
statColonyMood: document.getElementById("statColonyMood"),
colonyChart: document.getElementById("colonyChart"), colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"), colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"), archiveContent: document.getElementById("archiveContent"),

View file

@ -7,6 +7,8 @@ function bindUI() {
syncAudioControls(); syncAudioControls();
applyToolTips(); applyToolTips();
renderEcologyCards(); renderEcologyCards();
window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
syncToolSizeBadges(); syncToolSizeBadges();
for (const toggle of document.querySelectorAll(".panel-card-toggle")) { for (const toggle of document.querySelectorAll(".panel-card-toggle")) {
@ -266,7 +268,6 @@ function bindUI() {
transferEdgeScroll(ui.log, rightPanel); transferEdgeScroll(ui.log, rightPanel);
const inputModeManager = window.TarinaiInputMode; const inputModeManager = window.TarinaiInputMode;
const isTouchDevice = () => Boolean(inputModeManager?.snapshot?.().isTouchDevice || matchMedia?.("(pointer: coarse)")?.matches || navigator.maxTouchPoints > 0);
const setMobileInputMode = (mode = "auto") => { const setMobileInputMode = (mode = "auto") => {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto"); mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode; uiCache.mobileInputMode = mode;
@ -317,6 +318,16 @@ function bindUI() {
const sy = clientY - rect.top; const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 }; 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) => { const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => {
if (!world.setFieldZoom) return false; if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@ -331,6 +342,41 @@ function bindUI() {
world.clampCamera?.(); world.clampCamera?.();
return true; 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 beginTouchGrab = (p, e) => {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) return false; if (!target) return false;
@ -339,16 +385,19 @@ function bindUI() {
uiCache.grabMoved = false; uiCache.grabMoved = false;
uiCache.grabTarget = target; uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y; uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.touches?.[0]?.clientX || 0; uiCache.grabStartX = e.touches?.[0]?.clientX || 0;
uiCache.grabStartY = e.touches?.[0]?.clientY || 0; uiCache.grabStartY = e.touches?.[0]?.clientY || 0;
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
target.sleeping = false; 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.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else { } 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.dropTimer = 0;
target.dropMax = 0; target.dropMax = 0;
target.dropImpactDone = true; target.dropImpactDone = true;
@ -468,14 +517,19 @@ function bindUI() {
uiCache.grabLastWorldY = p.y; uiCache.grabLastWorldY = p.y;
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; } if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
const pad = target.radius || target.r || 16; const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); target.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.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160); target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
target.state = "idle"; target.goIdle?.("つままれている");
target.target = null; target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
target.sleeping = false;
} else if (target.type === "ball") { } else if (target.type === "ball") {
target.prevX = target.x - dx; target.prevX = target.x - dx;
target.prevY = target.y - dy; target.prevY = target.y - dy;
@ -513,6 +567,7 @@ function bindUI() {
target.target = null; target.target = null;
target.targetKey = ""; target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); 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") { } else if (target.type === "ball") {
target.prevX = target.x; target.prevX = target.x;
target.prevY = target.y; target.prevY = target.y;
@ -585,16 +640,19 @@ function bindUI() {
uiCache.grabMoved = false; uiCache.grabMoved = false;
uiCache.grabTarget = target; uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item"; uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x; uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y; uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.clientX; uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY; uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
target.sleeping = false; 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.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else { } 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.dropTimer = 0;
target.dropMax = 0; target.dropMax = 0;
target.dropImpactDone = true; target.dropImpactDone = true;
@ -628,6 +686,7 @@ function bindUI() {
target.target = null; target.target = null;
target.targetKey = ""; target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15); target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.clientX, e.clientY);
} else if (target.type === "ball") { } else if (target.type === "ball") {
target.prevX = target.x; target.prevX = target.x;
target.prevY = target.y; 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)); const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true; if (totalMove > 3) uiCache.grabMoved = true;
const pad = target.radius || target.r || 16; const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.clientX, e.clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15); target.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.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160); target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
target.state = "idle"; target.goIdle?.("つままれている");
target.target = null;
target.sleeping = false;
} else if (target.type === "ball") { } else if (target.type === "ball") {
target.prevX = target.x - dx; target.prevX = target.x - dx;
target.prevY = target.y - dy; target.prevY = target.y - dy;

View file

@ -18,6 +18,7 @@ function renderStats() {
}; };
setTextIfChanged(ui.statPop, "pop", values.pop); setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statDead, "dead", values.dead); setTextIfChanged(ui.statDead, "dead", values.dead);
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "のんびり");
updateColonyHistory(values); updateColonyHistory(values);
drawColonyChart(values); drawColonyChart(values);
renderSelected(); renderSelected();

View file

@ -112,35 +112,14 @@ function liveFamilyComponents(family) {
const relatedIds = lineageRelatedIdSet(family); const relatedIds = lineageRelatedIdSet(family);
const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean);
const ids = new Set(nodes.map(n => n.id)); const ids = new Set(nodes.map(n => n.id));
const graph = new Map(nodes.map(n => [n.id, new Set()])); const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, {
const addEdge = (a, b) => { sort: lineageNodeSort,
if (!a || !b || !ids.has(a) || !ids.has(b)) return; edgesOf: (n) => [
graph.get(a).add(b); ...lineageMutualParentIds(n, family, ids),
graph.get(b).add(a); ...lineageMutualChildIds(n, family, ids),
}; ],
for (const n of nodes) { filter: comp => comp.some(x => x.alive),
for (const p of lineageMutualParentIds(n, family, ids)) addEdge(n.id, p); });
for (const c of lineageMutualChildIds(n, family, ids)) addEdge(n.id, c);
}
const seen = new Set();
const comps = [];
for (const n of nodes.sort(lineageNodeSort)) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const comp = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
const item = family[id];
if (item) comp.push(item);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (comp.some(x => x.alive)) comps.push(comp);
}
comps.sort((a, b) => { comps.sort((a, b) => {
const ag = Math.min(...a.map(n => n.generation || 1)); const ag = Math.min(...a.map(n => n.generation || 1));
const bg = Math.min(...b.map(n => n.generation || 1)); const bg = Math.min(...b.map(n => n.generation || 1));

View file

@ -4,36 +4,14 @@ function lineageSplitByActualEdges(component) {
const family = lineageEdgeFamily(world.family || {}); const family = lineageEdgeFamily(world.family || {});
const nodes = Array.from(new Map(lineageCleanComponent(component, family).filter(Boolean).map(n => [n.id, n])).values()); 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 idSet = new Set(nodes.map(n => n.id));
const byId = new Map(nodes.map(n => [n.id, n])); const parts = window.TarinaiFamilyGraph.connectedComponents(nodes, {
const graph = new Map(nodes.map(n => [n.id, new Set()])); sort: lineageNodeSort,
const connect = (a, b) => { edgesOf: (n) => [
if (!a || !b || !idSet.has(a) || !idSet.has(b)) return; ...lineageMutualParentIds(n, family, idSet),
graph.get(a).add(b); ...lineageMutualChildIds(n, family, idSet),
graph.get(b).add(a); ],
}; filter: part => part.length && part.some(x => x.alive),
for (const n of nodes) { });
for (const p of lineageMutualParentIds(n, family, idSet)) connect(n.id, p);
for (const c of lineageMutualChildIds(n, family, idSet)) connect(n.id, c);
}
const seen = new Set();
const parts = [];
for (const n of nodes.sort(lineageNodeSort)) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const part = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
const node = byId.get(id);
if (node) part.push(node);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (part.length && part.some(x => x.alive)) parts.push(part.sort(lineageNodeSort));
}
return parts; return parts;
} }

33
js/ui_helpers.js Normal file
View file

@ -0,0 +1,33 @@
"use strict";
(function (global) {
function htmlEscape(value = "") {
return String(value ?? "").replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[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);

View file

@ -140,6 +140,18 @@ function personalityDailySnapshot(t) {
return `${keyPart}#${causePart}`; return `${keyPart}#${causePart}`;
} }
function recentChangeCausesHtml(t) {
const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : [];
if (!rows.length) return `<li><span>-</span><strong>\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057</strong></li>`;
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 `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}</strong></li>`;
}).join("");
}
function selectedDataCategoryHtml(id, label, bodyHtml) { function selectedDataCategoryHtml(id, label, bodyHtml) {
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set(); if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
const collapsed = uiCache.selectedCollapsedCategories.has(id); const collapsed = uiCache.selectedCollapsedCategories.has(id);
@ -192,6 +204,7 @@ function renderSelected() {
personalitySnapshot, personalityDailySnapshot(t), personalitySnapshot, personalityDailySnapshot(t),
["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100),
itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), 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("~") (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~")
].join("|"); ].join("|");
if (uiCache.selectedSnapshot === snapshot) return; if (uiCache.selectedSnapshot === snapshot) return;
@ -222,6 +235,7 @@ function renderSelected() {
const recordText = quoteNameInRecordText(r.text || "", t.name); const recordText = quoteNameInRecordText(r.text || "", t.name);
return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(recordText)}</strong></li>`; return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(recordText)}</strong></li>`;
}).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`; }).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
const recentCauseHtml = recentChangeCausesHtml(t);
const basicHtml = ` const basicHtml = `
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div> <div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div> <div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
@ -265,7 +279,7 @@ function renderSelected() {
${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)}
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)}
${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)}
${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><h3>\u500b\u4f53\u5225\u306e\u8a18\u9332</h3><ol>${recordHtml}</ol></div>`)} ${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><h3>\u6700\u8fd1\u306e\u5909\u5316\u539f\u56e0</h3><ol class="selected-change-list">${recentCauseHtml}</ol><h3>\u500b\u4f53\u5225\u306e\u8a18\u9332</h3><ol>${recordHtml}</ol></div>`)}
</div> </div>
<div class="selected-actions"><button type="button" class="favorite-toggle ${t.favorite ? "active" : ""}" data-selected-action="favorite">${t.favorite ? "\u2605 \u304a\u6c17\u306b\u5165\u308a\u89e3\u9664" : "\u2606 \u304a\u6c17\u306b\u5165\u308a"}</button></div> <div class="selected-actions"><button type="button" class="favorite-toggle ${t.favorite ? "active" : ""}" data-selected-action="favorite">${t.favorite ? "\u2605 \u304a\u6c17\u306b\u5165\u308a\u89e3\u9664" : "\u2606 \u304a\u6c17\u306b\u5165\u308a"}</button></div>
`; `;
@ -279,49 +293,15 @@ function formatRecordTime(time) {
} }
function stateLabel(s) { function stateLabel(s) {
return { return window.TEXT_CATALOG?.stateLabel?.(s) || s || "";
idle: "\u5f85\u6a5f",
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
system: "\u305a\u3093\u3061\u75c5",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
fight: "\u55a7\u5629\u4e2d",
sleep: "\u7761\u7720\u4e2d",
eat: "\u98df\u4e8b\u4e2d",
panic: "\u30d1\u30cb\u30c3\u30af",
intimidate: "\u5a01\u5687\u4e2d",
birth_ritual: "\u8a95\u751f\u524d",
}[s] || s;
} }
function reasonLabel(t) { function reasonLabel(t) {
if (!t) return "\u306a\u3057"; return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし";
const thought = String(t.thought || "").trim();
if (thought) return thought;
if (t.state === "sleep") return "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
if (t.state === "panic") return "\u6016\u304c\u3063\u3066\u3044\u308b";
if (t.state === "intimidate") return "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5";
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
} }
function targetLabel(target) { function targetLabel(target) {
if (!target) return "\u306a\u3057"; return window.TEXT_CATALOG?.targetLabel?.(target) || "なし";
if (target.name) return target.name;
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", genkotsu: "\u3052\u3093\u3053\u3064", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
return "\u4e0d\u660e";
} }
function fightWinRateLabel(t) { function fightWinRateLabel(t) {
@ -348,13 +328,15 @@ function setValueIfChanged(el, key, value) {
} }
function escapeHtml(value) { function escapeHtml(value) {
const helper = window.TarinaiUIHelpers?.htmlEscape;
if (typeof helper === "function" && helper !== escapeHtml) return helper(value);
return String(value ?? "").replace(/[&<>"']/g, c => ({ return String(value ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "&": "&amp;",
"<": "&lt;", "<": "&lt;",
">": "&gt;", ">": "&gt;",
'"': "&quot;", '"': "&quot;",
"'": "&#39;" "'": "&#39;"
}[c])); }[c] || c));
} }
function escapeRegExp(value) { function escapeRegExp(value) {

View file

@ -2,9 +2,9 @@
const SCALABLE_TOOLS = new Set(scalableToolIds()); 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() { function renderToolPalette() {
if (!ui?.toolPalette || typeof toolCategories !== "function") return; if (!ui?.toolPalette || typeof toolCategories !== "function") return;
@ -39,7 +39,7 @@ function renderToolPalette() {
function toolSizeFor(tool) { function toolSizeFor(tool) {
if (!SCALABLE_TOOLS.has(tool)) return ""; 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) { function cycleToolSize(tool) {

View file

@ -1,8 +1,8 @@
"use strict"; "use strict";
(function () { (function () {
const APP_VERSION = "15.20.16"; const APP_VERSION = "15.24.0";
const APP_BUILD = "registry-ui"; const APP_BUILD = "safe-refactor-ui-fix";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -13,6 +13,7 @@ class World { constructor() {
this.toolSize = "medium"; this.toolSize = "medium";
this.toolSizes = {}; this.toolSizes = {};
this.tarinai = []; this.tarinai = [];
this.frozenTarinai = [];
this.items = []; this.items = [];
this.ants = []; this.ants = [];
this.effects = []; this.effects = [];
@ -66,6 +67,8 @@ class World { constructor() {
this.drawList = []; this.drawList = [];
this.itemCounts = {}; this.itemCounts = {};
this.effectCounts = {}; this.effectCounts = {};
this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } };
this.lastColonyMoodDay = 0;
this.foodSpoilagePenalty = 0; this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0; this.foodSpoilageEvents = 0;
this.updateItemCounts(); this.updateItemCounts();

View file

@ -65,11 +65,8 @@
const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed"; const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed";
t.sleeping = false; t.sleeping = false;
if (wasSleeping) { if (wasSleeping) {
t.state = "panic"; 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" });
t.target = { x, y, dead: false }; 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.surpriseTimer = Math.max(t.surpriseTimer, 0.95);
t.fearTimer = Math.max(t.fearTimer, 1.2);
t.thought = "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f";
} }
t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); t.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); 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.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.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
t.fearTimer = Math.max(t.fearTimer, 1.7 + p); if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
t.state = "panic"; else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); }
t.target = { x, y };
let nx = dx / d; let nx = dx / d;
let ny = dy / d; let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
@ -98,6 +94,7 @@
panic: true, panic: true,
target: { x, y }, target: { x, y },
fearTimer: 1.7 + p, fearTimer: 1.7 + p,
thought: "爆竹の爆風でパニックになっている",
}); });
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); 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.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); 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)"); 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", { this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
@ -182,19 +180,20 @@
panic: true, panic: true,
target: { x, y }, target: { x, y },
fearTimer: 1.7 + q, fearTimer: 1.7 + q,
thought: "爆発病の爆発に巻き込まれている",
}); });
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8); o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8);
o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85); o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85);
o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
o.state = "panic"; else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); }
o.target = { x, y };
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); 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.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70);
o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer); o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer);
this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q); 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)"); 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", { 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) { spawnZunchi(x, y) {
const zunchiLimit = CONFIG.zunchiLimit ?? 56; const zunchiLimit = CONFIG.zunchiLimit ?? 56;
if ((this.itemCounts.zunchi || 0) >= zunchiLimit) { if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
@ -345,7 +365,7 @@
}, },
isSleepFurniture(it) { 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) { isTarinaiHiddenInNestBox(t) {
@ -416,9 +436,8 @@
entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy;
} }
if (options.panic && entity instanceof Tarinai) { if (options.panic && entity instanceof Tarinai) {
entity.state = "panic"; if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
entity.target = options.target || null; else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55);
} }
return true; return true;
}, },
@ -470,9 +489,8 @@
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42));
attacker.fightTimer = Math.min(attacker.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 }); this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
victim.state = "panic"; if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" });
victim.target = attacker; else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1));
const dx = victim.x - attacker.x; const dx = victim.x - attacker.x;
const dy = victim.y - attacker.y; const dy = victim.y - attacker.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
@ -520,7 +538,7 @@
const total = wins + losses; const total = wins + losses;
if (total < 5) return false; if (total < 5) return false;
const winRate = total > 0 ? wins / total : 0; 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; const oldName = tarinai.formerName || tarinai.name;
if (!tarinai.becomeZunchiSlave?.({ fightRecord: true, locked: true })) return false; 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)"); 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.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); 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.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.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26)); t.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); return dist(a, ball) + ap - (dist(b, ball) + bp);
}); });
for (const t of arr.slice(5)) { for (const t of arr.slice(5)) {
t.state = "idle"; t.goIdle("ボールが混み合っているので眺めている");
t.target = null;
t.thought = "\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b";
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
} }
} }
@ -794,13 +812,14 @@
panic: true, panic: true,
target: { x: ball.x, y: ball.y }, target: { x: ball.x, y: ball.y },
fearTimer: 0.95, fearTimer: 0.95,
thought: "高速のボールにぶつかってパニックになっている",
}); });
this.applyImpactDamage(t, damage, "\u885d\u7a81"); this.applyImpactDamage(t, damage, "\u885d\u7a81");
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); 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); 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.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130));
t.state = "panic"; if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
t.target = { x: ball.x, y: ball.y }; 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.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46); t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer); t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
@ -906,6 +925,7 @@
panic: true, panic: true,
target: { x: impactor.x, y: impactor.y }, target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55, fearTimer: 0.55,
thought: "高速でぶつかって混乱している",
}); });
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * 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"); if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) { for (const t of [target, impactor]) {
if (!t || t.dead) continue; if (!t || t.dead) continue;
t.state = "panic"; if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
t.target = { x: impactor.x, y: impactor.y }; 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.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025); t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);

View file

@ -344,7 +344,7 @@
if (!other || other === dead || other.dead) continue; if (!other || other === dead || other.dead) continue;
if (other.target === dead || other.target?.id === deadId) { if (other.target === dead || other.target?.id === deadId) {
other.target = null; 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; changed = true;
} }
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; } if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
@ -374,6 +374,7 @@
this.markFamilyTreeDirty("death"); this.markFamilyTreeDirty("death");
} }
} }
this.notifyDeathToRelations?.(t, finalReason);
this.familyPrunePending = true; this.familyPrunePending = true;
}, },
@ -382,38 +383,12 @@
const family = this.family || {}; const family = this.family || {};
const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length); const nodes = Object.values(family).filter(Boolean).filter(n => n.hasPaired || (n.parents || []).length || (n.children || []).length);
if (!nodes.length) return; if (!nodes.length) return;
const ids = new Set(nodes.map(n => n.id)); const components = globalThis.TarinaiFamilyGraph?.connectedComponents
const graph = new Map(); ? globalThis.TarinaiFamilyGraph.connectedComponents(nodes)
const add = (id) => { if (!graph.has(id)) graph.set(id, new Set()); }; : [];
const link = (a, b) => { const remove = components.length
if (!a || !b || !ids.has(a) || !ids.has(b)) return; ? components.filter(comp => !comp.some(n => family[n.id]?.alive)).flat().map(n => n.id)
add(a); add(b); : [];
graph.get(a).add(b);
graph.get(b).add(a);
};
for (const n of nodes) {
add(n.id);
for (const p of n.parents || []) link(n.id, p);
for (const c of n.children || []) link(n.id, c);
}
const seen = new Set();
const remove = [];
for (const n of nodes) {
if (seen.has(n.id)) continue;
const stack = [n.id];
const comp = [];
seen.add(n.id);
while (stack.length) {
const id = stack.pop();
comp.push(id);
for (const next of graph.get(id) || []) {
if (seen.has(next)) continue;
seen.add(next);
stack.push(next);
}
}
if (!comp.some(id => family[id]?.alive)) remove.push(...comp);
}
if (!remove.length) return; if (!remove.length) return;
for (const id of remove) delete family[id]; for (const id of remove) delete family[id];
this.familyVersion = (this.familyVersion || 0) + 1; this.familyVersion = (this.familyVersion || 0) + 1;
@ -455,9 +430,8 @@
b.birthRitualRole = 1; b.birthRitualRole = 1;
a.birthRitualLeader = true; a.birthRitualLeader = true;
b.birthRitualLeader = false; b.birthRitualLeader = false;
a.state = b.state = "birth_ritual"; a.setActionState("birth_ritual", { target: b, reason: "近くで体をゆすっている" });
a.target = b; b.setActionState("birth_ritual", { target: a, reason: "近くで体をゆすっている" });
b.target = a;
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
@ -471,7 +445,8 @@
a.birthRitualTimer = b.birthRitualTimer = 0; a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null; a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false; a.birthRitualLeader = b.birthRitualLeader = false;
a.state = b.state = "idle"; a.goIdle("誕生の前ぶれが終わった");
b.goIdle("誕生の前ぶれが終わった");
if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) { if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) {
a.birthRitualRole = b.birthRitualRole = 0; a.birthRitualRole = b.birthRitualRole = 0;
return null; return null;
@ -621,8 +596,7 @@
t.defeatedTimer = 0; t.defeatedTimer = 0;
t.defeatedById = null; t.defeatedById = null;
t.fightWinnerId = null; t.fightWinnerId = null;
t.target = null; t.goIdle("");
t.state = "idle";
} }
this.startFightMochiIntimidation(a, b); this.startFightMochiIntimidation(a, b);
this.startFight(a, b); this.startFight(a, b);
@ -695,7 +669,7 @@
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
actor.intimidateTargetId = target.id; 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, { this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42, life: 0.42,
size: actor.radius * 0.55, size: actor.radius * 0.55,
@ -710,8 +684,8 @@
actor.intimidateTimer = 0; actor.intimidateTimer = 0;
actor.intimidateTargetId = null; actor.intimidateTargetId = null;
target.intimidatedTimer = 0; target.intimidatedTimer = 0;
if (actor.state === "intimidate") actor.state = "idle"; if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した");
if (target.state === "panic") target.state = "idle"; if (target.state === "panic") target.goIdle("威嚇から戻った");
actor.spriteLockUntil = 0; actor.spriteLockUntil = 0;
target.spriteLockUntil = 0; target.spriteLockUntil = 0;
this.startFight(actor, target); this.startFight(actor, target);
@ -721,10 +695,9 @@
target.defeatedById = actor.id; target.defeatedById = actor.id;
target.fightWinnerId = actor.id; target.fightWinnerId = actor.id;
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2)); 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.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
target.state = "panic"; if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" });
target.target = actor; 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)"); 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.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
target.vy += rand(-18, 18); target.vy += rand(-18, 18);
@ -741,7 +714,8 @@
if (!a || !b || a.dead || b.dead) return; if (!a || !b || a.dead || b.dead) return;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return; if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) 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)); a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
b.fightTimer = Math.max(b.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); a.nextHeadbutt = this.time + rand(0.08, 0.18);

View file

@ -197,7 +197,7 @@
return null; return null;
} }
if (dropped) { 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.dropTimer = item.dropMax;
item.dropImpactDone = false; item.dropImpactDone = false;
} }
@ -218,6 +218,27 @@
for (let i = this.tarinai.length - 1; i >= 0; i--) { 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 (!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; this.selected = found;
return; return;
} }
@ -262,7 +283,7 @@
if (d < hit && d < bestD) { best = it; bestD = d; } 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) { 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; best.amount = 0;
audio.delete?.(); audio.delete?.();
this.compactItems(); this.compactItems();
@ -281,7 +302,7 @@
const placed = this.placeItem(rawItem, true); const placed = this.placeItem(rawItem, true);
if (!placed) return; if (!placed) return;
const label = toolLabel(itemType); 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`);
} }
}, },

View file

@ -37,6 +37,49 @@
this.foodSpoilageEvents = (this.foodSpoilageEvents || 0) + 1; 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) { observeHighlightKind(t) {
const s = this.selected; const s = this.selected;
if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; if (!s || !t || t === s || t.dead || this.tool !== "observe") return "";
@ -112,9 +155,14 @@
dt *= this.speed; dt *= this.speed;
dt = Math.min(dt, 0.12 * this.speed); dt = Math.min(dt, 0.12 * this.speed);
const prevTime = this.time;
this.time += dt; this.time += dt;
const previousDay = this.day || 1; const previousDay = this.day || 1;
this.day = Math.floor(this.time / CONFIG.dayLength) + 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) { if (this.day !== previousDay) {
for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} }; for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} };
} }

View file

@ -285,15 +285,11 @@
}, },
toolSizeFor(type = "") { toolSizeFor(type = "") {
const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(this, type) : ((this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium");
if (!def?.scalable) return "medium";
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}, },
toolSizeScale(type = "") { toolSizeScale(type = "") {
const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; return typeof toolSizeScaleFor === "function" ? toolSizeScaleFor(this, type) : ({ small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1);
if (!def?.scalable) return 1;
return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
}, },
applyToolSize(item) { applyToolSize(item) {

View file

@ -37,6 +37,18 @@ def draw_drop(fill, stroke, highlight):
d.ellipse([24, 38, 45, 48], fill=highlight) d.ellipse([24, 38, 45, 48], fill=highlight)
return fn 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): def rgba(value, fallback):
if not value: if not value:
@ -133,6 +145,8 @@ def main() -> None:
highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150)) highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150))
if renderer == "oval_droplet": if renderer == "oval_droplet":
draw_fn = draw_drop(fill, stroke, highlight) draw_fn = draw_drop(fill, stroke, highlight)
elif renderer == "water_bowl":
draw_fn = draw_water_bowl(palette)
elif renderer == "powder_pile": elif renderer == "powder_pile":
draw_fn = draw_powder(fill, stroke, highlight) draw_fn = draw_powder(fill, stroke, highlight)
elif renderer == "split_pill": elif renderer == "split_pill":

View file

@ -1,11 +1,11 @@
"use strict"; "use strict";
const APP_VERSION = "15.20.16"; const APP_VERSION = "15.24.0";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`; const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
const coreScriptNames = [ 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 = [ const CORE_ASSETS = [
"./", "./",