diff --git a/.tmp-edge-profile/Crashpad/metadata b/.tmp-edge-profile/Crashpad/metadata new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-edge-profile/Crashpad/settings.dat b/.tmp-edge-profile/Crashpad/settings.dat new file mode 100644 index 0000000..e76f11a Binary files /dev/null and b/.tmp-edge-profile/Crashpad/settings.dat differ diff --git a/.tmp-edge-profile/Crashpad/throttle_store.dat b/.tmp-edge-profile/Crashpad/throttle_store.dat new file mode 100644 index 0000000..9639e1b --- /dev/null +++ b/.tmp-edge-profile/Crashpad/throttle_store.dat @@ -0,0 +1 @@ +level=none expiry=0 diff --git a/.tmp-edge-profile/Variations b/.tmp-edge-profile/Variations new file mode 100644 index 0000000..a157215 --- /dev/null +++ b/.tmp-edge-profile/Variations @@ -0,0 +1 @@ +{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0} \ No newline at end of file diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index a18517f..0529b91 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,123 +1,23 @@ -# Patch notes +# v15.24.2 needs compliance fix -## 15.20.16 +This patch focuses on making the implementation closer to the need-system prompt. -- Added canonical item visual, food, and disease registries; wired food decay/visual helpers and mystery-drug disease sampling to registry data. -- Generated the tool palette from `TOOL_CATEGORIES` at runtime and removed duplicated category markup from `index.html`. -- Added compact family-tree personality tags, daily personality-change details, and selected-Tarinai rolling recent-change causes for personality, damage, item-effect, disease, and ant-carry changes. -- Added `InputModeManager` for touch-first mobile classification and `WeatherSystem` for weather transitions/rain-water spawning. -- Added central domain events for placement, eating, decay, effects, diseases, damage, death, birth, fights, ants, weather, and rain item drops. -- Moved item-effect modifiers, active summaries, apply/tick behavior callbacks, and food interest class queries into registries. -- Moved explosion-disease base infection chance reads into `DiseaseRegistry` while preserving protein/niterop disease multipliers. -- Moved serving-food nutrition and hunger-relief reads into `FoodRegistry`, including laxative's zero hunger relief. -- Routed direct love/fight/sleep item eating through `EffectRegistry` while preserving the previous bite-specific stress, fear, and sleep-disease chance behavior. -- Routed sleep drug, rain-water item, and water-bowl field visuals through `item_visual_registry.js`; added water-bowl icon-generation support. -- Moved canonical sound gains, sample paths, categories, cooldowns, and ID category mapping out of `audio.js` into `sound_pack.js`. -- Added named ant ecology extension hooks/comments without changing ant balance. -- Bumped cache/version references to `15.20.16`; documented the new source-of-truth rules. +## Fixed -## 15.20.12 +- Kept the six-need system as the behavior driver. +- Kept needs clamped/quantized and displayed as 0-10 values. +- Kept tied top-need random selection and tie reason text. +- Kept StructureRegistry and both buildable structures: grass_bed and plushie. +- Removed remaining mojibake strings in the main need/action implementation file. +- Changed safety fleeing so normal safety behavior no longer directly enters panic. +- Reworked stress so direct stress mutation is removed from the main behavior files. +- Stress is now recalculated from needs or projected needs via calculateStressFromNeeds. +- Added applyNeedShock/applyNeedRelief helpers and converted shock/relief events into need changes. +- Reworked enterPanic as a legacy adapter that converts old calls into need shock first. +- Panic/intimidate are now short reaction states triggered through need shock logic. +- Updated cache/version strings to 15.24.2. -- 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. +## Notes -- Removed the unwanted item-effect speech bubbles listed in `docs/prompt.txt`. -- Reorganized the tool palette into color-coded collapsible groups: operation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles. -- Changed the tool UI ball icon to a soccer-ball emoji preview. -- Changed time phases: morning 05:00, day 10:00, evening 17:00, night 20:00. -- Increased genkotsu knockback for Tarinai and ants; genkotsu now also blasts zunchi away. -- Genkotsu damage now adds fear/stress and decreases Tarinai openness. -- Rain weather overlay is now diagonal streaks. Fallen rain-water items, zunda juice, and mercury remain horizontal oval item sprites. -- Tightened mobile operation UI visibility to smartphone-class compact touch devices. -- Sleep-disease Tarinai can be moved by ant hauling without snapping back to their sleep anchor. - -## 15.20.12 and earlier summary - -- Consolidated documentation into dense English files; left `docs/prompt.txt` untouched. -- Restored rain, revised water/zunda/mercury item sprites, added zunchi-slave head zunchi, fight win rate, fight defeat chance, and post-wake lock. -- Earlier patches added parameter items, persistent item effects, WebP assets, sound categories, push notifications, ant systems, family tree UI, performance manager, event bus, manifest generation, CSS split, and large-file JS split. - -## Debug patch: panic chain and action reasons - -- Removed the friend-only panic chain that made only a best friend follow a panic target. -- Added general nearby panic contagion: any nearby live tarinai in panic can cause another tarinai to panic. Neuroticism, distance, and current stress affect the probability. -- Added / completed visible action reasons for: - - eating all food and effect items - - eating grass, ant corpses, and zunchi - - drinking / touching water - - resting near stone, bed, and nest box - - sunbathing - - zunchi-slave avoidance - - being pinched / dragged - - firecracker blast panic - - explosion-disease blast panic - - falling object / stone / genkotsu impact - - high-speed ball collision - - high-speed tarinai collision - - intimidation, intimidation escape, fights, birth ritual, and thaw surprise - - fence-blocked path give-up -- Added state labels for `sunbath`, `ant_attack`, `ant_intimidate`, and `frozen`. -- Expanded target labels for all current tool/item target types, including medicine/effect items, ant corpses, ant nests, firecrackers, pushpins, signboards, and fences. -- Verification performed: - - `node --check` over every `js/*.js` - - state-label coverage scan: no uncovered tarinai states - - old friend-panic chain scan: zero matches - - -## おしり鋲・冷凍操作変更パッチ -- ずんちどれい化条件の喧嘩勝率を30%以下に変更。 -- 衰弱死因判定を厳格化。 -- 冷凍手段をクリック式から「つまむで冷凍UIへ運ぶ」方式へ変更。 -- 新アイテム「おしり鋲」を追加。刺さるとずんちを溜め、日光浴不可。抜くと溜まったずんちが高速で飛散。 -- 高速ずんち衝突時の吹き飛び・パニック理由文を追加。 - -## 15.22.0 統合リファクタ - -- `snapshot_system.js` を追加し、セーブ/ロードと冷凍で重複していた `clonePlain` / `copyOwnData` / `applyOwnData` / 参照復元 / スナップショット作成・復元を共通化。 -- `tarinai_action_state.js` を追加し、`setActionState()` / `enterPanic()` / `beginEating()` により、状態変更・パニック化・食事開始の理由文設定を統合。 -- 直接 `state = "panic"` していた処理を `enterPanic()` 経由へ変更。爆竹、爆発病、高速ボール、高速衝突、アリ、画鋲、近傍パニック連鎖、関係者死亡、解凍時の訃報などが対象。 -- 直接 `state = "eat"` していた食事処理を `beginEating()` 経由へ変更。 -- `physics_helpers.js` を追加し、壁反射・摩擦・回転を `TarinaiPhysics.applyKinematicItemMotion()` に共通化。おしり鋲/画鋲/高速ずんちの移動処理に適用。 -- `ITEM_TRAITS` と `PIN_BEHAVIORS` を追加し、アイテム分類と画鋲/おしり鋲の差分挙動をテーブル化。 -- `text_catalog.js` に状態ラベル、対象ラベル、理由文補完を集約。`ui_selected.js` はカタログ参照へ変更。 -- `family_graph.js` を追加し、家系グラフの連結成分探索を共通化。同期家系データと描画側に適用。 -- `relation_event_system.js` を追加し、関係者死亡の即時反応と冷凍個体への遅延通知を共通化。 -- `ui_helpers.js` を追加し、HTMLエスケープ等のUI共通処理を切り出し。 -- 構文チェック: 全 `js/*.js` OK。 -- 重複確認: `clonePlain` / `copyOwnData` / `applyOwnData` は `snapshot_system.js` に集約、直接 `state = "panic"` と直接 `state = "eat"` は除去済み。 - -## 15.23.0 safe duplicate integration pass - -- `registry_base.js` を追加し、`food_registry.js` / `effect_registry.js` / `disease_registry.js` の `get` / `has` / `label` / `color` / `debugSnapshot` 系の共通基盤を統合。 -- `patch_helpers.js` を追加し、後付けの `World.prototype` パッチを `TarinaiPatcher.patchWorld()` 経由に統合。冷凍・関係者死亡通知で利用。 -- `data.js` に `sleepFurniture` / `draggable` trait と、`isPinType()` / `isSleepFurnitureType()` / `lodgedPinFor()` / `hasLodgedPinEffect()` / `normalizedToolSizeFor()` / `toolSizeScaleFor()` を追加。 -- `ui_tools.js` と `world_view.js` のツールサイズ算出を `normalizedToolSizeFor()` / `toolSizeScaleFor()` に統合。 -- `tarinai_action_state.js` に `currentLodgedPin()` / `currentLodgedPinBehavior()` / `hasLodgedPinEffect()` / `goIdle()` / `startSleeping()` / `seekTarget()` を追加。 -- `blockedByOshibyo` 系の重複判定を `hasLodgedPinEffect("blocksZunchi")` に統合。 -- 画鋲/おしり鋲の型判定を `isPinType()` に寄せ、描画・配置・つまみ・削除判定の分岐を整理。 -- 睡眠家具判定を `isSleepFurnitureType()` に寄せ、ベッド/巣箱の直書き判定を削減。 -- 手動アリ近傍探索の一部を `world.nearbyAnts()` に寄せた。 -- `pruneExtinctFamilies()` を `TarinaiFamilyGraph.connectedComponents()` に寄せた。`family_graph.js` 側のデフォルト edge も親子参照を使うようにした。 -- `save_system.js` / `freeze_system.js` / `ui_selected.js` のHTMLエスケープを `TarinaiUIHelpers.htmlEscape()` に統合。 -- `assets.js` のスプライト/汚れオーバーレイ用キャンバス生成を `TarinaiPhysics.createCachedCanvas()` に寄せた。 -- 危険度の高い `render.js` 全体再設計、`Tarinai.update()` の状態機械化、`ants.js` の状態機械化、`items.js` 描画全体のレジストリ化は未実施。 -- 確認: 全 `js/*.js` の `node --check` 通過。 - -### 15.23.0 追確認 - -- `service-worker.js` / `app_manifest.json` のスクリプト一覧を `index.html` の実ロード順に同期。新規共通化ファイルもキャッシュ対象に追加。 -- アリ運搬中に寝ているたりないの状態変更も `startSleeping()` 経由へ寄せた。 -- 最終確認: 全 `js/*.js` と `service-worker.js` の `node --check` 通過。 -- 最終確認: `blockedByOshibyo`、`type === "oshibyo"`、`type === "pushpin"` の直接判定は 0件。`clonePlain` / `copyOwnData` / `applyOwnData` / `isPlainObject` は `snapshot_system.js` のみ。 - -## 15.24.0 safe integration / UI escape recursion fix - -- Fixed `Maximum call stack size exceeded` caused by `ui_helpers.htmlEscape()` calling the global `escapeHtml()` while `ui_selected.escapeHtml()` called back into `TarinaiUIHelpers.htmlEscape()`. -- Made `TarinaiUIHelpers.htmlEscape()` a pure escaping function and loaded `ui_helpers.js` before `ui_selected.js`. -- Added a guard in `ui_selected.escapeHtml()` so it never delegates to itself. -- Added a data URL favicon link to stop the browser's default `/favicon.ico` 404 request. -- Bumped static asset version to `15.24.0` in `app_manifest.json`, `index.html`, `service-worker.js`, and `js/version.js` to avoid stale cached scripts. -- Reduced target-label duplication: item labels now come from `TOOL_DEFINITIONS` / `toolLabel()`, with `text_catalog.js` holding only non-tool overrides. -- Removed the remaining synchronous family-tree DFS fallback blocks in `ui_family_data.js` and `ui_family_render.js`; both now use `TarinaiFamilyGraph.connectedComponents()`. -- Simplified the remaining manual ant proximity fallback in `tarinai_needs_items.js` to `world.nearbyAnts()`. -- Verification: all `js/*.js` and `service-worker.js` pass `node --check`. +- Old save compatibility is intentionally not provided. +- Some legacy fields such as loneliness, affection, mood, and fearTimer still exist as compatibility/input/display fields, but the main action selection now uses the six needs. diff --git a/app_manifest.json b/app_manifest.json index fdd5ec8..b9cdf68 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "15.24.0", + "version": "15.24.2", "css": [ "css/base.css", "css/layout.css", diff --git a/css/components.css b/css/components.css index 1e02c43..119532a 100644 --- a/css/components.css +++ b/css/components.css @@ -451,6 +451,13 @@ .selected-info .info-row[data-metric="loneliness"] { background: rgba(116, 150, 214, 0.10); } .selected-info .info-row[data-metric="energy"] { background: rgba(104, 184, 96, 0.10); } .selected-info .info-row[data-metric="stress"] { background: rgba(214, 104, 88, 0.10); } +.selected-info .need-row[data-chosen-need="true"] { + border-color: rgba(65, 112, 74, 0.52); + background: rgba(106, 154, 84, 0.16); +} +.selected-info .need-row[data-chosen-need="true"] strong { + color: #315f32; +} .selected-info .info-row[data-metric="zunchi"] { background: rgba(112, 142, 70, 0.10); } .selected-info .info-row[data-metric="mood"] { background: rgba(222, 196, 86, 0.10); } .selected-info .info-row[data-metric="lack"] { background: rgba(142, 112, 184, 0.10); } diff --git a/docs/prompt.txt b/docs/prompt.txt index 636a065..1950df9 100644 --- a/docs/prompt.txt +++ b/docs/prompt.txt @@ -1,466 +1,860 @@ -You are working on the browser game "たりないコロニー" based on the current ZIP/project state, assumed to be v15.20.3. - -Goal: resolve the remaining internal design confusion and underdeveloped systems, while preserving existing gameplay unless explicitly requested below. - -Hard exclusions: - -* Do NOT implement field-size balance tuning. Specifically do not implement the previously proposed "field-specific balance correction" work. -* Do NOT implement save/load. -* Do NOT implement debug scenario modes. -* Do NOT redesign or expand the zunchi system. Keep zunchi mechanics and general design as-is, except where existing code must be connected through registries/events without changing behavior. -* Do NOT modify `docs/prompt.txt` if present. -* Do not introduce a build toolchain requirement. Plain static HTML/CSS/JS must still run directly. - -Primary requirements: - -1. Make item visuals single-source-of-truth. - -* Current problem: field item rendering, tool UI icons, generated WebP icons, documentation, and item definitions can diverge. -* Establish one canonical visual definition per item/tool. -* Field rendering must be the primary visual source, or both field rendering and UI preview must derive from the same canonical visual descriptor. -* The tool UI must never have an independently invented appearance. -* Create or consolidate a visual registry, for example `js/item_visual_registry.js`, with descriptors such as: - - * `renderer` - * `shape` - * `palette` - * `scale` - * `uiPreviewScale` - * `fieldScale` - * `accessibleShapeTraits` - * optional `emojiFallback` -* Update field rendering and UI icon generation/preview so they share this registry. -* Fix visual drift for all known drift-prone items: - - * ammo - * mystery drug - * laxative - * sleeping drug - * protein - * nietorop - * giant drug - * dwarf drug - * zunda juice - * mercury - * rain-water item - * ball -* Keep the rule in docs: UI icon is a preview of the field visual, not a separate design. -* Ensure scalable items correctly respond to small/medium/large placement size. - -2. Data-drive tool categories. - -* Current problem: tool category membership/order/color is partly hardcoded in HTML/CSS and not owned by tool data. -* Move tool categories into a data registry, for example `js/tool_registry.js` or `js/data.js`. -* The registry must define: - - * category id - * Japanese label - * color/theme class - * sort order - * tool ids included in that category -* Generate/build the tool UI from this registry at runtime. -* Keep collapsible category behavior. -* Keep simple category color coding. -* Remove duplicated static category definitions from `index.html` where possible. -* Category changes must require editing only the registry, not HTML. - -3. Convert EffectRegistry from display-only to real behavior ownership. - -* Current problem: `effect_registry.js` mainly handles labels/display/random candidates, while actual behavior remains scattered in Tarinai/item code. -* Promote `EffectRegistry` into the canonical owner for item effect metadata and behavior. -* Each effect should define, where relevant: - - * `id` - * Japanese label - * duration or permanent flag - * exclusive group - * stack behavior - * display color - * display summary - * modifiers - * `onApply(tarinai, context)` - * `onTick(tarinai, dt, context)` - * `onRemove(tarinai, context)` - * visual effect id - * bubble policy - * whether it is eligible for "怪しい薬" - * whether it is removed by "ずんだ汁" -* Migrate these item effects into the registry: - - * laxative - * protein - * nietorop - * ammo - * mystery drug - * mercury - * giant drug - * dwarf drug - * zunda juice clearing behavior - * heko mochi timed effect - * fight mochi timed effect - * sleeping drug if it changes behavior -* Existing semantics must remain: - - * laxative: emits zunchi every 3 seconds during effect; eating does not reduce hunger because it immediately passes through. - * protein: permanent; damage dealt x1.75; disease chance x0.5; red upward particles; mutually exclusive with nietorop; latest wins. - * nietorop: permanent; damage dealt x0.5; disease chance x2; purple downward particles; mutually exclusive with protein; latest wins. - * ammo: timed; walking speed x10; while moving, collision with Tarinai/ants knocks them away. - * mystery drug: grants random two effects from behavior-changing item effects and diseases. - * mercury: each consumption multiplicatively increases lifespan by x1.3. - * giant drug: permanent; size x3; damage x2; speed x1.5; 1 damage/sec; mutually exclusive with dwarf drug; latest wins. - * dwarf drug: permanent; size x0.25; damage x0.25; speed x0.5; 1 damage/sec; mutually exclusive with giant drug; latest wins. - * zunda juice: removes all removable item effects. -* "たりないデータ" must read active item effects from the registry, not hand-built scattered conditions. - -4. Add DiseaseRegistry and connect disease behavior. - -* Current problem: diseases are individual booleans/branches and are not uniformly represented. -* Add `DiseaseRegistry` or equivalent. -* It must define: - - * disease id - * Japanese label - * symptoms/behavior flags - * infection chance modifiers - * cure rules - * display text - * visual/bubble policy - * eligibility for mystery drug -* Migrate these diseases into registry metadata while preserving behavior: - - * zunchi disease - * sleep disease - * explosion disease - * fight disease -* Explosion disease infection probability must remain 15% where currently defined, with protein/nietorop disease multipliers applied to that base. -* Mystery drug must sample diseases through this registry. -* Zunda juice should remove item effects, not necessarily diseases, unless current semantics already remove a given disease; do not silently change disease cure rules. - -5. Add FoodRegistry and centralize food semantics. - -* Current problem: food interest, nutrition, decay, hygiene penalty, cures, and special exceptions are scattered. -* Add `FoodRegistry`. -* It must define: - - * item id/type - * Japanese label - * nutrition - * palatability/interest class - * decay profile - * hygiene penalty on decay - * special effect id, if eating applies an effect - * whether it counts as grass-like interest, zunda-mochi high interest, medicine-like low interest, water, corpse, etc. -* Preserve existing behavior: - - * zunda mochi remains highly attractive. - * most non-zunda foods remain only slightly more interesting than grass. - * all food naturally decays about twice per half-day, not continuously. - * decay slightly reduces hygiene stats at those discrete decay ticks. - * laxative effect prevents hunger reduction from eating. -* `Tarinai` food-seeking logic must consult `FoodRegistry`. -* Food decay logic must consult `FoodRegistry`. -* Tooltips/descriptions should remain Japanese and current user-facing wording must not regress. - -6. Separate WeatherSystem from water items. - -* Current problem: rain visual effects and rain-spawned items/water-like items are conceptually mixed. -* Create or isolate a `WeatherSystem`. -* Explicitly separate: - - * `rain_visual`: diagonal blue-ish streaks/lines falling at an angle. - * `rain_water_item`: horizontal oval water item dropped by rain. - * `water_bowl`: placed water object. - * `zunda_juice`: green horizontal oval liquid item. - * `mercury`: gray horizontal oval liquid item. -* Do not reuse rain visual renderer for item droplets. -* Do not render rain effect as oval droplets. Rain effect must be angled falling lines. -* Do not render zunda juice or mercury with diagonal lines. -* Preserve current rain gameplay behavior. -* Add code comments documenting the distinction so future changes do not confuse them. - -7. Apply event bus more broadly. - -* Current problem: logs, sounds, notifications, stats, effects, and debug traces are still partly direct calls. -* Use the existing event bus or strengthen it. -* Add domain events for: - - * `tool:placed` - * `item:eaten` - * `item:decayed` - * `effect:applied` - * `effect:removed` - * `disease:infected` - * `disease:cured` - * `tarinai:damaged` - * `tarinai:died` - * `tarinai:born` - * `fight:hit` - * `fight:lost` - * `fight:ended` - * `ant:spawned` - * `ant:grabbed` - * `ant:carried` - * `ant:died` - * `weather:changed` - * `rain:itemDropped` -* Logs, push notifications, audio, stats, and debug history should subscribe to events where practical. -* Do not rewrite everything destructively. Add event emission at central points first, then migrate obvious direct calls. -* Avoid duplicate logs/sounds. - -8. Sound system source-of-truth cleanup. - -* Current problem: `sound_pack.js` and `audio.js` both contain overlapping settings. -* Make `sound_pack.js` the canonical metadata source for: - - * sound id - * category - * volume multiplier - * cooldown - * sample path - * fallback synthesis profile -* `audio.js` should be the playback engine only: - - * loading samples - * fallback synthesis - * category enable/disable - * master gain - * cooldown enforcement - * concurrent voice limiting -* Keep the current user settings: - - * master effect sound on/off - * Tarinai voice - * notification - * operation -* Do not regress volume. The previous issue was that only the first sound was loud and subsequent sounds became too quiet. Preserve the fixed master gain behavior. -* Keep `?debug=1` showing recent sound IDs and sound-pack version. - -9. Mobile input manager. - -* Current problem: mobile operation UI has appeared on PC repeatedly. -* Add a single `InputModeManager` or equivalent. -* It must own: - - * `isTouchDevice` - * `isCompactViewport` - * `hasHover` - * `pointerCoarse` - * `userAgentMobile` - * `currentMode` - * `gestureState` - * whether canvas can pan - * whether canvas can place tools - * whether family tree is allowed to capture touch -* Mobile operation UI must appear only when the device is clearly touch-first: - - * coarse pointer - * no hover - * real touch support - * and either compact viewport or mobile/tablet UA -* Desktop PCs with touch screens must not automatically show the mobile UI unless the viewport/UA also indicates mobile/tablet. -* Add debug display of the input classification under `?debug=1`. -* Keep existing modes: - - * auto - * camera - * tool - * family tree - -10. Ant system cleanup and extension hooks, without broad balance changes. - -* Do not create a full new ant ecology balance pass. -* Do consolidate ant domain events and state names. -* Preserve existing behavior: - - * ants can carry sleeping Tarinai. - * sleeping Tarinai do not wake just because ants carry them. - * sleeping Tarinai do not counterattack ants while asleep. - * ants without nests either join another available nest or wander. - * empty nests are removed. - * queen ants are visually larger. -* Add clear extension hooks or comments for future ant ecology: - - * nest hunger - * nest growth - * queen reproduction - * worker lifecycle - * carrying success rate - * territorial behavior -* Do not implement those future mechanics now unless already present. - -11. Family tree: add personality tags. - -* The family tree currently records lineage but should expose personality more directly. -* Add compact personality tags to each Tarinai node/card in the family tree. -* Use existing personality axes: - - * aggression - * openness - * sociability - * neuroticism -* Tags should be short Japanese labels, for example: - - * aggressive/high aggression - * calm/low aggression - * curious/high openness - * cautious/low openness - * social/high sociability - * solitary/low sociability - * nervous/high neuroticism - * stable/low neuroticism -* Use at most 2-3 tags per node to avoid clutter. -* Make tags visually subtle and compact. -* Do not break existing family tree panning/zooming/layout. -* If a Tarinai is dead, still show its personality tags if data exists. - -12. Tarinai data panel: show recent change causes. - -* The Tarinai individual data panel must include recent reasons for personality/state changes. -* Add a small "recent change causes" section. -* Record recent causes when personality or important state changes happen, for example: - - * genkotsu damage: fear increase and openness decrease - * fight win/loss - * being carried by ants - * disease infection/cure - * protein/nietorop - * giant/dwarf drug - * mercury consumption - * zunda juice clearing effects - * sleep/wake transitions where meaningful - * stress/fear significant changes -* Store only a short rolling history per Tarinai, for example last 5-8 entries. -* Each entry should include: - - * Japanese short reason - * affected stat/effect - * approximate in-game time or "recent" -* This is UI/diagnostic information, not save/load. -* Do not add full persistence. - -13. Personality system visibility. - -* The existing personality axes are meaningful but currently not visible enough. -* In addition to family tree tags and recent change causes, improve the Tarinai data panel to make personality effects understandable: - - * show current personality values already present, if available - * show the most recent cause next to changed values where practical - * avoid long prose; use compact labels. -* Do not add new personality axes. - -14. Documentation consolidation and English-only high-density docs. - -* Keep `docs/prompt.txt` untouched. -* Keep documentation concise, English-only, and high information density. -* Update or consolidate docs so the following source-of-truth rules are explicit: - - * item visuals come from the canonical visual registry. - * UI icons are previews, not independent art. - * tool categories come from the tool registry. - * item effects come from EffectRegistry. - * diseases come from DiseaseRegistry. - * food behavior comes from FoodRegistry. - * rain visuals are separate from rain-spawned water items. - * zunchi is intentionally unchanged. - * no field balance, save/load, or debug scenario implementation in this pass. -* Avoid many small docs. Prefer a small set such as: - - * `docs/ARCHITECTURE.md` - * `docs/CONTENT_GUIDE.md` - * `docs/MAINTENANCE.md` -* Remove obsolete docs if safely redundant. -* Do not delete `docs/prompt.txt`. - -15. Preserve user-facing Japanese UI text unless intentionally specified. - -* Game UI remains Japanese. -* Docs are English. -* The Codex implementation may use English comments for architecture, but visible labels/tooltips remain Japanese. - -16. Regression fixes to preserve. - Do not regress the following existing fixes: - -* Removed unwanted bubbles: - - * ドン - * ぐるる - * すっきり - * Hg xN - * ちまっ - * でかっ - * へにゃ - * ムキッ - * mm... -* Ball tool UI icon must be soccer ball emoji. -* Time periods: - - * morning starts at 05:00 - * noon/day starts at 10:00 - * evening starts at 17:00 - * night starts at 20:00 -* Genkotsu: - - * stronger knockback for living things - * zunchi is knocked away - * damaged Tarinai gain fear and lose openness -* Rain visual: - - * diagonal angled line rain, not droplets. -* Rain-spawned water item / zunda juice / mercury: - - * horizontal oval item visuals. -* Sleeping Tarinai: - - * can be carried by ants - * do not wake from ant carrying - * do not attack ants while sleeping - * after waking, remain awake for at least 10 seconds. -* Tarinai data: - - * fight win rate instead of cumulative defeats. -* Fight: - - * on taking damage, Tarinai may lose/flee probabilistically. - * high aggression lowers loss chance. - * high damage and low HP increase loss chance. - -17. Versioning and cache. - -* Bump app version to the next patch/minor version consistently. -* Update: - - * version file - * service worker cache name - * asset query versions - * manifest if present -* Ensure `service-worker.js` itself remains no-cache/no-store. -* Ensure generated app manifest validation still passes. - -18. Validation. - Run and report: - -* `node --check js/*.js service-worker.js` -* manifest validation/generation check if scripts exist -* zip integrity check if packaging -* basic static grep checks: - - * no stale app version references - * no removed bubble strings still emitted as bubbles - * mobile UI visibility logic uses the new InputModeManager - * tool categories are not duplicated in `index.html` - * sound metadata is not duplicated as canonical data in both `sound_pack.js` and `audio.js` - * item visual drift-prone items use canonical visual registry - * docs are English except `docs/prompt.txt` - -Deliverables: - -* Updated project files. -* Updated ZIP. -* Brief implementation summary. -* Validation results. -* Mention any deliberately deferred items: - - * field balance tuning - * save/load - * debug scenario modes - * zunchi expansion +Implement the new need-driven behavior system for the existing “tarinai” observation game. + +Keep the implementation simple, localized, and compatible with the existing architecture. Do not perform a broad rewrite unless absolutely necessary. + +Important source-code text rule: +All newly added Japanese UI strings or Japanese reason/action strings in source code must be written as Unicode escape sequences, not raw Japanese characters. + +Example: +Do not write: +"あぶないけど、食べた。" + +Write: +"\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002" + +Runtime text may display Japanese, but source literals must be escaped. + +============================================================ +GOAL +============================================================ + +Replace or absorb the existing detailed emotion-driven behavior system into a simple six-need system. + +Behavior should be selected from six needs: + +- food +- sleep +- health +- safety +- social +- fulfill + +The system should allow imperfect behavior. + +Example: +If safety and food are both maxed, the tarinai may randomly choose food and eat even though it is dangerous. + +============================================================ +1. SIX NEEDS +============================================================ + +Each tarinai should have: + +needs = { + food: 0, + sleep: 0, + health: 0, + safety: 0, + social: 0, + fulfill: 0 +}; + +Meaning: + +food: +- eating +- drinking + +sleep: +- sleep +- rest +- tiredness + +health: +- illness +- injury +- dirt +- poison +- recovery + +safety: +- danger +- fear +- ants +- explosions +- punches +- being attacked + +social: +- friends +- parent/child +- mating/reproduction +- enemies if existing relation logic supports it + +fulfill: +- play +- owned place +- owned structure +- grass bed +- plushie +- simple construction + +Each need is internally 0–100. + +After raw calculation, quantize each value to 10-point steps: + +0, 10, 20, ... 100 + +Use a helper like: + +function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round(value / 10) * 10)); +} + +For UI display, show only the tens digit: + +0, 1, 2, ... 10 + +Example: +internal value 80 -> display 8 +internal value 100 -> display 10 + +Safety is capped at 100 like every other need. +Safety is not a hard interrupt. + +============================================================ +2. NEED CALCULATION +============================================================ + +Calculate needs from existing tarinai/world state where possible. + +Suggested mapping: + +food: +- hunger +- thirst if available + +sleep: +- low energy +- long awake time if available +- night/time-of-day bonus if available + +health: +- disease +- injury +- dirtiness +- poison or negative status if available + +safety: +- nearby ants +- nearby dangerous items +- recent damage +- fear/panic-related existing state if any +- being attacked +- owned structure destruction shock + +social: +- loneliness if available +- nearby friend / parent / child +- reproduction readiness +- enemy proximity if current relation system supports it + +fulfill: +- wants to play +- has no owned structure +- wants to return to owned grass bed +- wants to carry/use plushie +- wants to build a simple owned structure + +Use existing personality fields if available: + +aggression: +- safety rises slightly less from danger +- may choose intimidate instead of panic when there is a breaker/attacker + +openness: +- fulfill rises more easily +- play/build actions become more likely + +sociability: +- social rises more easily +- reacts less negatively when others use owned structures + +neuroticism: +- safety and health rise more easily +- owned structure damage/destruction has stronger effect + +Keep personality modifiers small and easy to tune. + +============================================================ +3. ACTION SELECTION +============================================================ + +Use this behavior flow: + +1. Calculate all six raw needs. +2. Quantize all needs. +3. Find the maximum need value. +4. Collect all needs equal to the maximum. +5. Randomly choose one need from that tied top set. +6. Choose an executable action associated with that chosen need. +7. Store the chosen need and action in tarinai.intent. +8. Highlight only the chosen need in the tarinai data UI. + +If safety and food are both 100, either may be chosen randomly. + +Add helpers: + +- createDefaultNeeds() +- quantizeNeed(value) +- updateNeeds(tarinai, world) +- chooseTopNeedRandom(needs) +- chooseActionForNeed(need, tarinai, world) +- buildReasonText(chosenNeed, tiedNeeds, action) +- getNeedDisplayValue(value) + +============================================================ +4. SIMPLE ACTION DEFINITIONS +============================================================ + +Use a simple action list, not a complex behavior tree. + +Each action should have: + +- id +- need +- label +- phrase +- weight +- condition(tarinai, world) +- run(tarinai, world) + +Example shape: + +const TARINAI_ACTIONS = [ + { + id: "eat_food", + need: "food", + label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", + phrase: "\u98df\u3079\u305f", + weight: 50, + condition(t, world) { + return !!findNearestItemWithRole(world, t, "food"); + }, + run(t, world) { + const item = findNearestItemWithRole(world, t, "food"); + moveToOrUse(t, item); + } + } +]; + +Suggested actions: + +food: +- eat_food +- drink_water + +sleep: +- sleep_in_bed +- sleep_in_owned_structure +- sleep_anywhere + +health: +- use_medicine +- wash_or_drink_water +- rest_to_recover + +safety: +- flee +- hide_in_nest +- hide_at_owned_structure + +social: +- approach_friend +- approach_parent_or_child +- approach_mate +- intimidate_enemy + +fulfill: +- play +- wander_lightly +- build_grass_bed +- build_plushie +- return_owned_structure +- use_plushie + +Use weighted random among executable actions for the chosen need. + +If no action is executable, fall back to idle, wander, or rest. + +Do not make tarinai perfectly optimal. + +============================================================ +5. TIE REASON TEXT +============================================================ + +If multiple needs are tied for highest and the chosen need is only one of them, generate: + +“[ignored need phrase]\u3051\u3069\u3001[chosen action phrase]\u3002” + +Examples: +- "\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002" +- "\u306d\u3080\u3044\u3051\u3069\u3001\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f\u3002" +- "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b\u3051\u3069\u3001\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f\u3002" +- "\u8abf\u5b50\u304c\u60aa\u3044\u3051\u3069\u3001\u904a\u3093\u3060\u3002" + +When multiple ignored needs exist, mention the ignored need with highest priority: + +safety > health > food > sleep > social > fulfill + +Conflict phrases: + +food: +"\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b" + +sleep: +"\u306d\u3080\u3044" + +health: +"\u8abf\u5b50\u304c\u60aa\u3044" + +safety: +"\u3042\u3076\u306a\u3044" + +social: +"\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b" + +fulfill: +"\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b" + +If there is no tie, use: + +“[need phrase]\u306e\u3067\u3001[action phrase]\u3002” + +Store: + +tarinai.intent = { + need: chosenNeed, + tiedNeeds, + actionId: action.id, + actionLabel: action.label, + reasonText +}; + +============================================================ +6. NEED UI +============================================================ + +Update the tarinai data UI. + +Show six needs as 0–10 values. + +Labels must be escaped in source: + +food label: +"\u6442\u990c" + +sleep label: +"\u7761\u7720" + +health label: +"\u5065\u5eb7" + +safety label: +"\u5b89\u5168" + +social label: +"\u95a2\u4fc2" + +fulfill label: +"\u5145\u8db3" + +Example UI: + +摂餌 8 +睡眠 3 +健康 1 +安全 10 +関係 4 +充足 10 ★ + +Only the need actually chosen for the current action receives the highlight. + +Also show: + +"\u3044\u307e": +[action label] + +"\u7406\u7531": +[reasonText] + +Do not show detailed need breakdowns. +Do not add graphs or extra panels. + +============================================================ +7. ITEM ROLES AND NEED EFFECTS +============================================================ + +Use minimal item roles and needEffects. + +Do not create a broad tag system. + +Item example: + +item.roles = { + food: true, + drink: true, + medicine: true, + sleepPlace: true, + danger: true, + grassMaterial: true +}; + +item.needEffects = { + food: 0, + sleep: 0, + health: 0, + safety: 0, + social: 0, + fulfill: 0 +}; + +Only add roles that are actually needed by the new behavior. + +Useful helpers: + +- findNearestItemWithRole(world, tarinai, role) +- findNearestSleepPlace(world, tarinai) +- findNearbyDanger(world, tarinai) +- findNearbyMaterial(world, tarinai, materialRole) + +Adapt to existing item registries where possible. + +============================================================ +8. STRUCTURE REGISTRY +============================================================ + +Keep StructureRegistry because tarinai-built structures will expand in the future. + +Do not collapse structures into one-off special item cases. + +Create or adapt a small StructureRegistry for buildable tarinai structures. + +Shared structure instance shape: + +{ + id, + type, + isStructure: true, + ownerId, + hp, + attachment, + usedCount, + x, + y, + roles, + needEffects +} + +Do not implement: +- repair +- inheritance +- ownership privacy +- material-carrying inventory +- complex construction logistics + +First buildable structures: + +- grass_bed +- plushie + +============================================================ +9. STRUCTURE: GRASS BED +============================================================ + +Structure type: +"grass_bed" + +Japanese label, escaped: +"\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9" + +Concept: +A simple bed made from grass. It is an owned place. The owner prefers it for sleep, safety, and fulfill. + +Example registry entry shape: + +grass_bed: { + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { + grassMaterial: 1 + }, + roles: { + sleepPlace: true, + ownedStructure: true + }, + needEffects: { + food: 0, + sleep: 60, + health: 5, + safety: 25, + social: 0, + fulfill: 40 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "grass_bed", + isStructure: true, + ownerId: owner.id, + hp: 100, + attachment: 20, + usedCount: 0, + x, + y, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // owner gets stronger effects + // non-owner gets weaker effects + // owner use increases attachment + }, + onDestroyed(structure, world, breaker) { + // increase owner safety and fulfill + // trigger panic or intimidate through need shock logic + } +} + +Build condition: +- chosen need is fulfill +- tarinai has no owned grass_bed +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create grass_bed through StructureRegistry + +Owner behavior: +- owner prefers own grass_bed for sleep, safety, and fulfill +- using own grass_bed reduces sleep, safety, and fulfill +- using own grass_bed increases attachment + +Other tarinai: +- may use it with weaker effects +- if owner sees another tarinai using it: + - aggression may cause intimidate + - neuroticism may increase safety/fulfill + - sociability reduces negative reaction + +Destruction: +- hp can be reduced by explosions, punches, weather, repeated use, or existing damage sources if easy to connect +- when hp reaches 0, call onDestroyed and remove structure + +============================================================ +10. STRUCTURE: PLUSHIE +============================================================ + +Add a new tarinai-built structure: + +type: +"plushie" + +Japanese label, escaped: +"\u306c\u3044\u3050\u308b\u307f" + +Concept: +A small plushie item that resembles a tarinai. It is made from grass. When completed, it attaches to the owner tarinai’s head. While attached, it reduces fulfill. If damaged, it breaks and disappears. The owner receives a strong need shock and enters panic or intimidate. + +Example registry entry shape: + +plushie: { + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { + grassMaterial: 1 + }, + roles: { + carriedObject: true, + ownedStructure: true, + fulfillObject: true + }, + needEffects: { + food: 0, + sleep: 0, + health: 0, + safety: 5, + social: 0, + fulfill: 50 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "plushie", + isStructure: true, + ownerId: owner.id, + hp: 1, + attachment: 30, + usedCount: 0, + x, + y, + carriedById: owner.id, + onHead: true, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // if owner carries/uses it, reduce fulfill + // optionally reduce safety slightly + // increase attachment slightly + }, + onDestroyed(structure, world, breaker) { + // large shock to owner safety and fulfill + // trigger panic or intimidate + } +} + +Build condition: +- chosen need is fulfill +- tarinai does not already own/carry a plushie +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create plushie through StructureRegistry +- attach it to owner’s head + +Behavior while attached: +- follow owner position +- render above owner’s head +- use same or similar sprite as tarinai, scaled down +- reduce fulfill over time or when used +- optionally reduce safety slightly +- do not implement inventory + +Damage/destruction: +- any meaningful damage destroys it +- remove plushie +- increase owner safety and fulfill strongly +- trigger panic or intimidate through need shock logic + +Do not implement: +- repair +- inheritance +- trading +- inventory +- multiple plushies per tarinai + +============================================================ +11. EMOTION SYSTEM SIMPLIFICATION +============================================================ + +Remove the old detailed emotion system as a behavior driver. + +Behavior must be selected only from the six needs: +food, sleep, health, safety, social, fulfill. + +Old emotions should be removed or absorbed: + +fear -> safety +loneliness -> social +happiness -> low stress / low fulfill pressure +sadness -> fulfill/social/safety pressure +comfort -> sleep/safety/fulfill satisfaction +boredom -> do not implement +anger -> intimidate state only +panic -> need shock reaction +stress -> derived pressure from needs + +Do not keep happiness, sadness, fear, loneliness, comfort, boredom, or anger as independent behavior-driving values. + +============================================================ +12. STRESS REDEFINITION +============================================================ + +Stress is not an independent emotion. + +Stress is a derived value calculated from current need pressure. + +Do not directly add to stress for shock events. +For shock events, increase relevant needs first, then recalculate stress from needs. + +Stress must not be used as a primary action-selection need. + +Behavior is selected only from the six needs. + +Stress may affect: +- expression +- minor visual intensity +- UI display if already present +- minor secondary effects if existing systems need it + +Suggested pressure function: + +function needPressure(value) { + const x = value / 100; + return x * x * 100; +} + +Suggested stress calculation: + +stress = + needPressure(food) * 0.10 + + needPressure(sleep) * 0.12 + + needPressure(health) * 0.25 + + needPressure(safety) * 0.30 + + needPressure(social) * 0.08 + + needPressure(fulfill) * 0.15; + +Adjust weights if needed, but keep stress derived from needs. + +============================================================ +13. PANIC REDEFINITION +============================================================ + +Panic is not a need. +Panic is not a persistent emotion. + +Panic is a short temporary reaction state caused by large sudden increases in safety, health, or fulfill. + +Food, sleep, and social should not normally trigger panic. + +Track previous needs or otherwise compute need delta. + +Suggested rule: + +panic triggers if: +- safety increases suddenly by 40 or more +- health increases suddenly by 45 or more +- fulfill increases suddenly by 50 or more + +Panic should last for a short timer, such as 2–4 seconds. + +After panic timer ends, return to normal need-based action selection. + +If panic would be triggered and the tarinai has high aggression and a known breaker/attacker, use intimidate instead of panic. + +Example: + +if (shockTriggered) { + if (breaker && t.personality?.aggression > 0.6) { + t.state = "intimidate"; + t.target = breaker; + } else { + t.state = "panic"; + t.panicTimer = 3.0; + } +} + +============================================================ +14. EXPRESSION / VISUAL EMOTION COMPATIBILITY +============================================================ + +If the old emotion system is deeply tied to expressions, keep only a small compatibility layer for visuals. + +Expressions should be derived from: +- current state +- chosen need +- stress level +- panic/intimidate + +Suggested mapping: + +state panic: +panic expression + +state intimidate: +angry/threat expression + +chosen need safety: +worried expression + +chosen need food: +hungry expression + +chosen need sleep: +sleepy expression + +chosen need health: +unwell expression + +chosen need social: +social/looking expression + +chosen need fulfill: +neutral/pleased expression + +Do not let old expression emotions drive behavior. + +============================================================ +15. SAVE COMPATIBILITY +============================================================ + +Backward compatibility is not required. + +Do not implement save migrations. +Do not add compatibility shims for old saves unless the current code would immediately crash during normal startup. + +It is acceptable if old saves are incompatible after this feature. + +============================================================ +16. DO NOT ADD +============================================================ + +Do not add: +- long-term memory +- boredom/habit system +- player-intervention profile +- ReactionRegistry +- WorldRules +- full behavior tree +- complex utility AI +- detailed need breakdown UI +- repair system +- inheritance system +- material-carrying inventory +- structure privacy system + +============================================================ +17. ACCEPTANCE CRITERIA +============================================================ + +After implementation: + +- Each tarinai has six needs: + - food + - sleep + - health + - safety + - social + - fulfill + +- Needs are clamped 0–100. +- Needs are quantized to 10-point steps. +- UI displays needs as 0–10. +- Only the chosen action-driving need is highlighted. +- If multiple needs are tied for highest, one is chosen randomly. +- Safety does not force interruption. +- If safety and another need are tied, non-safety behavior can happen. +- Tie reason text uses the “Xけど、Yした。” format. +- Newly added Japanese source strings are Unicode escaped. +- Actions are defined in a simple action list. +- Items expose minimal roles and needEffects. +- StructureRegistry exists and supports at least grass_bed and plushie. +- grass_bed consumes grass to build. +- grass_bed is preferred by its owner. +- grass_bed destruction increases owner safety/fulfill and can trigger panic/intimidate. +- plushie consumes grass to build. +- plushie attaches to the owner’s head. +- plushie visually resembles a small tarinai. +- plushie reduces fulfill while carried/used. +- plushie breaks and disappears when damaged. +- plushie destruction strongly increases owner safety/fulfill and triggers panic/intimidate. +- Old detailed emotion values no longer drive behavior. +- Stress is derived from needs. +- Panic is caused by sudden safety/health/fulfill increases. +- Old save compatibility is not required. \ No newline at end of file diff --git a/index.html b/index.html index 75ac774..4ea4a73 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,11 @@ たりない観察 - - - - - + + + + +
@@ -183,66 +183,67 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/effect_registry.js b/js/effect_registry.js index 4d73aa4..1810a8c 100644 --- a/js/effect_registry.js +++ b/js/effect_registry.js @@ -109,7 +109,7 @@ const scale = context.scale ?? 1; tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress - (context.source === "eating" ? nutrition * 0.25 : 8), 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); return true; }, }); @@ -118,7 +118,7 @@ const scale = context.scale ?? 1; tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress + (context.source === "eating" ? nutrition * 0.22 : 10), 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); if (context.source === "eating") tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); return true; }, @@ -127,7 +127,7 @@ onApply(tarinai, context = {}) { if (context.source === "eating") { const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress - nutrition * 0.10, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); diff --git a/js/freeze_system.js b/js/freeze_system.js index 506a2a6..17356bd 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -183,7 +183,7 @@ 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); + if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 }); t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); } diff --git a/js/health.js b/js/health.js index 690a16c..75f674f 100644 --- a/js/health.js +++ b/js/health.js @@ -134,6 +134,8 @@ const HEALTH = (() => { if (actualLoss > 0.01) { cause = rememberDamage(t, actualLoss, reason); t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason }); + const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id); + if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); audio.damage?.(actualLoss); if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); diff --git a/js/items.js b/js/items.js index 1989a4b..54e0cef 100644 --- a/js/items.js +++ b/js/items.js @@ -184,6 +184,14 @@ class Item { this.amount = itemAmountFor(type, 80); this.age = 0; this.seed = Math.random() * 1000; + this.roles = {}; + this.needEffects = { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) this.roles.food = true; + if (type === "water" || type === "water_bowl" || type === "zunda_juice") this.roles.drink = true; + if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) this.roles.medicine = true; + if (type === "bed" || type === "nest_box") this.roles.sleepPlace = true; + if (["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(type)) this.roles.danger = true; + if (type === "grass") this.roles.grassMaterial = true; this.dropTimer = 0; this.dropMax = 0; this.dropImpactDone = false; @@ -412,7 +420,7 @@ class Item { 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)); + if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); } worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); @@ -693,7 +701,7 @@ class Item { t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); t.fearTimer = Math.max(t.fearTimer || 0, 1.3); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); - t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130)); + if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); } if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { @@ -730,7 +738,7 @@ class Item { this.pinDamageTick -= 0.55; if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - 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 (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); } t.sleeping = false; diff --git a/js/relation_event_system.js b/js/relation_event_system.js index fcb3249..4d7cd80 100644 --- a/js/relation_event_system.js +++ b/js/relation_event_system.js @@ -30,7 +30,7 @@ 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); + if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 }); observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); } observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress }); diff --git a/js/render.js b/js/render.js index 5ea4cb6..38f79f8 100644 --- a/js/render.js +++ b/js/render.js @@ -743,6 +743,7 @@ function render() { for (const it of world.items) { if (isCachedTerrainItem(it)) continue; if (isPinType(it.type) && it.pinState === "lodged") continue; + if (it.isStructure && it.type === "plushie" && it.carriedById) continue; if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { @@ -769,6 +770,9 @@ function render() { for (const t of sorted) { if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); } + for (const it of world.items) { + if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + } for (const it of world.items) { if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } diff --git a/js/structures.js b/js/structures.js new file mode 100644 index 0000000..59bb11d --- /dev/null +++ b/js/structures.js @@ -0,0 +1,214 @@ +"use strict"; + +(function (global) { + function createStructureId(prefix = "structure") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; + } + + function clonePlain(value) { + return JSON.parse(JSON.stringify(value || {})); + } + + class TarinaiStructure { + constructor(definition, owner, x, y, world) { + this.id = createStructureId("structure"); + this.type = definition.type; + this.label = definition.label; + this.isStructure = true; + this.ownerId = owner?.id || ""; + this.hp = definition.maxHp || 1; + this.maxHp = definition.maxHp || this.hp; + this.attachment = definition.type === "plushie" ? 30 : 20; + this.usedCount = 0; + this.x = x; + this.y = y; + this.r = definition.type === "plushie" ? 13 : 24; + this.amount = this.hp; + this.seed = Math.random() * 1000; + this.roles = clonePlain(definition.roles); + this.needEffects = clonePlain(definition.needEffects); + this.carriedById = definition.type === "plushie" ? this.ownerId : ""; + this.onHead = definition.type === "plushie"; + this.dead = false; + this.world = world || null; + } + + owner(worldRef = this.world) { + return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === this.ownerId) || null; + } + + update(dt, worldRef = this.world) { + this.world = worldRef || this.world; + if (this.dead || this.hp <= 0) { + this.amount = 0; + return; + } + if (this.type === "plushie") { + const owner = this.owner(worldRef); + if (!owner) { + this.damage(1, worldRef, null); + return; + } + this.x = owner.x; + this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78); + if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8); + } + this.amount = Math.max(0.001, this.hp); + } + + use(user, worldRef = this.world) { + const definition = global.StructureRegistry?.get?.(this.type); + return definition?.onUse?.(user, this, worldRef) || false; + } + + damage(amount = 1, worldRef = this.world, breaker = null) { + if (this.dead) return false; + this.hp = Math.max(0, (this.hp || 0) - Math.max(0, amount || 0)); + this.amount = this.hp; + if (this.hp > 0) return false; + this.dead = true; + this.amount = 0; + global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, breaker); + return true; + } + + draw(ctx, time = 0, lighting = {}) { + if (this.dead) return; + ctx.save(); + ctx.translate(this.x, this.y); + const night = (lighting?.light || 1) < 0.42; + if (this.type === "grass_bed") { + ctx.fillStyle = night ? "rgba(108,129,77,0.88)" : "rgba(134,174,83,0.92)"; + ctx.strokeStyle = night ? "rgba(55,73,49,0.72)" : "rgba(72,105,50,0.72)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 4, this.r * 1.55, this.r * 0.62, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = night ? "rgba(185,203,142,0.35)" : "rgba(231,229,139,0.55)"; + for (let i = 0; i < 12; i++) { + const a = -0.8 + i * 0.145; + ctx.beginPath(); + ctx.moveTo(-this.r * 1.05 + i * this.r * 0.19, this.r * 0.38); + ctx.quadraticCurveTo(Math.sin(time + i) * 4, -this.r * 0.12, Math.cos(a) * this.r * 0.92, -this.r * 0.42 + Math.sin(i) * 4); + ctx.stroke(); + } + } else if (this.type === "plushie") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile"); + ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05); + if (img) { + const w = this.r * 2.2; + const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null; + const h = w * (metrics?.ratio || 0.92); + ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h); + } else { + ctx.fillStyle = "#f7dfc0"; + ctx.strokeStyle = "rgba(86,62,48,0.70)"; + ctx.lineWidth = 1.7; + ctx.beginPath(); + ctx.arc(0, 0, this.r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(52,43,38,0.88)"; + ctx.beginPath(); ctx.arc(-this.r * 0.28, -this.r * 0.08, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(this.r * 0.28, -this.r * 0.08, 1.5, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + } + } + + const StructureRegistry = { + defs: {}, + register(definition) { this.defs[definition.type] = definition; return definition; }, + get(type) { return this.defs[type] || null; }, + create(type, owner, x, y, world) { + const definition = this.get(type); + if (!definition) return null; + return definition.create ? definition.create(owner, x, y, world) : new TarinaiStructure(definition, owner, x, y, world); + }, + }; + + function applyStructureNeedShock(owner, safetyGain, fulfillGain, breaker = null) { + if (!owner || owner.dead) return; + if (typeof global.applyNeedShock === "function") global.applyNeedShock(owner, { safety: safetyGain || 0, fulfill: fulfillGain || 0 }, breaker); + else { + owner.needShock = owner.needShock || {}; + owner.needShock.safety = Math.max(owner.needShock.safety || 0, safetyGain || 0); + owner.needShock.fulfill = Math.max(owner.needShock.fulfill || 0, fulfillGain || 0); + owner.lastNeedShockBreaker = breaker || null; + } + if (typeof global.triggerNeedShockReaction === "function") { + const reason = breaker ? "\u81ea\u5206\u306e\u3082\u306e\u304c\u58ca\u308c\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u81ea\u5206\u306e\u3082\u306e\u304c\u58ca\u308c\u3066\u3044\u308b"; + global.triggerNeedShockReaction(owner, breaker, reason); + } + } + + function damageNearbyStructures(worldRef, x, y, radius, amount, breaker = null) { + let hit = 0; + for (const structure of worldRef?.nearbyItems?.(x, y, radius + 80) || worldRef?.items || []) { + if (!structure?.isStructure || structure.dead) continue; + const d = distXY(x, y, structure.x, structure.y); + const p = clamp(1 - d / Math.max(1, radius + (structure.r || 12)), 0, 1); + if (p <= 0) continue; + if (structure.damage?.(Math.max(1, amount * (0.35 + p * 0.65)), worldRef, breaker)) hit++; + } + return hit; + } + + StructureRegistry.register({ + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { grassMaterial: 1 }, + roles: { sleepPlace: true, ownedStructure: true }, + needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 }, + create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, + onUse(user, structure) { + const ownerUse = user?.id && user.id === structure.ownerId; + if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2); + structure.usedCount = (structure.usedCount || 0) + 1; + if (user) { + user.needShock = user.needShock || {}; + user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8)); + user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4)); + user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6)); + } + return true; + }, + onDestroyed(structure, world, breaker) { + applyStructureNeedShock(structure.owner(world), 42, 46, breaker); + }, + }); + + StructureRegistry.register({ + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { grassMaterial: 1 }, + roles: { carriedObject: true, ownedStructure: true, fulfillObject: true }, + needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 }, + create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, + onUse(user, structure) { + if (user?.id === structure.ownerId) structure.attachment = Math.min(100, (structure.attachment || 0) + 1); + structure.usedCount = (structure.usedCount || 0) + 1; + if (user?.id === structure.ownerId) { + user.needShock = user.needShock || {}; + user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - 24); + user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - 4); + } + return true; + }, + onDestroyed(structure, world, breaker) { + applyStructureNeedShock(structure.owner(world), 55, 64, breaker); + }, + }); + + global.TarinaiStructure = TarinaiStructure; + global.StructureRegistry = StructureRegistry; + global.damageNearbyStructures = damageNearbyStructures; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index d3cd670..4862e29 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -53,7 +53,12 @@ class Tarinai { constructor(world, opts = {}) { this.hunger = opts.hunger ?? rand(16, 45); this.loneliness = opts.loneliness ?? rand(18, 62); this.energy = opts.energy ?? rand(54, 98); - this.stress = opts.stress ?? rand(0, 20); + this.needs = opts.needs && typeof opts.needs === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.needs } : (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }); + this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needs) : 0; + this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.previousNeeds } : { ...this.needs }; + this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null; + this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {}; + this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null; this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index c5665cb..5c30997 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -58,18 +58,30 @@ 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); + const safetyShock = Math.max(40, (opts.fearTimer ?? opts.fear ?? 0.55) * 32 * fearMul, Number(opts.stress || 0) * 1.8); + const healthShock = Math.max(0, Number(opts.hurtTimer || 0) > 0 ? 45 : 0); + if (typeof applyNeedShock === "function") { + applyNeedShock(this, { safety: safetyShock, health: healthShock }, threat); + } else { + this.needShock = this.needShock || {}; + this.needShock.safety = Math.max(this.needShock.safety || 0, safetyShock); + if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock); + } + let handled = false; + if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている"); + if (!handled) { + let target = null; + if ("target" in opts) target = opts.target; + else if (opts.destination) target = opts.destination; + else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); + this.setActionState("panic", { + target, + reason: opts.reason || this.thought || "パニックになっている", + wake: opts.wake !== false, + sleeping: false, + }); + this.fearTimer = Math.max(this.fearTimer || 0, 2.4); + } 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); @@ -77,10 +89,6 @@ 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; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 5bbec40..fa06865 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -26,7 +26,7 @@ this.zunchiDiseaseSeverity = 0; this.zunchiDiseaseCooldown = 18; this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100); - this.stress = clamp(this.stress - 8, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -18, safety: -6 }); this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f"; audio.heal?.(); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)"); @@ -61,7 +61,7 @@ } if (this.zunchiDisease) { this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); - this.stress = clamp(this.stress + dt * (0.28 + 0.18 * this.zunchiDiseaseSeverity), 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * (5 + 4 * this.zunchiDiseaseSeverity) }); this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); @@ -136,7 +136,7 @@ this.explosionDiseaseTimer = 0; this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); - this.stress = clamp(this.stress - 10, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -22, safety: -8 }); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_explosion", reason }); @@ -168,7 +168,7 @@ this.fightDiseaseCooldown = 20; this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); - this.stress = clamp(this.stress - 12, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -24, safety: -10 }); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_fight", reason }); @@ -311,7 +311,7 @@ applyWaterEffect(dt, source = "water") { const power = Math.max(0, dt || 0); - this.stress = clamp(this.stress - power * 2.4, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -power * 12, safety: -power * 6 }); this.energy = clamp(this.energy + power * 0.5, 0, 100); this.hunger = clamp(this.hunger - power * 0.2, 0, 115); this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); @@ -329,7 +329,7 @@ this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt); if (this.explosionDisease) { this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); - this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130)); + if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); if (Math.random() < dt * 2.1) { const a = rand(0, Math.PI * 2); this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { @@ -353,7 +353,7 @@ this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } if (this.fightDisease) { - this.stress = clamp(this.stress + dt * 0.16, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 3, safety: dt * 2 }); if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); } diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 6d0776b..c50f81f 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -147,7 +147,7 @@ const changedA = this.adjustPersonality?.("neuroticism", 0.025, "after being hurt") || 0; const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; this.fearTimer = Math.max(this.fearTimer || 0, 1.2); - this.stress = clamp((this.stress || 0) + 5, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 9 }); return Boolean(changedA || changedB); }, @@ -256,9 +256,15 @@ const tolerance = Number.isFinite(opts.tolerance) ? opts.tolerance : 0.88; const delta = Math.max(0, (amount || 0) * tolerance); if (delta <= 0) return 0; + if (!this.needShock) this.needShock = {}; + const shock = Math.min(100, delta * 1.8); + this.needShock.safety = Math.max(this.needShock.safety || 0, shock); const before = this.stress || 0; - this.stress = clamp(before + delta, 0, 130); - const gained = Math.max(0, this.stress - before); + if (this.needs && typeof calculateStressFromNeeds === "function") { + const nextSafety = typeof quantizeNeed === "function" ? quantizeNeed((this.needs.safety || 0) + shock) : Math.min(100, (this.needs.safety || 0) + shock); + this.stress = calculateStressFromNeeds({ ...this.needs, safety: nextSafety }); + } + const gained = Math.max(delta, Math.max(0, (this.stress || 0) - before)); const threshold = opts.threshold ?? 8; if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2); return gained; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index fc9170d..03180e7 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,5 +1,324 @@ "use strict"; +const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; +const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"]; +const TARINAI_NEED_LABELS = { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", +}; +const TARINAI_NEED_PHRASES = { + food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", + sleep: "\u306d\u3080\u3044", + health: "\u8abf\u5b50\u304c\u60aa\u3044", + safety: "\u3042\u3076\u306a\u3044", + social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", + fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b", +}; + +function createDefaultNeeds() { + return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; +} + +function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); +} + +function getNeedDisplayValue(value) { + return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10))); +} + +function needPressure(value) { + const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100; + return x * x * 100; +} + +function calculateStressFromNeeds(needs) { + return clamp( + needPressure(needs.food) * 0.10 + + needPressure(needs.sleep) * 0.12 + + needPressure(needs.health) * 0.25 + + needPressure(needs.safety) * 0.30 + + needPressure(needs.social) * 0.08 + + needPressure(needs.fulfill) * 0.15, + 0, + 130 + ); +} + + +function applyNeedShock(tarinai, deltas = {}, breaker = null) { + if (!tarinai || tarinai.dead) return false; + tarinai.needShock = tarinai.needShock || {}; + for (const key of TARINAI_NEED_KEYS) { + const delta = Number(deltas[key] || 0) || 0; + if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta); + else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta); + } + if (breaker) tarinai.lastNeedShockBreaker = breaker; + if (tarinai.needs && typeof calculateStressFromNeeds === "function") { + const projected = { ...tarinai.needs }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0)); + tarinai.stress = calculateStressFromNeeds(projected); + } + return true; +} + +function applyNeedRelief(tarinai, deltas = {}) { + return applyNeedShock(tarinai, deltas, null); +} + +function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { + if (!tarinai || tarinai.dead) return false; + const before = tarinai.previousNeeds || createDefaultNeeds(); + const projected = { ...(tarinai.needs || createDefaultNeeds()) }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0)); + const safetyDelta = (projected.safety || 0) - (before.safety || 0); + const healthDelta = (projected.health || 0) - (before.health || 0); + const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (!shockTriggered) return false; + if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false; + const aggression = Number(tarinai.currentPersonality?.aggression || 0); + if (breaker && aggression > 0.6) { + tarinai.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); + tarinai.intimidateTimer = Math.max(tarinai.intimidateTimer || 0, 2.0); + } else { + tarinai.setActionState?.("panic", { target: tarinai.panicDestination?.(breaker), reason, wake: true, sleeping: false }); + tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 2.6); + } + tarinai.lastNeedShockBreaker = null; + return true; +} + +function chooseTopNeedRandom(needs) { + const max = Math.max(...TARINAI_NEED_KEYS.map(key => Number(needs?.[key]) || 0)); + const tiedNeeds = TARINAI_NEED_KEYS.filter(key => (Number(needs?.[key]) || 0) === max); + return { need: pick(tiedNeeds), tiedNeeds, max }; +} + +function buildReasonText(chosenNeed, tiedNeeds, action) { + const chosenPhrase = action?.phrase || "\u5f85\u3063\u305f"; + const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; + if (tied.length) { + const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; + return `${TARINAI_NEED_PHRASES[ignored] || ignored}\u3051\u3069\u3001${chosenPhrase}\u3002`; + } + return `${TARINAI_NEED_PHRASES[chosenNeed] || chosenNeed}\u306e\u3067\u3001${chosenPhrase}\u3002`; +} + +function roleMatches(item, role) { + if (!item || item.dead || (item.amount != null && item.amount <= 0)) return false; + if (item.roles?.[role]) return true; + if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(item.type) || (typeof isServingFoodType === "function" && isServingFoodType(item.type)); + if (role === "drink") return item.type === "water" || item.type === "water_bowl" || item.type === "zunda_juice"; + if (role === "medicine") return item.type === "sweet" || item.type === "water" || item.type === "water_bowl" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(item.type)); + if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; + if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(item.type); + if (role === "grassMaterial") return item.type === "grass"; + return false; +} + +function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { + let best = null, bestD = maxDist; + for (const item of world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []) { + if (!roleMatches(item, role)) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function findNearestSleepPlace(world, tarinai, maxDist = 520) { + const own = findOwnedStructure(world, tarinai, "grass_bed", maxDist); + return own || world?.bestBedFor?.(tarinai, maxDist) || findNearestItemWithRole(world, tarinai, "sleepPlace", maxDist); +} + +function findNearbyDanger(world, tarinai, maxDist = 150) { + const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null; + const item = findNearestItemWithRole(world, tarinai, "danger", maxDist); + if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item; + return ant || item; +} + +function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) { + return findNearestItemWithRole(world, tarinai, materialRole, maxDist); +} + +function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of world?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue; + if (type && item.type !== type) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function consumeGrassMaterial(material) { + if (!material) return false; + material.amount = Math.max(0, (material.amount || 0) - 18); + material.eatenAmount = (material.eatenAmount || 0) + 18; + if (Number.isFinite(material.growth)) material.growth = clamp(material.growth - 0.18, 0.04, 1.2); + return true; +} + +function moveToOrUse(t, target, state = "seek_food", reason = "") { + if (!target) return false; + t.setActionState?.(state, { target, reason, sleeping: false }); + return true; +} + +if (typeof window !== "undefined") { + Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction }); +} + +function updateNeeds(tarinai, world) { + const p = tarinai.currentPersonality || {}; + const aggression = Number(p.aggression) || 0; + const openness = Number(p.openness) || 0; + const sociability = Number(p.sociability) || 0; + const neuroticism = Number(p.neuroticism) || 0; + const needs = createDefaultNeeds(); + needs.food = (tarinai.hunger || 0) * 0.88; + needs.sleep = Math.max(0, 100 - (tarinai.energy || 0)); + const dayP = world?.dayProgress?.() || 0; + const night = dayP >= 0.62 || dayP < 0.18; + if (night) needs.sleep += 10; + needs.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); + if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) needs.health += 42; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.stuckPushpinId || "")) needs.health += 36; + const danger = findNearbyDanger(world, tarinai, 170); + if (danger) needs.safety += danger.kind ? 78 : 48; + if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) needs.safety += 34; + needs.social = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16); + if (tarinai.parentToFollow?.()) needs.social += 18; + if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) needs.social += 16; + const enemy = tarinai.strongestRelation?.("fear"); + if (enemy?.score > 12) needs.social += 10; + needs.fulfill = 28 + (openness > 0 ? openness * 18 : 0); + if (!findOwnedStructure(world, tarinai, "grass_bed")) needs.fulfill += 20; + const ownBed = findOwnedStructure(world, tarinai, "grass_bed"); + const ownPlushie = findOwnedStructure(world, tarinai, "plushie"); + if (ownBed) { + needs.sleep -= dist(tarinai, ownBed) < 80 ? 16 : 6; + needs.safety -= dist(tarinai, ownBed) < 100 ? 14 : 4; + needs.fulfill -= dist(tarinai, ownBed) < 120 ? 22 : 8; + } + if (!ownPlushie) needs.fulfill += 16; + else needs.fulfill -= ownPlushie.carriedById === tarinai.id ? 28 : 14; + if (findNearestItemWithRole(world, tarinai, "grassMaterial", 420)) needs.fulfill += 12; + for (const key of TARINAI_NEED_KEYS) { + const shock = Number(tarinai.needShock?.[key] || 0) || 0; + if (shock !== 0) needs[key] += shock; + } + needs.safety *= clamp(1 - Math.max(0, aggression) * 0.12 + Math.max(0, neuroticism) * 0.16, 0.75, 1.25); + needs.health *= clamp(1 + Math.max(0, neuroticism) * 0.14, 0.9, 1.22); + needs.fulfill *= clamp(1 + Math.max(0, openness) * 0.14, 0.9, 1.24); + for (const key of TARINAI_NEED_KEYS) needs[key] = quantizeNeed(needs[key]); + tarinai.previousNeeds = tarinai.needs ? { ...tarinai.needs } : createDefaultNeeds(); + tarinai.needs = needs; + tarinai.stress = calculateStressFromNeeds(needs); + if (tarinai.needShock) { + for (const key of TARINAI_NEED_KEYS) { + const shock = Number(tarinai.needShock[key] || 0) || 0; + tarinai.needShock[key] = shock > 0 ? Math.max(0, shock - 12) : Math.min(0, shock + 12); + } + } + return needs; +} + +const TARINAI_ACTIONS = [ + { id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50, condition(t, world) { return !!findNearestItemWithRole(world, t, "food", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "food", 560), "seek_food", this.label); } }, + { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 24, condition(t, world) { return !!findNearestItemWithRole(world, t, "drink", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "drink", 560), "seek_water", this.label); } }, + { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, condition(t, world) { return !!findNearestSleepPlace(world, t, 560); }, run(t, world) { const bed = findNearestSleepPlace(world, t, 560); if (bed && dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); bed?.use?.(t, world); t.startSleeping?.(bed, this.label); return true; } }, + { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 18, condition() { return true; }, run(t) { t.startSleeping?.(null, this.label); return true; } }, + { id: "use_medicine", need: "health", label: "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u56de\u5fa9\u3057\u305f", weight: 34, condition(t, world) { return !!findNearestItemWithRole(world, t, "medicine", 620); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "medicine", 620), "seek_food", this.label); } }, + { id: "rest_to_recover", need: "health", label: "\u56de\u5fa9\u306e\u305f\u3081\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 24, condition() { return true; }, run(t) { t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); t.energy = clamp((t.energy || 0) + 1.6, 0, 100); return true; } }, + { id: "flee", need: "safety", label: "\u5371\u306a\u3044\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b", phrase: "\u96e2\u308c\u305f", weight: 40, condition(t, world) { return !!findNearbyDanger(world, t, 220); }, run(t, world) { const danger = findNearbyDanger(world, t, 220); t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); return true; } }, + { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u96a0\u308c\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 30, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, + { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, condition(t, world) { return !!(t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420)); }, run(t, world) { const other = t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420); return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "approach_parent_or_child", need: "social", label: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u305f", weight: 28, condition(t) { return !!t.parentToFollow?.(); }, run(t) { return moveToOrUse(t, t.parentToFollow?.(), "follow_parent", this.label); } }, + { id: "approach_mate", need: "social", label: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u305f", weight: 24, condition(t, world) { return (t.reproductionTimer || 0) <= 8 && !!world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); }, run(t, world) { const other = world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); if (!other) return false; if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 12, condition(t) { return !!t.recentFightRival?.(80, 180); }, run(t) { const rival = t.recentFightRival?.(80, 180); t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 1.6); return true; } }, + { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 24, condition(t, world) { return !!world.nearest?.(t, ["ball"], 520); }, run(t, world) { return moveToOrUse(t, world.nearest?.(t, ["ball"], 520), "play_ball", this.label); } }, + { id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, condition(t, world) { return !findOwnedStructure(world, t, "grass_bed") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); } }, + { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 28, condition(t, world) { return !findOwnedStructure(world, t, "plushie") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); } }, + { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 22, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, + { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f", weight: 18, condition(t, world) { return !!findOwnedStructure(world, t, "plushie"); }, run(t, world) { const plushie = findOwnedStructure(world, t, "plushie"); plushie?.use?.(t, world); t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); return true; } }, + { id: "wander_lightly", need: "fulfill", label: "\u5c11\u3057\u6b69\u3044\u3066\u3044\u308b", phrase: "\u5c11\u3057\u6b69\u3044\u305f", weight: 14, condition() { return true; }, run(t) { t.wanderAngle += rand(-1.7, 1.7); t.goIdle?.(this.label); return true; } }, +]; + +function buildOwnedStructureFromGrass(t, world, type, label) { + const material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material || !globalThis.StructureRegistry) return false; + t.buildPlan = { + type, + materialId: material.id, + timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4), + }; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label); + t.setActionState?.("build", { target: material, reason: label, sleeping: false }); + return true; +} + +function continueBuildPlan(t, world, dt) { + const plan = t.buildPlan; + if (!plan?.type || !globalThis.StructureRegistry) return false; + if (findOwnedStructure(world, t, plan.type)) { + t.buildPlan = null; + return false; + } + let material = (world?.items || []).find(item => item && !item.dead && item.id === plan.materialId && roleMatches(item, "grassMaterial")); + if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material) { + t.buildPlan = null; + return false; + } + plan.materialId = material.id; + const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269"; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) { + moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`); + return true; + } + plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1)); + t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false }); + if (plan.timer > 0) return true; + consumeGrassMaterial(material); + const structure = globalThis.StructureRegistry.create(plan.type, t, t.x + rand(-18, 18), t.y + rand(16, 28), world); + if (!structure) { + t.buildPlan = null; + return false; + } + world.items.push(structure); + world.drawListDirty = true; + structure.use?.(t, world); + t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); + applyNeedRelief(t, { fulfill: -(plan.type === "plushie" ? 50 : 36), safety: plan.type === "plushie" ? -4 : -8 }); + t.buildPlan = null; + return true; +} + +function weightedPickAction(actions) { + const total = actions.reduce((sum, action) => sum + Math.max(0.01, Number(action.weight) || 1), 0); + let roll = Math.random() * total; + for (const action of actions) { + roll -= Math.max(0.01, Number(action.weight) || 1); + if (roll <= 0) return action; + } + return actions[0] || null; +} + +function chooseActionForNeed(need, tarinai, world) { + const actions = TARINAI_ACTIONS.filter(action => action.need === need && (!action.condition || action.condition(tarinai, world))); + return weightedPickAction(actions) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); +} + (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js"); @@ -56,7 +375,7 @@ if (this.fightDisease) return "stress_dizzy"; if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak"; if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); - if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; + if (this.type === "cry" && (this.stress > 78 || this.hunger > 96 || this.energy < 12 || (this.needs?.safety || 0) >= 90)) return "cry"; if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat"; if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat"; if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt"); @@ -68,7 +387,7 @@ if (this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate"); if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear"); if (this.state === "fight" || this.fightTimer > 0.04) return "angry"; - if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); + if (this.stress > 62) return this.variantSprite("stress"); if (low) return low; if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); if (this.surpriseTimer > 0.08) return "pokan"; @@ -76,9 +395,9 @@ if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back"; if (this.state === "seek_food") return "drool"; - if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); - if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; - if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; + if (this.stress < 24 && (this.needs?.food || 0) < 60 && (this.needs?.social || 0) < 60 && (this.needs?.fulfill || 0) < 60) return this.variantSprite("low_stress"); + if (this.stress < 44 && (this.needs?.food || 0) < 60) return this.goodMode; + if ((this.needs?.food || 0) > 70 || (this.needs?.social || 0) > 80) return "angry"; return this.goodMode || this.variantSprite("normal"); }, @@ -104,8 +423,8 @@ if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false; if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false; if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false; - if (this.mood < 46 || this.stress > 64) return false; - return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); + if (this.stress > 64 || (this.needs?.safety || 0) >= 70) return false; + return (this.needs?.social || 0) < 70 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); }, sunbathSpriteId() { @@ -135,24 +454,24 @@ 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] }); + if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("\u65e5\u5149\u6d74\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u3063\u305f") || healed; + if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("\u65e5\u5149\u6d74\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f") || healed; + if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("\u65e5\u5149\u6d74\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f") || healed; + if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("\u65e5\u5149\u6d74\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f") || healed; + if (healed) this.world?.log?.(`${this.name}\u306f\u65e5\u5149\u6d74\u3067\u75c5\u6c17\u304c\u8efd\u304f\u306a\u3063\u305f\u3002`, "event", { participants: [this] }); return healed; }, startSunbath() { if (!this.canStartSunbath()) return false; - this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false }); + this.setActionState?.("sunbath", { target: null, reason: "\u65e5\u306a\u305f\u307c\u3063\u3053", sleeping: false }); this.sunbathTimer = rand(6, 14); this.sunbathCooldown = rand(16, 34); this.sunbathFrameTimer = 0; this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); this.sunbathRecoveryChecked = false; this.tryRecoverBySunbath(); - if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("ぽかぽか", 4.0, "rgba(112,86,36,0.78)"); + if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("\u307d\u304b\u3063", 4.0, "rgba(112,86,36,0.78)"); return true; }, @@ -164,7 +483,7 @@ 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); + applyNeedRelief(this, { safety: -dt * 6, fulfill: -dt * 4 }); this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0; if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { @@ -180,7 +499,7 @@ if (d > touch) return; const p = clamp(1 - d / touch, 0, 1); const relief = dt * (1.35 + 2.65 * p); - this.stress = clamp((this.stress || 0) - relief, 0, 130); + applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 }); this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); @@ -222,7 +541,7 @@ this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); if (this.fightMochiTimer > 0.04) { - this.stress = clamp(this.stress + dt * 0.11, 0, 130); + applyNeedShock(this, { safety: dt * 4 }); if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); } if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; @@ -246,13 +565,13 @@ } 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 === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 }); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 }); 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); } + if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); + else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } } } const wasFighting = this.fightTimer > 0.04; @@ -297,19 +616,19 @@ this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; - this.stress += tooCold * dt * 0.25; + // stress is derived from needs; no independent stress decay. + if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); const phase = this.world.lightLevel(); if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) this.stress += dt * 0.04; + if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 }); if (this.world.weather === "cloudy") { - this.stress -= dt * 0.018; + applyNeedRelief(this, { safety: -dt * 1 }); this.vx *= Math.pow(0.996, dt * 60); this.vy *= Math.pow(0.996, dt * 60); } else if (this.world.weather === "light_rain") { this.energy += dt * 0.018; - this.stress -= dt * 0.025; + applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 }); } else if (this.world.weather === "sunny") { this.vx *= Math.pow(1.001, dt * 60); this.vy *= Math.pow(1.001, dt * 60); @@ -320,7 +639,7 @@ this.hunger = clamp(this.hunger, 0, 115); this.loneliness = clamp(this.loneliness, 0, 110); this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); + this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); @@ -371,14 +690,14 @@ applyEnvironment(dt) { const parent = this.parentToFollow ? this.parentToFollow() : null; if (parent && dist(this, parent) < 96) { - this.stress = clamp(this.stress - dt * 0.42, 0, 130); + applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 }); this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); } const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; if (friend) { - this.stress = clamp(this.stress - dt * 0.36, 0, 130); + applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 }); this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); @@ -395,9 +714,9 @@ if (!it || it.dead || it.type !== "nest_box") continue; nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); } - if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130); + if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) }); if (grassComfort > 0) { - this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); + applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) }); if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); } @@ -418,10 +737,11 @@ }, isSleepFurniture(target) { - return Boolean(target && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))); + return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box")))); }, sleepFurnitureLabel(target) { + if (target?.label) return target.label; return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; }, @@ -513,11 +833,11 @@ if (!z || z.stage !== "fresh") return; const d = dist(this, z); const push = clamp(1 - d / 90, 0, 1); - this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { safety: push * dt * 6 }); if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop."); this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40; this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40; - if (push > 0.35 && (this.state === "idle" || !this.thought)) this.thought = "新鮮なずんちを避けている"; + if (push > 0.35 && (this.state === "idle" || !this.thought)) this.thought = "\u65b0\u9bae\u306a\u305a\u3093\u3061\u3092\u907f\u3051\u3066\u3044\u308b"; }, resolveNeeds(dt) { @@ -527,18 +847,18 @@ this.sleeping = false; if (this.birthRitualTimer > 0.04) { - this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "近くで体をゆすっている" }); + this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b" }); return; } if (this.intimidateTimer > 0.04) { - this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "相手を威嚇している" }); + this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); this.bubble("!", 1.2); return; } if (this.fightTimer > 0.04) { - this.setActionState?.("fight", { reason: "喧嘩している" }); + this.setActionState?.("fight", { reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; let best = null, bestD = Infinity; @@ -548,7 +868,7 @@ const d = dist(this, t); if (d < bestD) { best = t; bestD = d; } } - this.setActionState?.("fight", { target: best, reason: "喧嘩している" }); + this.setActionState?.("fight", { target: best, reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); this.bubble("!", 1.6); return; } @@ -568,265 +888,55 @@ const pin = this.currentLodgedPin?.() || null; const pinBehavior = this.currentLodgedPinBehavior?.() || null; if (pin && pinBehavior?.panicOnAttach) { - if (this.enterPanic) this.enterPanic({ threat: pin, reason: "画鋲が刺さってパニックになっている", fear: 0.60, hurtTimer: 0.24, awakeLockTimer: 1.8, bubble: "!!", bubbleColor: "rgba(168,72,72,0.82)", cause: "pushpin_stuck" }); - else { this.setActionState?.("panic", { target: this.panicDestination(pin), reason: "画鋲が刺さってパニックになっている", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.60); } + this.needShock = this.needShock || {}; + this.needShock.safety = Math.max(this.needShock.safety || 0, 42); + this.needShock.health = Math.max(this.needShock.health || 0, 46); + this.lastNeedShockBreaker = pin; this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - return; } this.stuckPushpinId = null; } - - const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; - const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || []) - .find(o => o && o !== this && !o.dead && !this.world?.isTarinaiHiddenInNestBox?.(o) && o.state === "panic" && (o.fearTimer || 0) > 0.12); - if (panicSource && !["panic", "sleep", "seek_bed", "fight", "birth_ritual", "eat", "sunbath"].includes(this.state)) { - const d = distXY(this.x, this.y, panicSource.x, panicSource.y); - const neuro = typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (this.currentPersonality?.neuroticism || 0); - const distFactor = clamp(1 - d / 128, 0, 1); - const stressFactor = clamp(((this.stress || 0) - 35) / 80, 0, 0.22); - const chance = clamp(0.10 + Math.max(0, neuro) * 0.36 + distFactor * 0.18 + stressFactor, 0.05, 0.76); - if (Math.random() < chance) { - if (this.enterPanic) this.enterPanic({ threat: panicSource.target || panicSource, reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, fear: 0.75, stress: 3 + distFactor * 5, stressThreshold: 6, cause: "nearby_panic_chain" }); - else { this.setActionState?.("panic", { target: this.panicDestination(panicSource.target || panicSource), reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.75 * this.personalityProfile().fear); this.addStress ? this.addStress(3 + distFactor * 5, { threshold: 6 }) : (this.stress = clamp((this.stress || 0) + 3 + distFactor * 5, 0, 130)); } - return; + + if (continueBuildPlan(this, this.world, dt)) return; + + const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); + const needs = updateNeeds(this, this.world); + const breaker = this.lastNeedShockBreaker || null; + const safetyDelta = (needs.safety || 0) - (beforeNeeds.safety || 0); + const healthDelta = (needs.health || 0) - (beforeNeeds.health || 0); + const fulfillDelta = (needs.fulfill || 0) - (beforeNeeds.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + const aggression = Number(this.currentPersonality?.aggression || 0); + if (breaker && aggression > 0.6) { + this.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); + this.intimidateTimer = Math.max(this.intimidateTimer || 0, 2.0); + } else { + this.setActionState?.("panic", { target: this.panicDestination?.(breaker), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true, sleeping: false }); + this.fearTimer = Math.max(this.fearTimer || 0, 2.6); } - } - - const nearbyAnts = this.world.nearbyAnts?.(this.x, this.y, 112, true) || []; - let nearestAnt = null; - let nearestAntDist = Infinity; - for (const ant of nearbyAnts) { - const ad = distXY(this.x, this.y, ant.x, ant.y); - if (ad < nearestAntDist) { - nearestAnt = ant; - nearestAntDist = ad; - } - } - if (nearestAnt) { - const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1); - if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { - if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { - // Intimidation has no effect on ants. This is only a visible reaction. - this.setActionState?.("ant_intimidate", { target: nearestAnt, reason: "アリを威嚇している" }); - this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); - return; - } - this.setActionState?.("ant_attack", { target: nearestAnt, reason: "アリを攻撃している" }); - this.goodMode = "angry"; - return; - } - if (this.enterPanic) this.enterPanic({ threat: nearestAnt, reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", fear: 0.34, wake: true, cause: "ant_seen" }); - else this.setActionState?.("panic", { target: this.panicDestination(nearestAnt), reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", wake: true }); + this.lastNeedShockBreaker = null; return; } - - const dayP = this.world.dayProgress(); - const personalSleep = this.sleepStart < this.sleepEnd - ? dayP >= this.sleepStart && dayP < this.sleepEnd - : dayP >= this.sleepStart || dayP < this.sleepEnd; - - if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o)); - if (opponent) { - const d = dist(this, opponent); - if (d <= Math.max(48, this.radius + opponent.radius + 18)) { - if (this.world.startForcedFight?.(this, opponent)) return; - } - this.setActionState?.("seek_friend", { target: opponent, reason: "けんか餅で相手を探している" }); - this.goodMode = "angry"; - if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); - return; - } - } - - const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; - const feared = this.strongestRelation("fear"); - if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { - const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); - if (rival && dist(this, rival) < 150) { - if (this.enterPanic) this.enterPanic({ threat: rival, reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, fear: 0.35, wake: true, cause: "avoid_rival" }); - else this.setActionState?.("panic", { target: this.panicDestination(rival), reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, wake: true }); - this.world.relationNotice(this.id, rival.id, "avoid", 22); - return; - } - } - - if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const water = this.world.nearest(this, ["water", "water_bowl"], 760); - if (water && !water.dead) { - this.setActionState?.("seek_water", { target: water, reason: this.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している" }); - return; - } - if (this.zunchiDisease) { - this.setActionState?.("zunchi_sick", { target: null, reason: "ずんち病で当てもなく彷徨っている" }); - if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); - return; - } - } - - if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const cureFood = this.world.nearest(this, ["sweet"], 700); - if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { - this.setActionState?.("seek_food", { target: cureFood, reason: this.fightDisease ? "きずつき病でずんだ餅を探している" : "爆発病でずんだ餅を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); - return; - } - } - - if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - this.setActionState?.("fight_sick", { target: null, reason: "きずつき病で当てもなく彷徨っている" }); - if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); - return; - } - - if (!this.isZunchiSlave) { - const medicineTypes = window.TarinaiFoodRegistry?.typesByInterest?.("medicine_low") || ["love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]; - const specialMochi = this.world.nearest(this, medicineTypes, wasSleeping ? 190 : 155); - const specialDist = specialMochi ? distXY(this.x, this.y, specialMochi.x, specialMochi.y) : Infinity; - const weakInterest = this.hunger > (wasSleeping ? 68 : 76) && (specialDist < 72 || Math.random() < dt * 0.028); - if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) { - this.setActionState?.("seek_food", { target: specialMochi, reason: `${toolLabel(specialMochi.type)}が少し気になっている` }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); - return; - } - } else if (this.hunger > 20) { - const zOnly = this.world.nearest(this, ["zunchi"], 520); - if (zOnly && zOnly.amount > 1.2) { - this.setActionState?.("seek_food", { target: zOnly, reason: "ずんちを探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - return; - } - } - - const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; - const zunda = this.world.nearest(this, ["sweet"], zundaRange); - const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); - const zundaReward = this.affection > 8 && this.hunger > 7; - if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { - this.setActionState?.("seek_food", { target: zunda, reason: "ずんだ餅を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); - return; - } - - if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { - if (this.enterPanic) this.enterPanic({ reason: "\u9003\u3052\u3066\u3044\u308b", fear: 0.65, wake: true, cause: "fear_or_stress" }); - else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u9003\u3052\u3066\u3044\u308b", wake: true }); - return; - } - - const sleepBias = this.personalityProfile().sleep; - const wakeLocked = (this.awakeLockTimer || 0) > 0 && !wasSleeping; - const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); - if (!wakeLocked && (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias)) { - let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; - if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; - if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); - if (bed && !wasSleeping && this.energy > 18) { - const spot = this.sleepSpotFor(bed); - if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { - this.setActionState?.("seek_bed", { target: bed, reason: `${this.sleepFurnitureLabel(bed)}へ向かっている` }); - return; - } - } - this.startSleeping?.(bed, bed ? (bed.type === "nest_box" ? "巣箱の中で眠っている" : "ベッドの近くで眠っている") : "眠っている"); - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - this.thought = bed ? (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b") : "\u7720\u3063\u3066\u3044\u308b"; - return; - } - - const pointer = this.world.pointer; - if (pointer?.inside) { - const pd = distXY(this.x, this.y, pointer.x, pointer.y); - if (pd < 170) { - const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; - const friendly = !hostile && this.isAwakeCursorFriendly(); - if (hostile) { - this.setActionState?.("cursor_enemy", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルを避けている" }); - this.fearTimer = Math.max(this.fearTimer, 0.18); - if (this.world.time > this.nextFearBubbleAt) { - this.nextFearBubbleAt = this.world.time + 2.4; - this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); - } - this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; - return; - } - if (friendly) { - this.setActionState?.("cursor_friend", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルについていく" }); - this.goodMode = "smile"; - this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - } - - const playTrait = this.personalityProfile().play ?? 1; - const openPlay = this.shouldApplyPersonalityBehavior("openness", 1); - const closedPlay = this.shouldApplyPersonalityBehavior("openness", -1); - const ballRange = openPlay ? 540 : (closedPlay ? 170 : 240 + 60 * playTrait); - const ball = this.world.nearest(this, ["ball"], ballRange); - if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { - const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0; - const baseChance = openPlay ? 0.74 : (closedPlay ? 0.025 : 0.08); - const movingBonus = clamp(ballSpeed / 120, 0, 0.42); - const socialBonus = this.loneliness > 48 ? 0.10 : 0; - if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { - this.setActionState?.("play_ball", { target: ball, reason: openPlay ? "ボールに引き寄せられている" : "転がるものが気になっている" }); - this.goodMode = "smile"; - return; - } - } - - const parent = this.parentToFollow(); - if (parent && !this.shouldAvoidTarget(parent)) { - const d = dist(this, parent); - if (d > 34 || (this.hunger < 78 && this.energy > 24)) { - this.setActionState?.("follow_parent", { target: parent, reason: "親についていく" }); - this.bubble("?", 4.0); - return; - } - } - - if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { - this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - return; - } - - if (this.hunger > 62) { - const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; - const item = this.world.nearest(this, itemTypes, 420); - if (item) { - this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - return; - } - } - - const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1); - const lonerSeek = this.shouldApplyPersonalityBehavior("sociability", -1); - if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { - const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); - if (other) { - this.setActionState?.("seek_friend", { target: other, reason: "仲間を探している" }); - return; - } - } - - if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { - if (this.enterPanic) this.enterPanic({ reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.55, wake: true, cause: "stress_panic" }); - else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - return; - } - - if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); - this.goIdle(`${this.need}がたりない`); + + const choice = chooseTopNeedRandom(needs); + let action = chooseActionForNeed(choice.need, this, this.world); + if (!action) action = TARINAI_ACTIONS.find(a => a.id === "wander_lightly"); + const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action); + this.intent = { + need: choice.need, + tiedNeeds: [...choice.tiedNeeds], + actionId: action?.id || "idle", + actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b", + reasonText, + }; + const ran = action?.run?.(this, this.world); + if (!ran) this.goIdle?.(this.intent.actionLabel); + this.thought = reasonText; + return; }, makePoop(force = 1) { @@ -835,8 +945,8 @@ 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); + this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b"; + if ((this.oshiriByoZunchiStock || 0) >= 6) applyNeedShock(this, { health: 8 }); return; } this.digest = rand(0.4, 1.6); @@ -864,7 +974,7 @@ if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating > 0) { - this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); const hungerRelief = this.foodHungerReliefFor(it.type, eating, it.type === "sweet" ? 0.75 : 0.95); @@ -872,7 +982,7 @@ this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; if (it.type === "sweet") { - this.stress = clamp(this.stress - eating * 0.42, 0, 130); + applyNeedRelief(this, { food: -eating * 0.4, fulfill: -eating * 0.2 }); this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); @@ -899,7 +1009,7 @@ 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); if (eating <= 0) continue; - this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); const hungerRelief = this.foodHungerReliefFor(it.type, eating, 0.82); @@ -942,7 +1052,7 @@ if (canBite && it.type === "grass") { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); - this.beginEating(it, "草を食べている"); + this.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"); this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.vx *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60); @@ -951,7 +1061,7 @@ it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); this.recoverHealth(bite * 0.18); - this.stress -= dt * 0.5; + applyNeedRelief(this, { food: -dt * 2, fulfill: -dt * 1 }); this.eatTimer = Math.max(this.eatTimer, 0.22); this.eatCooldown = rand(0.68, 1.15); this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); @@ -968,13 +1078,13 @@ if (canBite && it.type === "ant_corpse") { const bite = Math.min(it.amount, rand(3.2, 5.4)); if (bite > 0) { - this.beginEating(it, "アリの死骸を食べている"); + this.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); this.recoverHealth(bite * 0.12); - this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { safety: bite * 0.12 }); this.eatTimer = Math.max(this.eatTimer, 0.26); this.eatCooldown = rand(0.75, 1.25); this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); @@ -990,20 +1100,20 @@ if (it.type === "zunchi") { const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); if (!this.isZunchiSlave) { - this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { health: touch * dt * 7, safety: touch * dt * 4 }); if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); } this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); if (canZunchiBite) { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている"); + this.beginEating(it, this.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18)); this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); - this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { health: bite * (this.isZunchiSlave ? 0.2 : 1.2), safety: bite * (this.isZunchiSlave ? 0.1 : 0.6) }); if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); this.lastMealColor = "#6b8d3f"; @@ -1018,7 +1128,7 @@ } if (it.type === "water" || it.type === "water_bowl") { - this.thought = it.type === "water_bowl" ? "水の皿で水を飲んでいる" : "水を浴びて落ち着いている"; + this.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; this.target = it; this.applyWaterEffect(dt, it.type); if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { @@ -1030,24 +1140,25 @@ } if (it.type === "stone") { - if (this.state === "idle" && (this.need === "あたたかさ" || this.stress > 48)) this.thought = "石の近くで落ち着いている"; - this.stress -= dt * 0.85; + if (this.state === "idle" && (this.need === "\u3042\u305f\u305f\u304b\u3055" || this.stress > 48)) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + applyNeedRelief(this, { safety: -dt * 4, fulfill: -dt * 2 }); this.energy += dt * 0.18; if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; } if (this.isSleepFurniture(it)) { if (this.energy < 74 + 6 * this.personalityProfile().sleep) { + it.use?.(this, this.world); const occ = this.world.bedOccupancy(it); const comfort = this.world.bedComfort(it); - this.startSleeping?.(it, it.type === "nest_box" ? "巣箱の中で休んでいる" : "寝床の近くで休んでいる"); + this.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u5e8a\u306e\u8fd1\u304f\u3067\u4f11\u3093\u3067\u3044\u308b"); if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; const crowded = occ > 5; this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); } this.energy += dt * (1.9 + comfort * 1.4); - this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); + if (occ > 5) applyNeedShock(this, { safety: dt * 3 }); else applyNeedRelief(this, { sleep: -dt * 6 * comfort, safety: -dt * 4 * comfort }); it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); } } @@ -1056,7 +1167,7 @@ const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { this.goodMode = "smile"; - this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); + applyNeedRelief(this, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) }); this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10); } @@ -1065,19 +1176,19 @@ if (it.type === "splat") { const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); if (touch > 0) { - this.stress = clamp(this.stress + touch * dt * 2.8 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { safety: touch * dt * 5, health: touch * dt * 3 }); if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); } } if (it.type === "trace") { this.loneliness -= dt * 0.25; - this.stress += dt * 0.06 * this.personalityProfile().fear; + applyNeedShock(this, { social: -dt * 1, safety: dt * 0.8 }); } } this.hunger = clamp(this.hunger, 0, 115); this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); + this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); this.loneliness = clamp(this.loneliness, 0, 110); } })); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index c8af767..8905b6e 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -35,12 +35,12 @@ nonSlave.vx += nx * 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 = "ずんちどれいを怖がっている"; } + if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044", 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 = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; } } else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; - nonSlave.thought = "ずんちどれいを怖がっている"; + nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; } nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); @@ -66,15 +66,15 @@ this.affection += dt * 0.28 * this.trait.social; this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby"); if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { - this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { social: -dt * 5 * this.trait.social, fulfill: -dt * 2 }); this.affection = clamp(this.affection + dt * 0.18, 0, 80); if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation", { participants: [o, this] }); } if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && this.world.relationNotice(this.id, o.id, "friend", 32)) { this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation", { participants: [o, this] }); } - if (this.type === "angry") this.stress += dt * 0.08; - if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; + if (this.type === "angry" && typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 1.5, safety: dt * 1 }); + if (this.type === "teary" && o.type === "teary" && typeof applyNeedRelief === "function") applyNeedRelief(this, { social: -dt * 2 }); } const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); @@ -86,8 +86,8 @@ this.vy -= ny * rand(24, 44); o.vx += nx * rand(34, 64); o.vy += ny * rand(34, 64); - this.stress = clamp(this.stress + 1.6, 0, 130); - o.stress = clamp(o.stress + 2.8, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 8, health: 4 }); + if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 }); const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; const damageToOther = this.outgoingDamage ? this.outgoingDamage(1.6) : 1.6; this.world.emit?.("fight:hit", { attacker: o, target: this, amount: damageToThis, cause: "\u55a7\u5629" }); @@ -106,7 +106,7 @@ o.fallDir = nx > 0 ? 1 : -1; this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); } - if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { + if (o.energy < this.energy - 8) { this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); } this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); @@ -122,7 +122,7 @@ const aggressiveOther = o.shouldApplyPersonalityBehavior?.("aggression", 1); const calmThis = this.shouldApplyPersonalityBehavior("aggression", -1); const calmOther = o.shouldApplyPersonalityBehavior?.("aggression", -1); - const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); + const fightRisk = (this.stress > 46 || o.stress > 46 || (this.needs?.safety || 0) > 60 || (o.needs?.safety || 0) > 60 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); @@ -145,7 +145,7 @@ } if (closeCount > 8) { - this.stress += dt * 0.9 * this.personalityProfile().fear; + if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 5, safety: dt * 3 * this.personalityProfile().fear }); this.energy -= dt * 0.25; this.fearTimer = Math.max(this.fearTimer, 0.18); } @@ -174,7 +174,7 @@ const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const crowdedSleep = bedCrowd > 5; this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); - this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, bedCrowd - 5) : -0.12 * bedComfort), 0, 130); + if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort }); this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); if (this.target && !this.target.dead && this.target.type === "nest_box") { this.updateNestBoxPresence(dt); @@ -259,7 +259,8 @@ ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); if (reachedSleepFurniture) { - this.startSleeping?.(this.target, this.target.type === "nest_box" ? "巣箱の中で休んでいる" : "眠る場所に着いたので休んでいる"); + this.target.use?.(this, this.world); + this.startSleeping?.(this.target, this.target.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u308b\u5834\u6240\u306b\u7740\u3044\u305f\u306e\u3067\u4f11\u3093\u3067\u3044\u308b"); if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); return; } @@ -366,14 +367,6 @@ else if (this.stress >= 124) { reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; } - else if (this.mood <= 20) { - const lowMood = clamp((20 - this.mood) / 20, 0, 1); - const bodyLoad = clamp((this.hunger - 72) / 42, 0, 1) * 0.018 + clamp((28 - this.energy) / 28, 0, 1) * 0.020 + clamp((this.stress - 86) / 44, 0, 1) * 0.022; - const deathRisk = 0.010 + lowMood * 0.060 + bodyLoad; - if (Math.random() < Math.max(0, dt) * deathRisk) { - reason = this.dominantDeathCause("", { lowMood: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - } - } if (reason) this.die(reason); }, diff --git a/js/ui_selected.js b/js/ui_selected.js index 1faf3f5..88b9048 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -192,8 +192,10 @@ function renderSelected() { personalityTags.join(",") ].join(","); const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : []; + const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const snapshot = [ t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), + t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), fmt(t.energy), fmt(t.stress), fmt(t.mood), t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), @@ -239,6 +241,7 @@ function renderSelected() { const basicHtml = `
\u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
\u72b6\u614b${escapeHtml(stateLabel(t.state))}
+
\u3044\u307e${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}
\u7406\u7531${escapeHtml(reasonLabel(t))}
\u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
\u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
@@ -247,13 +250,16 @@ function renderSelected() {
\u79fb\u52d5\u901f\u5ea6${geneticPercentText(t, "speed")}
\u30b5\u30a4\u30ba${geneticSizeText(t)}
`; + const needRowsHtml = needKeys.map(key => { + const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key; + const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); + const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : ""; + return `
${escapeHtml(label)}${escapeHtml(value)}
`; + }).join(""); const healthHtml = [ - selectedInfoRowHtml("\u7dcf\u5408\u7684\u6c17\u5206", fmt(t.mood), "mood"), - selectedInfoRowHtml("\u7a7a\u8179", fmt(t.hunger), "hunger"), - selectedInfoRowHtml("\u5bc2\u3057\u3055", fmt(t.loneliness), "loneliness"), + needRowsHtml, selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"), selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), - selectedInfoRowHtml("\u305a\u3093\u3061\u6c5a\u308c", fmt(t.zunchiStain || 0), "zunchi"), ].join(""); const itemEffectHtml = itemEffectSummary.length ? itemEffectSummary.map(e => `
${escapeHtml(e.label)}${escapeHtml(e.detail)}
`).join("") @@ -297,6 +303,7 @@ function stateLabel(s) { } function reasonLabel(t) { + if (t?.intent?.reasonText) return t.intent.reasonText; return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし"; } diff --git a/js/version.js b/js/version.js index ec564a1..0a7e113 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.0"; - const APP_BUILD = "safe-refactor-ui-fix"; + const APP_VERSION = "15.24.2"; + const APP_BUILD = "needs-compliance-fix"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 843e9b0..d1fd863 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -68,7 +68,7 @@ if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } } - t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); + if (t.addStress) t.addStress(rand(8, 15), { threshold: 8 }); t.fearTimer = Math.max(t.fearTimer, 0.58); const dx = t.x - x; const dy = t.y - y; @@ -76,7 +76,7 @@ if (d > blastRadius) continue; const p = clamp(1 - d / blastRadius, 0, 1); t.damage(5 + p * 18, "\u7206\u7af9"); - t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); + if (t.addStress) t.addStress(22 * p, { threshold: 8 }); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); @@ -106,6 +106,7 @@ 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 (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it); this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), }); @@ -166,7 +167,7 @@ if (d > blastRadius) continue; const q = clamp(1 - d / blastRadius, 0, 1); o.damage(5 + q * 18, "\u7206\u767a\u75c5"); - o.addStress ? o.addStress(22 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 22 * q, 0, 130)); + if (o.addStress) o.addStress(22 * q, { threshold: 8 }); let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { @@ -365,7 +366,7 @@ }, isSleepFurniture(it) { - return Boolean(it && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box"))); + return Boolean(it && (it.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box")))); }, isTarinaiHiddenInNestBox(t) { @@ -412,8 +413,10 @@ } return best; }; - // Prefer an available nest box, then fall back to a hay bed. - return choose("nest_box") || choose("bed"); + const owned = (this.nearbyItems(t.x, t.y, maxDist) || []) + .filter(bed => bed && !bed.dead && bed.ownerId === t.id && bed.roles?.sleepPlace) + .sort((a, b) => dist(t, a) - dist(t, b))[0] || null; + return owned || choose("nest_box") || choose("bed") || choose("grass_bed"); }, relationNotice(aId, bId, kind, cooldown = 30) { @@ -525,7 +528,7 @@ loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); winner.affection = clamp(winner.affection + 0.8, 0, 80); - winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -rand(12, 22), fulfill: -rand(8, 16) }); if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } @@ -574,7 +577,7 @@ t.damage(dealtDamage, reason); if (isGenkotsu) { t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); - t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 }); t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); t.thought = "げんこつがこわい"; } else { @@ -586,6 +589,7 @@ t.fallDir = nx >= 0 ? 1 : -1; hit += 1; } + if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it); const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.62 + p * 1.25), }); @@ -817,7 +821,7 @@ this.applyImpactDamage(t, damage, "\u885d\u7a81"); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); - t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); + if (t.addStress) t.addStress(stressGain, { threshold: 8 }); if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true }); t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); @@ -855,7 +859,7 @@ t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; - t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); + if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); if (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100); diff --git a/js/world_family_social.js b/js/world_family_social.js index c210a5a..fb3130c 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -730,8 +730,8 @@ b.fightTargetIds = b.fightTargetIds.slice(-4); a.fightTargetId = a.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0]; - a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); - b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); + if (a.addStress) a.addStress(rand(8, 18), { threshold: 8 }); + if (b.addStress) b.addStress(rand(8, 18), { threshold: 8 }); const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); a.damage(damageToA, "\u55a7\u5629"); diff --git a/service-worker.js b/service-worker.js index a062aa1..6430d70 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "15.24.0"; +const APP_VERSION = "15.24.2"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.