This commit is contained in:
33333-33333 2026-06-25 14:51:58 +09:00
commit 434f07cc4e
103 changed files with 8387 additions and 8152 deletions

View file

@ -1,54 +0,0 @@
# Lag optimization pass 16.00.01
## Intent
Observation-game pacing allows delayed non-critical scans. This pass removes dynamic load-quality checks and moves broad scans to fixed, low-frequency schedules.
## Changes
- Removed dynamic load-based quality calculation from hot paths.
- `performance_manager.js` is now a compatibility shim only.
- No workload count over tarinai / ants / items / effects is performed by performance quality.
- Main loop, renderer, DPR resize, rain density, tarinai render decorations, social scan limits, and item scan limits no longer call load quality.
- Tarinai decision cadence reduced.
- Normal AI/action resolution: about 1.0-1.35s per tarinai, staggered.
- Urgent AI state: about 0.35s.
- Need recalculation: about 1.0-1.35s normally.
- Environmental comfort scan: about 3.0-4.4s per tarinai, staggered.
- Non-critical item updates are scheduled.
- Realtime: balls, pins, firecrackers, falling genkotsu, moving items.
- Grass: about 3.0-5.2s staggered.
- Zunchi lifecycle: about 2.4s unless moving.
- Duplicator: about 2.0s.
- Water: about 1.8s.
- Trace/splat: about 1.2s.
- Serving-food passive decay: about 5.0s.
- Fully static items are not updated every frame.
- Spatial rebuilds are capped.
- `ensureSpatial()` now rebuilds at most once per frame.
- Movement or item changes after that use the previous grid until the next frame.
- Other broad scans are delayed or cached.
- Bed conflict check: 3.2s.
- High-speed tarinai collision scan: 1.8s.
- Compaction: 2.5s.
- Item/effect count rebuild: 2.0s or structural change.
- Out-of-bounds sanitation: 5.0s.
- Environment score cache: 3.0s.
- Terrain periodic dirtying: 3.0s.
- Stats UI interval: 2.5s.
- Ant nest management/spawn sync: 3.0s.
- ID/spatial replacements.
- Pushpin lodged owner lookup uses `liveTarinaiById()` instead of scanning tarinai.
- Loose pushpin hit testing uses `nearbyTarinai()` instead of full tarinai scan.
- Ant home nest lookup uses `itemById()`.
- Ant target choice uses `nearbyTarinai()`.
- Several tarinai target lookups use `liveTarinaiById()`.
## Validation
- `python3 scripts/regression_check.py` passed.
- `node --check` passed for all JavaScript files.

View file

@ -1,52 +0,0 @@
# 15.52.00 - 草上限復活・日光浴描画修正
- 草だけ上限を復活。上限到達後は、手動配置と自然繁殖の新規追加を止める。既存草は上限処理では削除しない。
- 日光浴開始位置をアンカー化し、発生後に中央へ寄らないよう固定。
- 日光浴スプライト描画を通常描画経路に一本化し、未ロード時は透明化せず読み込み済み候補または通常表情へフォールバック。
- 到達不能だった日光浴専用直接描画メソッドと、未使用のアイテム上限メソッドを削除。
# 15.24.53
- 排泄直後のずんちを通常個体が即座に食べないよう、ずんち専用の摂食保護時間を追加。
- 通常個体のずんち摂食条件をさらに厳格化し、他の食料が近い場合はずんちを食べないよう再調整。
- ずんちの風化・乾燥・分解・肥土化の進行速度と量減少速度を低下。
- 日光浴スプライトの切替を `updateSunbath` に一本化し、`spriteId()` が毎フレーム再抽選しないよう修正。
- 日光浴中はキャッシュ済み透明キャンバスではなく、読み込み済みの `sunbath` 画像を直接描画するよう修正。
# 15.24.52
- 日光浴中の専用直接描画を停止し、通常のたりない描画経路で `sunbath` スプライトを常時表示するよう修正。
- 日光浴中は通常描画直前の `spriteId()` で0.5秒ごとに `sunbath` 系スプライトをランダム切替するよう統一。
- 切替フレーム以外で日光浴スプライトが消える問題を抑止。
- 持ち主不明のかんたんベッドが放置で徐々に劣化・消滅する処理を追加。
# 15.24.52
- 日光浴中の描画を通常表情描画から分離し、毎フレーム必ず sunbath 系スプライトを直接描画するよう修正。
- 0.5秒ごとのランダム切替は維持しつつ、切替フレーム以外でスプライトが消える問題を抑止。
- sunbath画像が未ロードの瞬間も、読み込み済み候補またはフォールバックで描画を継続。
# 15.24.50
- 日光浴中のスプライトを、通常表情ロックとは別枠で固定。
- sunbath 系スプライトを初期ロード対象へ追加。
## 15.40.00 - アイテム上限廃止
- アイテム総数・種類別上限による自動削除を廃止。
- ずんち生成時に古いずんちを押し出し削除する処理を削除。
- 草・柵の配置数上限と、草の繁殖数上限を削除。
- compactItems() はプレイヤー削除・腐敗・枯死・摂食などで dead になったアイテムだけを片付ける。
## 15.56.00
- 日光浴中は通常描画経路へフォールバックしないようにし、描画座標を本体座標に固定。
- 日光浴終了後に sunbath sprite ID が通常描画へ残らないよう、visibleSpriteId と spriteLockUntil をリセット。
- 日光浴スプライト切替時は未ロード画像へ切り替えず、準備済みの直前スプライトまたは通常スプライトを同座標で描画して透明フレームを避ける。
## 16.00.00 - Sunbath voice finish
- Added `assets/sounds/voice_007_sunbath_pokapoka.wav` from the provided sunbath voice asset.
- Registered `voice_sunbath` in `sound_pack.js`.
- Routed bubbles containing `ぽか` through `audio.bubble(text)` to play `voice_sunbath` at the same moment the bubble is spawned.
- Kept the existing sunbath rendering stabilization untouched.

View file

@ -1,55 +0,0 @@
# Tarinai Colony Observation Game
Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.24.53`.
## Edit contract
- Preserve Japanese in-game UI tone and strings.
- Do not edit `docs/prompt.txt`; it is external user material.
- Use `app_manifest.json` as the source of truth for CSS/JS load order and service-worker cache entries.
- Run `python3 scripts/generate_app_files.py` after version or asset-list changes.
- Run `node --check js/*.js service-worker.js` before zipping.
- Verify ZIP integrity with `zip -T`.
## Documentation map
- `docs/ARCHITECTURE.md`: runtime architecture, file ownership, system boundaries.
- `docs/CONTENT_GUIDE.md`: item visuals, UI/field visual parity, audio, bubble text inventory.
- `docs/MAINTENANCE.md`: release process, validation, debugging, cache/version rules.
- `docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md`: 15.24.32 runtime polish summary before save migration.
- `docs/COMPACT_SAVE_V2_REPORT.md`: 15.24.33 compact save v2 summary.
- `docs/REMAINING_WORK_SPLIT.md`: work intentionally split after 15.24.33.
- `docs/prompt.txt`: untouched user prompt/context archive.
## Player-facing surface
- Startup uses a loading overlay that eagerly preloads a small sprite set, initializes UI/world state, then defers remaining image work.
- Field choices are cage, garden, and park. `FIELD_TYPES` controls world scale, starting population, initial grass count, and responsive field height.
- The default field is garden. Reset opens the field dialog; cancel keeps the current field.
- Tools are grouped in collapsible categories: operation/observation, food/water/grass, medicine/effects, habitat/living things, hazards/obstacles.
- The top bar owns pause, speed, ecology, sound, credits, and reset controls. Event push toasts live inside the Event card, while sound category toggles live only in the sound menu.
- The colony panel charts population, mental state, environment, and object counts. The family tree is a separate main section with manual/auto refresh and reset controls.
- Mobile operation modes are available only on compact touch-first phone viewports: auto, camera, tool, family.
## Core systems
- `World`: `js/world.js` plus `js/world_*.js`; time, weather, placement, effects, family records, spatial queries, ants, combat, logging.
- `Tarinai`: `js/tarinai.js` plus `js/tarinai_*.js`; identity, personality, needs, items, disease, movement, social behavior, rendering.
- `Item`: `js/items.js`; lifecycle, scaling, field rendering. Field rendering is the visual source of truth for tool icons.
- `ItemVisualRegistry`: `js/item_visual_registry.js`; canonical item visuals used by field sprites and generated icon previews.
- `EffectRegistry`: `js/effect_registry.js`; item-effect names, modifiers, exclusivity, display metadata, random-effect candidates.
- `DiseaseRegistry`: `js/disease_registry.js`; disease flags, labels, display metadata, cure-rule notes, mystery-drug eligibility.
- `FoodRegistry`: `js/food_registry.js`; serving classes, labels, decay, hygiene penalty, special effect IDs.
- `InputModeManager`: `js/input_mode_manager.js`; mobile/touch classification, gesture capability policy, mode persistence.
- `WeatherSystem`: `js/weather_system.js`; weather state transitions and rain-spawned water items.
- `Audio`: `js/sound_pack.js` owns sound metadata; `js/audio.js` loads samples, applies settings, enforces cooldowns, and runs synthetic fallbacks.
- `Performance`: `js/performance_manager.js`; FPS quality level, ant stride, effect budget, terrain redraw cadence.
- `Runtime item lookup`: `World.addItem()`, `itemsOfType()`, `itemById()`, and `ensureItemBuckets()` keep item caches coherent for population-scale runs.
## Current high-risk areas
- Item effects touch `effect_registry`, `tarinai_item_effects`, `tarinai_needs_items`, `items`, and selected-info UI.
- Mobile input must remain hidden on desktop-class devices; `TarinaiInputMode.snapshot().touchFirst` controls body class `touch-mobile-ui`.
- Weather rain overlay is diagonal line streaks. Fallen water items, zunda juice, and mercury share the same horizontal oval item-droplet silhouette; recolor only.
- Tool UI icons must be generated previews of `js/items.js` drawing helpers, never separate designs.

View file

@ -1,5 +1,5 @@
{
"version": "16.00.01",
"version": "16.07.09",
"css": [
"css/base.css",
"css/layout.css",
@ -10,23 +10,26 @@
"js": [
"js/version.js",
"js/event_bus.js",
"js/performance_manager.js",
"js/registry_base.js",
"js/disease_registry.js",
"js/effect_registry.js",
"js/sound_pack.js",
"js/item_registry.js",
"js/data.js",
"js/item_visual_registry.js",
"js/food_registry.js",
"js/ground_types.js",
"js/input_mode_manager.js",
"js/math.js",
"js/physics_helpers.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
"js/render.js",
"js/sim_core.js",
"js/structures.js",
"js/items.js",
"js/item_type_initializers.js",
"js/item_lifecycle_runtime.js",
"js/structure_lifecycle.js",
"js/item_render_runtime.js",
"js/ants.js",
"js/health.js",
"js/tarinai.js",
@ -36,6 +39,14 @@
"js/tarinai_action_state.js",
"js/tarinai_disease_nest.js",
"js/tarinai_item_effects.js",
"js/tarinai_needs_core.js",
"js/tarinai_behavior_text.js",
"js/tarinai_forced_behavior.js",
"js/tarinai_item_targeting.js",
"js/tarinai_consumable_behavior.js",
"js/tarinai_social_action_runtime.js",
"js/tarinai_building_behavior.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_social_move_life.js",
"js/tarinai_render.js",
@ -48,8 +59,12 @@
"js/world_spatial_budget.js",
"js/world_ants_system.js",
"js/weather_system.js",
"js/item_update_scheduler.js",
"js/world_update_phases.js",
"js/world_update.js",
"js/world_tool_actions.js",
"js/world_placement_log.js",
"js/world_event_effects.js",
"js/world_bootstrap.js",
"js/text_catalog.js",
"js/ui.js",
@ -57,12 +72,22 @@
"js/ui_helpers.js",
"js/ui_selected.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/save_codec.js",
"js/save_storage.js",
"js/save_system.js",
"js/patch_helpers.js",
"js/freeze_store.js",
"js/freeze_snapshot_adapter.js",
"js/freeze_panel.js",
"js/freeze_system.js",
"js/relation_event_system.js",
"js/ui_tooltips.js",
"js/ui_layout_dialogs.js",
"js/ui_ground.js",
"js/ui_tools.js",
"js/ui_input_shared.js",
"js/ui_input_touch.js",
"js/ui_input_mouse.js",
"js/ui_bind.js",
"js/ui_charts.js",
"js/ui_family_data.js",
@ -80,5 +105,5 @@
"assets/sounds/*.wav",
"assets/sounds/*.mp3"
],
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list."
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list. Item definitions are centralized in js/item_registry.js. Save compatibility migration is intentionally not retained. Snapshot restore is data-only; save and freeze are split into coordinator/codec/storage/panel/adapter modules. EventBus owns phase audio and log UI/audio notification side effects. World.update is phase-driven through js/world_update_phases.js; UI field input is split into shared/touch/mouse modules. Item construction, lifecycle, rendering, Tarinai targeting, ActionSpec definitions, consumable behavior, social action ticks, and building behavior are split into dedicated modules. Lightweight cleanup: item type constructor initialization is isolated; action selection bonus logic is shared; diagnostics no longer use removed performance-quality fields. Ground definitions, structure lifecycle, delete-tool actions, shared tooltips, and ground UI helpers are split into dedicated modules. FPS work: profiler, static/dynamic spatial buckets, item update scheduler, visible-cell rendering, chunked terrain cache, adaptive DPR, and render quality LOD are included."
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -351,16 +351,16 @@ h1 {
}
.tool {
--tool-main-icon-size: 34px;
--tool-main-icon-size: 30px;
--tool-watermark-size: 0px;
border-radius: 12px;
display: grid;
grid-template-rows: 31px minmax(1.05em, auto);
grid-template-rows: 27px minmax(1.05em, auto);
align-items: start;
justify-items: center;
gap: 1px;
padding: 4px 5px 5px;
min-height: 46px;
gap: 0;
padding: 3px 5px 4px;
min-height: 36px;
font-size: 12px;
line-height: 1.08;
letter-spacing: -0.015em;
@ -375,7 +375,7 @@ h1 {
content: "";
position: absolute;
left: 50%;
top: 4px;
top: 3px;
width: var(--tool-main-icon-size);
height: var(--tool-main-icon-size);
transform: translateX(-50%);
@ -408,15 +408,16 @@ h1 {
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
}
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-rows: 42px minmax(1.05em, auto); min-height: 58px; padding-top: 2px; }
.tool.tool-has-canvas-icon { --tool-icon: none; grid-template-rows: 34px minmax(1.05em, auto); min-height: 44px; padding-top: 1px; }
.tool.tool-has-canvas-icon::before,
.tool.tool-has-canvas-icon::after { background: none !important; }
.tool-sprite-preview {
position: absolute;
left: 50%;
top: -2px;
width: 56px;
height: 56px;
top: -5px;
width: 48px;
height: 48px;
transform: translateX(-50%);
pointer-events: none;
z-index: 1;
@ -539,3 +540,37 @@ h1 {
gap: 8px;
margin-top: 12px;
}
/* v16.07.08: right menu fixed quick tabs */
.panel-quick-tabs {
position: sticky;
top: 0;
z-index: 12;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 8px 4px 9px;
margin: -2px 0 2px;
background: linear-gradient(180deg, rgba(244, 250, 247, 0.96), rgba(244, 250, 247, 0.78));
border-bottom: 1px solid rgba(102, 169, 215, 0.34);
box-shadow: 0 8px 16px rgba(79, 103, 121, 0.10);
backdrop-filter: blur(8px);
}
.panel-quick-tabs button {
min-width: 0;
border: 1px solid rgba(102, 169, 215, 0.34);
border-radius: 999px;
padding: 7px 5px;
background: rgba(255,255,255,0.76);
color: var(--ink);
font-size: 11px;
line-height: 1.1;
font-weight: 700;
cursor: pointer;
box-shadow: 0 3px 9px rgba(68, 92, 110, 0.08);
}
.panel-quick-tabs button:hover,
.panel-quick-tabs button.active {
background: #efffde;
border-color: rgba(103,178,44,0.46);
}

View file

@ -431,17 +431,6 @@
to { transform: translateY(8px); opacity: 0; }
}
/* v15.12.0: loading / collapsible cards / sound settings */
.loading-screen {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 20px;
background: radial-gradient(circle at top, rgba(255,251,243,0.98), rgba(245,238,225,0.96) 56%, rgba(234,225,208,0.98));
transition: opacity .22s ease, visibility .22s ease;
}
.selected-info .info-row[data-metric] {
border-radius: 8px;
@ -662,3 +651,27 @@
justify-content: flex-start;
}
}
.ground-stat {
padding: 7px 8px;
}
.ground-type-btn {
width: 100%;
min-height: 28px;
border: 1px solid rgba(92, 142, 74, 0.28);
border-radius: 999px;
background: rgba(255,255,255,0.58);
color: inherit;
cursor: pointer;
font: inherit;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
}
.ground-type-btn:hover {
background: rgba(255,255,255,0.82);
transform: translateY(-1px);
}
.ground-type-btn strong {
display: inline-block;
font-size: 16px;
line-height: 1.2;
}

View file

@ -1,17 +1,3 @@
/* CSS split file; preserve manifest load order. */
.loading-screen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.loading-kicker { font-size: 12px; letter-spacing: .14em; color: rgba(116,96,76,.82); margin-bottom: 6px; }
.loading-title { margin: 0 0 8px; font-size: 28px; color: #3f3429; }
.loading-text { margin: 0 0 12px; color: rgba(90,74,58,.86); }
.loading-sprite-strip { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin: 8px 0 14px; }
.loading-sprite-card { border-radius: 16px; background: linear-gradient(180deg,rgba(255,255,255,.84),rgba(248,243,232,.90)); border: 1px solid rgba(126,104,80,.12); min-height: 92px; display: grid; place-items: center; padding: 8px; box-shadow: 0 8px 18px rgba(83,63,44,.06); }
.loading-sprite-card img { width: 68px; height: 68px; object-fit: contain; }
.loading-progress-track { width: 100%; height: 14px; border-radius: 999px; overflow: hidden; background: rgba(132,164,217,.16); border: 1px solid rgba(92,126,181,.14); }
.loading-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg,#92d1ff,#a3dfa0 58%,#ffe192); transition: width .18s ease; }
.loading-progress-meta { margin-top: 8px; display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: rgba(88,73,60,.82); }
.loading-hint { margin: 10px 0 0; font-size: 12px; line-height: 1.55; color: rgba(111,92,73,.78); }
.sound-category-controls,
.log-push-controls {
margin-top: 10px;
@ -47,7 +33,6 @@
.log-push-toast.log-push-grass::before { background: #8cc77a; }
.log-push-toast.leaving { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
@keyframes logPushIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 920px) { .loading-sprite-strip { grid-template-columns: repeat(2,minmax(0,1fr)); } }
/* v15.12.1: top-right sound settings panel */

View file

@ -1,77 +0,0 @@
# Architecture
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.24.33`.
## Load order
`app_manifest.json` owns CSS/JS order. Regenerate `index.html` and `service-worker.js` with `python3 scripts/generate_app_files.py` after version/order changes. `js/version.js` exposes `TARINAI_APP.version`, cache name, and query helper.
The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker have been removed. The family tree entry point now starts at `ui_family_data.js` and continues through async/layout/path/render modules.
## Runtime objects
- `World`: constructor in `js/world.js`; methods split into view, family/social, combat/effects, environment, spatial/budget, ants, update, placement/log, bootstrap.
- `Tarinai`: constructor in `js/tarinai.js`; methods split into identity/social, disease/nest, item effects, needs/items, social/move/life, render.
- Behavior text is composed from `behavior`, needs, tied needs, ActionSpec metadata, and forced-request cause metadata; stored explicit live text remains the display fallback to avoid player-visible wording churn.
- `Item`: item state, lifecycle, scaling, and field rendering in `js/items.js`.
- `Effect`: transient visuals in `js/sim_core.js`; combat/weather/food effects are spawned through `World` helpers.
- `TarinaiEvents`: global event bus for logs, terrain dirtiness, audio failures, phase/performance changes.
- `TarinaiPerformance`: FPS quality manager; provides effect caps, ant stride, terrain redraw cadence, UI intervals.
- `TarinaiInputMode`: input classification and mobile-mode state. UI code reads this manager instead of duplicating touch heuristics.
- `TarinaiWeatherSystem`: weather transitions and rain-spawned water item creation. Rain overlay drawing remains in `render.js`.
## Startup path
`main.js` owns the user-visible boot sequence:
1. Start the loading showcase and preload the initial sprite/image set through `loadImages`.
2. Bind UI, apply the default `garden` field layout, reset the world, and select `observe`.
3. Render logs, family archive, first frame, and stats.
4. Start deferred background image loading, enter `requestAnimationFrame(loop)`, and hide the loading screen.
5. Register `service-worker.js` with `updateViaCache: "none"` after window load.
## Data ownership
- `js/data.js`: base item/tool definitions, `TOOL_CATEGORIES`, config, image metrics, field presets.
- `js/item_visual_registry.js`: canonical item visual descriptors used by field drawing and generated tool-icon previews.
- `js/food_registry.js`: food/medicine semantics such as labels, serving classes, decay multipliers, and hygiene penalties.
- `js/disease_registry.js`: disease labels, flags, display metadata, cure-rule notes, and mystery-drug eligibility.
- `js/input_mode_manager.js`: touch/mobile classification, mobile mode persistence, and gesture capability policy.
- `js/weather_system.js`: weather state transitions and rain-water item spawning.
- `js/text_catalog.js`: Japanese UI copy/tooltips. Keep JS strings escaped where existing style uses escapes.
- `js/effect_registry.js`: item-effect labels, colors, modifiers, exclusivity/removal metadata, behavior callbacks (`onApply`, `onTick`, `onRemove`), direct eating context, and random-effect candidates.
- `js/sound_pack.js`: canonical sound metadata: IDs, categories, gain multipliers, cooldowns, optional sample paths, and ID-to-category mapping.
- `FIELD_TYPES` in `js/data.js`: cage/garden/park field labels, responsive heights, world scale, starting population, and initial grass.
## State loops
`main.js` starts asset preload, world bootstrap, animation loop, deferred image loading, service-worker registration. `World.update(dt)` advances time, weather, items, ants, Tarinai, effects, budgets, terrain dirtiness. `render()` draws cached background/terrain, visible entities, weather overlay, cards, HUD, and debug overlays.
World time is `CONFIG.dayLength` seconds per day. Version `15.24.33` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events.
Ant behavior remains intentionally conservative. `ANT_EXTENSION_HOOKS` names future ecology slots for nest hunger, nest growth, queen reproduction, worker lifecycle, carrying success, and territorial behavior, but this pass does not rebalance those mechanics.
## Spatial/performance rules
Use `World.nearby*` helpers rather than full-array scans in hot paths. Terrain cache uses dirty/version flags; do not reintroduce every-frame full terrain hashing. Performance degradation should be centralized in `performance_manager.js`.
Family tree rendering is intentionally asynchronous and cached. `ui_family_async.js` chunks component discovery with `requestIdleCallback` when available; `ui_family_render.js` hydrates cached HTML, keeps archive dirty/version state, and exposes `validateFamilyTree()` plus `familyTreeDiagnostics()` for browser-console checks.
## Basic ActionSpec migration
Version `15.24.33` keeps low-risk need actions, social/fear/combat/mate/birth actions, and sunbath registered through ActionSpec factories. The former configureTarinaiNeedActions late-binding bridge has been removed; the existing sunbath subsystem remains as the body-level timer implementation called by the ActionSpec.
## Runtime diagnostics
`World.runtimeDiagnostics()` exposes spatial rebuild counters, draw-list status, item bucket counts, and behavior counts. `?debug=1` renders these values in the existing debug overlay for manual browser regression checks.
## Social balance guards
Version `15.24.33` adds explicit guardrails around combat-heavy social behavior. Normal conflict must be dominant over bond/mate/family pressure before `fight_rival` can start, and `World.startFight()` / `startConflict()` / `startForcedFight()` use pair-level cooldowns plus already-fighting checks to prevent burst re-entry. Fight mochi remains a strong forced exception, but ordinary proximity conflict is intentionally lower than mate/friend pressure.
## Pre-save runtime polish
Version `15.24.33` keeps item type buckets and item id lookup as runtime caches owned by `World.updateItemCounts()`. Runtime item additions should go through `World.addItem(item, reason)` when practical so spatial, terrain, and item-bucket invalidation stay synchronized. Save migration is still intentionally deferred until this runtime shape has been browser-observed.

View file

@ -1,42 +0,0 @@
# Compact Save V2 Report — 15.24.33
## 方針
15.24.33 では旧セーブ互換を捨て、保存形式を `v: 2` に作り直した。
目的はハッシュテキストを短くすることであり、旧 `TARINAI_SAVE_V1:` / `tarinai_save_slot_v1_` は読み込み対象外。
## 削除した保存対象
- ログ履歴。
- 一時エフェクト。
- UI選択状態。
- 描画用キャッシュ、spatial/grid、bucket、terrain cache。
- `needDisplay` / `needRawBase` / `previousNeeds` など、読み込み後に再計算または近似できる値。
- 短命な表示タイマー、吹き出し文、内部デバッグ計測値。
- 死亡者を含む完全な家系アーカイブ履歴。生存個体の親子関係は index 参照として保存し、読み込み後に `recordFamily()` で再構築する。
## 残した保存対象
- world の field / day / time / weather / camera / generation などの進行状態。
- 生存中たりないの名前、見た目、性格、遺伝、位置、主要欲求、病気、親子参照、友好・恐怖関係。
- 生存中アイテムの type / 位置 / 量 / type別に必要な追加状態。
- アリの巣・アリの主要状態。
- 構造物の owner / hp / attachment などのプレイに関わる状態。
## ハッシュテキスト
- Prefix: `TN2!`
- Codec: `D` = `CompressionStream("deflate-raw")``R` = fallback raw bytes。
- Text encoding: printable ASCII 33〜126 のうち `*`, `'`, `_` を除いた91文字集合。
- Binary-to-text は base91 相当の13/14bit詰め。
## 注意
- 旧セーブは読めない。これは意図した仕様。
- 家系図の死亡者履歴は保存しない。読み込み後の家系図は生存個体ベースになる。
- `CompressionStream` 非対応環境では raw bytes を同じ91文字集合で符号化する。短縮率は下がるが読み書きは維持する。
## 15.24.37 修正
- エクスポート対象のたりないを生存個体のみに限定した。15.24.33 の方針では死亡者履歴を保存しない設計だったが、実装では `world.tarinai` 全体を書き出していたため、長期プレイほどハッシュテキストが不必要に肥大化していた。
- 読み込み後の死亡累計は world メタ情報から維持する。死亡者を含む完全な家系履歴は引き続き保存しない。

View file

@ -1,64 +0,0 @@
# Content and visual guide
## Visual source of truth
Do not invent tool-UI-only item designs. `js/item_visual_registry.js` is the canonical descriptor table for drift-prone item visuals; field drawing in `js/items.js` and generated previews must consume the same concepts. Tool icons in `assets/ui/tool_*.webp` are generated previews and must match field rendering. Update `scripts/generate_item_icons.py` when changing item helper silhouettes; regenerate icons before shipping.
Canonical helper mapping:
- Weather rain overlay: `drawRain` in `render.js`; diagonal blue line streaks only, never item droplets.
- Horizontal item droplet: `drawOvalWaterDropSprite`; used by fallen water items, zunda juice, mercury. Same silhouette; recolor only.
- Water bowl: `water_bowl` descriptor in `js/item_visual_registry.js`; bowl and inner horizontal water palette are registry-owned.
- Sleep drug: `drawSleepTabletSprite`.
- Laxative: `drawLaxativeTabletSprite`; zunchi-motif tablet.
- Mystery drug: `drawMysteryTabletSprite`; question-mark tablet.
- Ammo: `drawBulletSprite`; bullet silhouette.
- Protein/niteropu: powder-pile rendering in the parameter-item branch.
## Tool groups
`TOOL_CATEGORIES` in `js/data.js` owns the runtime-generated tool palette. CSS colors/icons are tied to `data-tool-category` and `data-tool` values:
- Operation/observation: observe, delete, poke, pinch, new, water hose.
- Food/water/grass: zunda mochi, love mochi, fight mochi, grass, water bowl, zunda juice.
- Medicine/effects: sleep drug, laxative, protein, niteropu, ammo, mystery drug, mercury, giant drug, dwarf drug.
- Habitat/living things: zunchi, bed, nest box, ant nest, ball.
- Hazards/obstacles: stone, genkotsu, firecracker, vertical fence, horizontal fence.
When adding or renaming a tool, update `TOOL_DEFINITIONS`, `TOOL_CATEGORIES`, `TEXT_CATALOG.toolTips`, CSS icon mapping, generated `assets/ui/tool_*.webp`, and docs. Do not duplicate category membership in `index.html`.
## Gameplay data registries
- Item effects: `TarinaiEffectRegistry` owns labels, display colors, modifiers, exclusivity/removal metadata, behavior callbacks, and mystery-drug candidates. Tarinai item-effect methods are the integration layer and should route apply/tick/remove behavior through the registry.
- Diseases: `TarinaiDiseaseRegistry` owns disease flags, labels, symptoms/display text, base infection chances, cure-rule notes, and mystery-drug eligibility.
- Food: `TarinaiFoodRegistry` owns labels, serving classes, nutrition, hunger relief, decay multipliers, hygiene penalties, and special effect IDs. Serving-food eating paths must read nutrition and hunger relief from this registry.
- Weather: `TarinaiWeatherSystem` owns weather transitions and rain-spawned water items. Rain visuals are diagonal line streaks only. Rain-spawned water items, water bowls, zunda juice, and mercury are separate item/object concepts.
- Ants: `ANT_EXTENSION_HOOKS` documents future ecology slots only. Do not change worker count, queen founding, carrying, or nest removal balance as part of registry/event cleanup.
## UI copy and labels
Most visible UI remains Japanese. Keep user-facing naming consistent: the former lifestyle chart label is now the mental-state label; selected Tarinai combat summary shows fight win rate, not cumulative defeats. The selected Tarinai data panel must show both daily personality-change causes and the rolling `recentChangeCauses` history.
## Bubble text inventory
Active Tarinai/world bubble text classes:
- Symbols: `!`, `?`, `♡`, `...`.
- Sleep: `Zzz...`.
- Eating/poop: short munching and poop sounds.
- Fear/fight: short panic, shout, and breathing sounds.
- Disease/recovery: short weak or relief sounds.
- Random small voice: short Tarinai voice variants.
- Item effects: `???`.
When adding bubbles, keep them short; every bubble may also trigger `audio.bubble`.
## Audio
Sound settings are in the top-right sound menu, not inside Event UI. Categories: Tarinai voice, notification, operation. `js/sound_pack.js` is the canonical metadata source for sound IDs, categories, gains, cooldowns, sample paths, and ID mapping; `js/audio.js` is the playback engine. Missing samples fall back to synthetic audio. Notification and operation sounds are intentionally louder than voice sounds.
The Event card owns in-page push toast settings for log kinds: death, accident, birth, fight, food, weather, grass, relation. Those settings are separate from audio categories and persist through localStorage.
## Ecology and credits
Ecology dialog card copy and images come from `TEXT_CATALOG.ecologyCards`; image paths should use real assets or generated WebP assets. Credits are hardcoded in `index.html`; update them when adding new external media sources.

View file

@ -1,45 +0,0 @@
# Maintenance
## Release process
1. Edit source files.
2. Bump `app_manifest.json` and `js/version.js` to the same version.
3. Regenerate static references: `python3 scripts/generate_app_files.py`.
4. Regenerate item icons if item visuals changed: `python3 scripts/generate_item_icons.py`.
5. Check versions: no stale `v=<old>` in `index.html`, CSS icon URLs, `service-worker.js`.
6. Validate syntax: `node --check js/*.js service-worker.js`. If Node is not installed on the local machine, document that explicitly in the handoff and use browser-console smoke checks instead.
7. Open `index.html` in a browser and verify startup, reset field selection, one tool placement, observe selection, family tree render, sound menu toggles, and `?debug=1`.
8. Zip from project root and run `zip -T`.
If `scripts/generate_app_files.py` cannot run locally, manually keep these in sync: `app_manifest.json`, `index.html` script/link tags, and `service-worker.js` `coreScriptNames`/CSS cache entries.
## Cache rules
`service-worker.js` must remain no-cache/no-store in `.htaccess`; versioned JS/CSS/WebP can be immutable. Register service worker with `updateViaCache: "none"`.
## Debugging
Use `?debug=1` for FPS, performance level, counts, cache/SW state, last audio ID, effect registry count, terrain/spatial version, and recent event-bus history.
Useful browser-console checks:
- `TARINAI_VERSION` and `TARINAI_APP.cacheName` confirm version/cache naming.
- `world.fieldType`, `world.tarinai.length`, `world.items.length`, and `world.ants.length` confirm the active simulation shape.
- `TarinaiEffectRegistry.debugSnapshot()` confirms registered item effects.
- `TarinaiDiseaseRegistry.debugSnapshot()` confirms disease metadata.
- `TarinaiFoodRegistry.debugSnapshot()` confirms food semantics.
- `toolCategories().map(c => c.id)` confirms the runtime tool palette registry.
- `TarinaiInputMode.snapshot()` confirms mobile classification and mode policy.
- `TarinaiEvents.recent(null, 20).map(e => e.type)` confirms domain-event emission.
- `validateFamilyTree()` checks reciprocal family links and rendered family tree node/link consistency.
- `familyTreeDiagnostics()` reports the latest async archive render state.
## Mobile input
The mobile mode controls are owned by `TarinaiInputMode` and are only for compact/no-hover/coarse-pointer touch-first devices. Desktop browsers, including touch-screen PCs, should not show the mobile controls unless the viewport or UA also indicates mobile/tablet.
The mode state is stored as `tarinai_mobile_input_mode_v1`; sound settings use `tarinai_audio_settings_v2`; log push settings use `tarinai_log_push_settings_v2`.
## Prompt archive
`docs/prompt.txt` is external user material. Do not rewrite, translate, trim, or delete it.

View file

@ -1,52 +0,0 @@
# Player-visible diff report: 15.24.20 initial ZIP vs 15.24.33
Comparison target: `tarinai_ui_revision_v15_24_20_pin_social_sign_polish(1).zip` → current build `15.24.33`.
## Verdict
No large UI/tool/item/save/family-tree specification change was detected. The intentional player-visible behavior change in this build is limited to social-action balance: ordinary fights and intimidation should occur less often, while friendship and reproduction should no longer be crowded out by conflict.
## Evidence
| Surface | Result | Notes |
|---|---|---|
| Top-level visible text in `index.html` | unchanged | Script tags/version params changed; visible UI copy is still the same. |
| Assets | unchanged | No sprite, object, UI icon, or sound asset change was introduced in this pass. |
| CSS | unchanged | No style-file change in this pass. |
| Tool/item definitions | unchanged | `js/data.js` tool/item definitions were not changed. |
| Tool UI binding | unchanged | No tool-list or operation binding change in this pass. |
| Item behavior/render definitions | unchanged | `items.js` was not changed. |
| Save/snapshot files | unchanged | Save migration remains intentionally deferred. |
| Family-tree UI modules | unchanged | Family-tree UI remains out of scope. |
| Tarinai social behavior | intentionally tuned | Conflict pressure and repeated-fight gates were adjusted to restore friend/mate/birth viability. |
## Files added since the original internal-structure pass
- `docs/REGRESSION_CHECKLIST.md`
- `docs/SOCIAL_BALANCE_FIX_REPORT.md`
- `js/tarinai_action_spec.js`
- `js/tarinai_behavior_state.js`
- `scripts/regression_check.py`
## Changed files with possible player-visible risk
| File | Risk | Assessment |
|---|---|---|
| `js/tarinai_needs_items.js` | Behavior selection and observation text | Social conflict is now less dominant; mate/friend actions are easier to select. |
| `js/world_family_social.js` | Fight/intimidation timing | Pair-level fight re-entry guard added; intimidation and fight durations shortened. |
| `js/tarinai_social_move_life.js` | Proximity conflict pressure | Ordinary close-contact fight probability reduced; fight mochi remains strong. |
| `js/render.js` | Draw ordering | Dynamic render stack remains cached for performance; possible minor y-sort delay under dense scenes. |
| `js/world_update.js` / `js/world_spatial_budget.js` | Simulation lookup performance | Spatial rebuild timing remains dirty/ensure style. |
| `service-worker.js` / `index.html` / `app_manifest.json` / `js/version.js` | Loading/cache | Version and script query params changed only. |
## Explicitly not changed
- No population cap was added.
- No family or generation cap was added.
- Family-tree UI was not modified.
- No tool category, tool name, item sprite, CSS layout, or field choice change was introduced.
- Save migration was not implemented in this pass.
## Residual risk
The remaining visible risk is balance rather than UI specification: social behavior should now produce fewer conflict loops, but exact rates still require browser playtesting with dense populations and multiple personality mixes.

View file

@ -1,45 +0,0 @@
# Regression Checklist
Version: 15.24.33
This pass freezes the current internal baseline before continuing deeper behavior refactors.
## Scope
- Static loading order must remain generated from `app_manifest.json`.
- `index.html` and `service-worker.js` must match the manifest after `scripts/generate_app_files.py`.
- JavaScript syntax must pass `node --check`.
- `ActionSpec` lifecycle helpers must support `selectTarget / canStart / start / tick / finish / fail`.
- Save-data migration is intentionally out of scope for this pass.
- Population caps and family-tree UI are intentionally out of scope.
## Command
```bash
python3 scripts/regression_check.py
```
Expected result: all checks print `[OK]` and exit with status 0.
## 15.24.28 runtime diagnostics
When opening with `?debug=1`, verify the overlay reports spatial rebuilds, draw-list state, item bucket/id-map state, forced/locked behavior counts, and top behavior ids. These are observation aids only and do not change player controls or simulation rules.
## Social balance regression checks
For `15.24.33+`, also verify:
- Ordinary conflict does not crowd out `approach_friend` / `approach_mate` in a neutral population.
- Fight mochi still forces conflict, but one individual should not start several fights at once.
- Intimidation resolves quickly enough that it does not visually lock the colony.
- With `?debug=1`, behavior counts should show non-conflict social actions appearing over time in a dense but non-drugged population.
## 15.24.33 runtime item-bucket checks
- Runtime-created items should go through `World.addItem()` where safe.
- `World.itemsOfType()` should call `ensureItemBuckets()` before returning a bucket.
- `World.itemById()` should not return dead items.
- The debug overlay should expose item bucket dirty state, id-map size, and bucket rebuild counters.
- Save migration remains out of scope until this runtime polish is observed in-browser.

View file

@ -1,46 +0,0 @@
# Remaining Work Split — after 15.24.32
## Bundled in 15.24.32
- Performance/runtime polish that does not alter save semantics.
- Item bucket/id-map coherence.
- Direct item creation helper consolidation.
- Regression guard expansion.
- Documentation update for the pre-save state.
## Still intentionally split
### 1. Browser long-run verification
Static checks pass, but long-run behavior still needs a real browser observation pass:
- 10+ minutes normal observation.
- Food/water/medicine/mochi placement.
- Friend formation, mate approach, birth ritual.
- Conflict/fight/intimidation cooldown behavior.
- Large population stress check with `?debug=1`.
### 2. Save migration
Do after the runtime shape is stable.
Target migration scope:
- Convert legacy `intent/currentAction/activeBehavior/activeForcedBehavior` save fields into `behavior`.
- Avoid saving transient display text and short-lived timers where possible.
- Keep old saves readable.
- Keep new saves simpler.
### 3. Compatibility-layer shrink
Only after save migration:
- Mark legacy behavior fields as deprecated runtime views.
- Remove direct write paths where no longer needed.
- Keep read-only compatibility only if old save support requires it.
## Not in scope by user preference
- Population cap.
- Family/generation cap.
- Family-tree UI rewrite.

View file

@ -1,63 +0,0 @@
# Runtime Polish Pre-Save Report — 15.24.32
This pass bundles the remaining low-risk internal cleanup that can be done before save-data migration.
## Scope
Included:
- Runtime item lookup coherence.
- Item bucket/id-map helper expansion.
- Common runtime item creation paths routed through `World.addItem()`.
- Regression guard updates.
- Final pre-save documentation.
Explicitly excluded:
- Save-data migration.
- Population or family caps.
- Family-tree UI performance work.
- Player-facing tool/UI redesign.
## What changed internally
`World.updateItemCounts()` now rebuilds both:
- `itemTypeBuckets: Map<string, Item[]>`
- `itemIdMap: Map<string, Item>`
The following helpers are available:
```txt
markItemBucketsDirty(reason)
ensureItemBuckets(reason)
addItem(item, reason, opts)
itemById(id)
itemsOfType(type)
liveItemsOfType(type, predicate)
```
This keeps high-frequency code from repeatedly scanning `world.items` for common lookups.
## Runtime creation paths routed through addItem
Common additions now dirty item buckets/spatial/terrain consistently:
- Player placement.
- Rain water drop.
- Grass spawn and grass seed growth.
- Zunchi burst/spawn.
- Ant corpse and ant nest creation.
- Tarinai death trace/splat/grass.
- Built owned structures.
- Field reset grass/stone.
## Player-visible impact
No intentional change to normal UI, tools, item definitions, population/family rules, family-tree UI, or save/load controls.
The only visible change is in `?debug=1`: item bucket dirty state, id-map size, and bucket rebuild counters are now exposed through runtime diagnostics.
## Remaining split
Save migration is still separated because it changes persistence semantics. It should run after this runtime structure has been observed in browser for basic stability.

View file

@ -1,28 +0,0 @@
# Social Balance Fix Report — 15.24.31
## Reported symptoms
- Tarinai rarely reproduced or formed friendships.
- Fights could start in rapid consecutive bursts.
- Intimidation lasted too long.
## Main cause
The ActionSpec social migration preserved the old combat hooks, but conflict pressure and same-priority social selection made `fight_rival` too competitive against `approach_friend` and `approach_mate`. Friend contact also routed into fight behavior whenever nearby conflict was detected, even when bond or mate pressure was stronger.
## Fixes
- Reduced ordinary conflict pressure and carried conflict weight.
- Lowered `fight_rival` and `intimidate_enemy` priority/weight.
- Raised `approach_mate` and `approach_friend` priority/weight.
- Required non-forced conflict to be genuinely dominant over bond/mate/family pressure.
- Added pair-level fight cooldown and already-fighting guards in `World.startFight()`, `startConflict()`, and `startForcedFight()`.
- Shortened intimidation and intimidation-defeat timers.
- Kept fight mochi as a high-priority forced behavior.
## Not changed
- No population cap.
- No family-generation cap.
- No family-tree UI work.
- No save migration.

View file

@ -1,860 +0,0 @@
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 0100.
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 010 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 tarinais 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 owners head
Behavior while attached:
- follow owner position
- render above owners 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 24 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 0100.
- Needs are quantized to 10-point steps.
- UI displays needs as 010.
- 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 owners 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.

View file

@ -3,18 +3,18 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<title>個体&#x89B3;&#x5BDF;</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Ctext y=%2752%27 font-size=%2752%27%3E%E3%81%9F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/base.css?v=16.00.01" />
<link rel="stylesheet" href="css/layout.css?v=16.00.01" />
<link rel="stylesheet" href="css/panel.css?v=16.00.01" />
<link rel="stylesheet" href="css/components.css?v=16.00.01" />
<link rel="stylesheet" href="css/mobile.css?v=16.00.01" />
<link rel="stylesheet" href="css/base.css?v=16.07.09" />
<link rel="stylesheet" href="css/layout.css?v=16.07.09" />
<link rel="stylesheet" href="css/panel.css?v=16.07.09" />
<link rel="stylesheet" href="css/components.css?v=16.07.09" />
<link rel="stylesheet" href="css/mobile.css?v=16.07.09" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
<div class="loading-panel">
<div class="loading-kicker">&#x305F;&#x308A;&#x306A;&#x3044;&#x30B3;&#x30ED;&#x30CB;&#x30FC;</div>
<div class="loading-kicker">個体&#x30B3;&#x30ED;&#x30CB;&#x30FC;</div>
<h1 class="loading-title">&#x8AAD;&#x307F;&#x8FBC;&#x307F;&#x4E2D;&#x2026;</h1>
<p id="loadingText" class="loading-text">&#x6E96;&#x5099;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;</p>
<div id="loadingSpriteStrip" class="loading-sprite-strip" aria-hidden="true"></div>
@ -28,19 +28,19 @@
<div class="top-left">
<a class="btn room-link" href="https://host.nishi.boats/~333/">333&#x306E;&#x90E8;&#x5C4B;&#x306B;&#x79FB;&#x52D5;</a>
<div class="brand">
<h1>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</h1>
<h1>個体&#x89B3;&#x5BDF;</h1>
</div>
</div>
<div class="top-actions">
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<button id="ecologyBtn" class="btn">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</button>
<button id="ecologyBtn" class="btn">個体&#x306E;&#x751F;&#x614B;</button>
<div id="soundMenu" class="sound-menu">
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">&#x97F3;&#x8A2D;&#x5B9A; ON</button>
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="&#x52B9;&#x679C;&#x97F3;&#x8A2D;&#x5B9A;">
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>&#x52B9;&#x679C;&#x97F3;</span></label>
<div class="sound-panel-divider"></div>
<label><input type="checkbox" data-sound-category="voice"><span>&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x58F0;</span></label>
<label><input type="checkbox" data-sound-category="voice"><span>個体&#x306E;&#x58F0;</span></label>
<label><input type="checkbox" data-sound-category="notify" checked><span>&#x901A;&#x77E5;</span></label>
<label><input type="checkbox" data-sound-category="ops" checked><span>&#x64CD;&#x4F5C;</span></label>
</div>
@ -53,7 +53,7 @@
<main class="layout">
<section class="stage-wrap">
<canvas id="gameCanvas" aria-label="&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<canvas id="gameCanvas" aria-label="個体&#x89B3;&#x5BDF;&#x30AD;&#x30E3;&#x30F3;&#x30D0;&#x30B9;"></canvas>
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="&#x30B9;&#x30DE;&#x30DB;&#x64CD;&#x4F5C;&#x30E2;&#x30FC;&#x30C9;">
<button type="button" data-mobile-mode="auto" class="active">&#x81EA;&#x52D5;</button>
<button type="button" data-mobile-mode="camera">&#x30AB;&#x30E1;&#x30E9;</button>
@ -64,9 +64,15 @@
</section>
<aside class="panel">
<nav id="panelQuickTabs" class="panel-quick-tabs" aria-label="右メニューのタブ">
<button type="button" data-panel-tab="selected">たりないデータ</button>
<button type="button" data-panel-tab="tools">道具</button>
<button type="button" data-panel-tab="colony">コロニー</button>
<button type="button" data-panel-tab="events">イベント</button>
</nav>
<section id="selectedCard" class="card selected-card hidden">
<div class="selected-head">
<h2>&#x305F;&#x308A;&#x306A;&#x3044;&#x30C7;&#x30FC;&#x30BF;</h2>
<h2>個体&#x30C7;&#x30FC;&#x30BF;</h2>
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="&#x89B3;&#x5BDF;&#x3092;&#x3084;&#x3081;&#x308B;">&times;</button>
</div>
<div id="selectedInfo" class="selected-info empty">&#x672A;&#x9078;&#x629E;</div>
@ -86,11 +92,12 @@
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x30B3;&#x30ED;&#x30CB;&#x30FC;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div class="stats colony-stats">
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x7D2F;&#x8A08;&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
<div class="stat"><span>&#x96F0;&#x56F2;&#x6C17;</span><strong id="statColonyMood">&#x306E;&#x3093;&#x3073;&#x308A;</strong></div>
<div class="stat"><span>個体</span><strong id="statPop">0</strong></div>
<div class="stat"><span>死亡</span><strong id="statDead">0</strong></div>
<div class="stat"><span>状態</span><strong id="statColonyMood">安定</strong></div>
<div class="stat ground-stat"><span>地面</span><button id="groundTypeBtn" class="ground-type-btn" type="button" aria-label="地面&#x3092;&#x304D;&#x308A;&#x304B;&#x3048;&#x308B;"><strong id="statGroundType">&#x571F;</strong></button></div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x7CBE;&#x795E;</button><button data-chart="environment">&#x74B0;&#x5883;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">個体数</button><button data-chart="life">欲求</button><button data-chart="environment">環境</button><button data-chart="objects">物量</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</div>
@ -151,7 +158,7 @@
<h2 id="creditsDialogTitle">&#x30AF;&#x30EC;&#x30B8;&#x30C3;&#x30C8;</h2>
<div class="credits-content">
<p><strong>&#x88FD;&#x4F5C;:</strong> 333</p>
<p><strong>&#x305F;&#x308A;&#x306A;&#x3044;:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F;</p>
<p><strong>個体:</strong> &#x3080;&#x3089;&#x3055;&#x304D;&#x6C0F;</p>
<p><strong>&#x52B9;&#x679C;&#x97F3;:</strong> VOICEVOX: &#x305A;&#x3093;&#x3060;&#x3082;&#x3093;&#x3000;&#x52B9;&#x679C;&#x97F3;&#x30E9;&#x30DC;: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
<p><strong>&#x30A2;&#x30EA;&#x7D20;&#x6750;:</strong> &#x3044;&#x3089;&#x3059;&#x3068;&#x3084;: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
</div>
@ -164,7 +171,7 @@
<div id="archiveResetDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="archiveResetDialogTitle">
<div class="field-dialog-panel archive-reset-dialog-panel">
<h2 id="archiveResetDialogTitle">&#x5BB6;&#x7CFB;&#x56F3;&#x3092;&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x3057;&#x307E;&#x3059;&#x304B;&#xFF1F;</h2>
<p class="hint">&#x73FE;&#x5728;&#x306E;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x3068;&#x4E16;&#x4EE3;&#x60C5;&#x5831;&#x3092;&#x6D88;&#x53BB;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x305D;&#x306E;&#x3082;&#x306E;&#x3084;&#x5EAD;&#x306E;&#x72B6;&#x614B;&#x306F;&#x6D88;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<p class="hint">&#x73FE;&#x5728;&#x306E;&#x89AA;&#x5B50;&#x95A2;&#x4FC2;&#x3068;&#x4E16;&#x4EE3;&#x60C5;&#x5831;&#x3092;&#x6D88;&#x53BB;&#x3057;&#x307E;&#x3059;&#x3002;&#x500B;&#x4F53;&#x305D;&#x306E;物量&#x3084;&#x5EAD;&#x306E;&#x72B6;&#x614B;&#x306F;&#x6D88;&#x3048;&#x307E;&#x305B;&#x3093;&#x3002;</p>
<div class="field-dialog-actions archive-reset-actions">
<button id="archiveResetCancelBtn" class="btn" type="button">&#x30AD;&#x30E3;&#x30F3;&#x30BB;&#x30EB;</button>
<button id="archiveResetConfirmBtn" class="btn danger" type="button">&#x30EA;&#x30BB;&#x30C3;&#x30C8;&#x3059;&#x308B;</button>
@ -174,7 +181,7 @@
<div id="ecologyDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="ecologyDialogTitle">
<div class="field-dialog-panel ecology-dialog-panel">
<h2 id="ecologyDialogTitle">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</h2>
<h2 id="ecologyDialogTitle">個体&#x306E;&#x751F;&#x614B;</h2>
<div id="ecologyGrid" class="ecology-grid"></div>
<div class="field-dialog-actions">
<button id="ecologyCloseBtn" class="btn" type="button">&#x9589;&#x3058;&#x308B;</button>
@ -183,69 +190,94 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=16.00.01" defer></script>
<script src="js/event_bus.js?v=16.00.01" defer></script>
<script src="js/performance_manager.js?v=16.00.01" defer></script>
<script src="js/registry_base.js?v=16.00.01" defer></script>
<script src="js/disease_registry.js?v=16.00.01" defer></script>
<script src="js/effect_registry.js?v=16.00.01" defer></script>
<script src="js/sound_pack.js?v=16.00.01" defer></script>
<script src="js/data.js?v=16.00.01" defer></script>
<script src="js/item_visual_registry.js?v=16.00.01" defer></script>
<script src="js/food_registry.js?v=16.00.01" defer></script>
<script src="js/input_mode_manager.js?v=16.00.01" defer></script>
<script src="js/math.js?v=16.00.01" defer></script>
<script src="js/physics_helpers.js?v=16.00.01" defer></script>
<script src="js/assets.js?v=16.00.01" defer></script>
<script src="js/audio.js?v=16.00.01" defer></script>
<script src="js/render.js?v=16.00.01" defer></script>
<script src="js/sim_core.js?v=16.00.01" defer></script>
<script src="js/structures.js?v=16.00.01" defer></script>
<script src="js/items.js?v=16.00.01" defer></script>
<script src="js/ants.js?v=16.00.01" defer></script>
<script src="js/health.js?v=16.00.01" defer></script>
<script src="js/tarinai.js?v=16.00.01" defer></script>
<script src="js/tarinai_action_spec.js?v=16.00.01" defer></script>
<script src="js/tarinai_behavior_state.js?v=16.00.01" defer></script>
<script src="js/tarinai_identity_social.js?v=16.00.01" defer></script>
<script src="js/tarinai_action_state.js?v=16.00.01" defer></script>
<script src="js/tarinai_disease_nest.js?v=16.00.01" defer></script>
<script src="js/tarinai_item_effects.js?v=16.00.01" defer></script>
<script src="js/tarinai_needs_items.js?v=16.00.01" defer></script>
<script src="js/tarinai_social_move_life.js?v=16.00.01" defer></script>
<script src="js/tarinai_render.js?v=16.00.01" defer></script>
<script src="js/world.js?v=16.00.01" defer></script>
<script src="js/world_view.js?v=16.00.01" defer></script>
<script src="js/family_graph.js?v=16.00.01" defer></script>
<script src="js/world_family_social.js?v=16.00.01" defer></script>
<script src="js/world_combat_effects.js?v=16.00.01" defer></script>
<script src="js/world_environment.js?v=16.00.01" defer></script>
<script src="js/world_spatial_budget.js?v=16.00.01" defer></script>
<script src="js/world_ants_system.js?v=16.00.01" defer></script>
<script src="js/weather_system.js?v=16.00.01" defer></script>
<script src="js/world_update.js?v=16.00.01" defer></script>
<script src="js/world_placement_log.js?v=16.00.01" defer></script>
<script src="js/world_bootstrap.js?v=16.00.01" defer></script>
<script src="js/text_catalog.js?v=16.00.01" defer></script>
<script src="js/ui.js?v=16.00.01" defer></script>
<script src="js/ui_log.js?v=16.00.01" defer></script>
<script src="js/ui_helpers.js?v=16.00.01" defer></script>
<script src="js/ui_selected.js?v=16.00.01" defer></script>
<script src="js/snapshot_system.js?v=16.00.01" defer></script>
<script src="js/save_system.js?v=16.00.01" defer></script>
<script src="js/patch_helpers.js?v=16.00.01" defer></script>
<script src="js/freeze_system.js?v=16.00.01" defer></script>
<script src="js/relation_event_system.js?v=16.00.01" defer></script>
<script src="js/ui_layout_dialogs.js?v=16.00.01" defer></script>
<script src="js/ui_tools.js?v=16.00.01" defer></script>
<script src="js/ui_bind.js?v=16.00.01" defer></script>
<script src="js/ui_charts.js?v=16.00.01" defer></script>
<script src="js/ui_family_data.js?v=16.00.01" defer></script>
<script src="js/ui_family_async.js?v=16.00.01" defer></script>
<script src="js/ui_family_layout.js?v=16.00.01" defer></script>
<script src="js/ui_family_paths.js?v=16.00.01" defer></script>
<script src="js/ui_family_render.js?v=16.00.01" defer></script>
<script src="js/main.js?v=16.00.01" defer></script>
<script src="js/debug_tools.js?v=16.00.01" defer></script>
<script src="js/version.js?v=16.07.09" defer></script>
<script src="js/event_bus.js?v=16.07.09" defer></script>
<script src="js/registry_base.js?v=16.07.09" defer></script>
<script src="js/disease_registry.js?v=16.07.09" defer></script>
<script src="js/sound_pack.js?v=16.07.09" defer></script>
<script src="js/item_registry.js?v=16.07.09" defer></script>
<script src="js/data.js?v=16.07.09" defer></script>
<script src="js/ground_types.js?v=16.07.09" defer></script>
<script src="js/input_mode_manager.js?v=16.07.09" defer></script>
<script src="js/math.js?v=16.07.09" defer></script>
<script src="js/physics_helpers.js?v=16.07.09" defer></script>
<script src="js/assets.js?v=16.07.09" defer></script>
<script src="js/audio.js?v=16.07.09" defer></script>
<script src="js/perf_profiler.js?v=16.07.09" defer></script>
<script src="js/render.js?v=16.07.09" defer></script>
<script src="js/sim_core.js?v=16.07.09" defer></script>
<script src="js/structures.js?v=16.07.09" defer></script>
<script src="js/items.js?v=16.07.09" defer></script>
<script src="js/item_type_initializers.js?v=16.07.09" defer></script>
<script src="js/item_lifecycle_runtime.js?v=16.07.09" defer></script>
<script src="js/structure_lifecycle.js?v=16.07.09" defer></script>
<script src="js/item_render_runtime.js?v=16.07.09" defer></script>
<script src="js/ants.js?v=16.07.09" defer></script>
<script src="js/health.js?v=16.07.09" defer></script>
<script src="js/tarinai.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_spec.js?v=16.07.09" defer></script>
<script src="js/tarinai_behavior_state.js?v=16.07.09" defer></script>
<script src="js/tarinai_identity_social.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_state.js?v=16.07.09" defer></script>
<script src="js/tarinai_disease_nest.js?v=16.07.09" defer></script>
<script src="js/tarinai_item_effects.js?v=16.07.09" defer></script>
<script src="js/tarinai_needs_core.js?v=16.07.09" defer></script>
<script src="js/tarinai_behavior_text.js?v=16.07.09" defer></script>
<script src="js/tarinai_forced_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_item_targeting.js?v=16.07.09" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=16.07.09" defer></script>
<script src="js/tarinai_building_behavior.js?v=16.07.09" defer></script>
<script src="js/tarinai_action_definitions.js?v=16.07.09" defer></script>
<script src="js/tarinai_needs_items.js?v=16.07.09" defer></script>
<script src="js/tarinai_social_move_life.js?v=16.07.09" defer></script>
<script src="js/tarinai_render.js?v=16.07.09" defer></script>
<script src="js/world.js?v=16.07.09" defer></script>
<script src="js/world_view.js?v=16.07.09" defer></script>
<script src="js/family_graph.js?v=16.07.09" defer></script>
<script src="js/world_family_social.js?v=16.07.09" defer></script>
<script src="js/world_combat_effects.js?v=16.07.09" defer></script>
<script src="js/world_environment.js?v=16.07.09" defer></script>
<script src="js/world_spatial_budget.js?v=16.07.09" defer></script>
<script src="js/world_ants_system.js?v=16.07.09" defer></script>
<script src="js/weather_system.js?v=16.07.09" defer></script>
<script src="js/item_update_scheduler.js?v=16.07.09" defer></script>
<script src="js/world_update_phases.js?v=16.07.09" defer></script>
<script src="js/world_update.js?v=16.07.09" defer></script>
<script src="js/world_tool_actions.js?v=16.07.09" defer></script>
<script src="js/world_placement_log.js?v=16.07.09" defer></script>
<script src="js/world_event_effects.js?v=16.07.09" defer></script>
<script src="js/world_bootstrap.js?v=16.07.09" defer></script>
<script src="js/text_catalog.js?v=16.07.09" defer></script>
<script src="js/ui.js?v=16.07.09" defer></script>
<script src="js/ui_log.js?v=16.07.09" defer></script>
<script src="js/ui_helpers.js?v=16.07.09" defer></script>
<script src="js/ui_selected.js?v=16.07.09" defer></script>
<script src="js/snapshot_system.js?v=16.07.09" defer></script>
<script src="js/restore_coordinator.js?v=16.07.09" defer></script>
<script src="js/save_codec.js?v=16.07.09" defer></script>
<script src="js/save_storage.js?v=16.07.09" defer></script>
<script src="js/save_system.js?v=16.07.09" defer></script>
<script src="js/patch_helpers.js?v=16.07.09" defer></script>
<script src="js/freeze_store.js?v=16.07.09" defer></script>
<script src="js/freeze_snapshot_adapter.js?v=16.07.09" defer></script>
<script src="js/freeze_panel.js?v=16.07.09" defer></script>
<script src="js/freeze_system.js?v=16.07.09" defer></script>
<script src="js/ui_tooltips.js?v=16.07.09" defer></script>
<script src="js/ui_layout_dialogs.js?v=16.07.09" defer></script>
<script src="js/ui_ground.js?v=16.07.09" defer></script>
<script src="js/ui_tools.js?v=16.07.09" defer></script>
<script src="js/ui_input_shared.js?v=16.07.09" defer></script>
<script src="js/ui_input_touch.js?v=16.07.09" defer></script>
<script src="js/ui_input_mouse.js?v=16.07.09" defer></script>
<script src="js/ui_bind.js?v=16.07.09" defer></script>
<script src="js/ui_charts.js?v=16.07.09" defer></script>
<script src="js/ui_family_data.js?v=16.07.09" defer></script>
<script src="js/ui_family_async.js?v=16.07.09" defer></script>
<script src="js/ui_family_layout.js?v=16.07.09" defer></script>
<script src="js/ui_family_paths.js?v=16.07.09" defer></script>
<script src="js/ui_family_render.js?v=16.07.09" defer></script>
<script src="js/main.js?v=16.07.09" defer></script>
<script src="js/debug_tools.js?v=16.07.09" defer></script>
</body>
</html>

View file

@ -107,27 +107,11 @@ const audio = {
categoryOn(category = "ops") {
return Boolean(this.categoryEnabled[category] ?? true);
},
gainFor(category = "ops", base = 1) {
return Math.max(0.1, Number(base) || 1) * (this.categoryGain?.[category] ?? 1);
},
sampleVolumeFor(key = "") {
const category = this.categoryFor(key, key.startsWith("voice") ? "voice" : "ops");
const base = category === "voice" ? 0.95 : 1.0;
return Math.min(1, base * Math.max(1, (this.categoryGain?.[category] ?? 1) * 0.35) * (this.idGain?.[key] ?? 1));
},
distanceGain(opts = {}) {
if (!opts || opts.category === "notify" || !Number.isFinite(opts.x) || !Number.isFinite(opts.y)) return 1;
const w = window.world;
if (!w || !w.worldToScreen) return 1;
const p = w.worldToScreen(opts.x, opts.y);
const margin = 140;
const vw = w.viewportW || w.w || 1000;
const vh = w.viewportH || w.h || 720;
if (p.x < -margin || p.y < -margin || p.x > vw + margin || p.y > vh + margin) return 0;
const cx = vw / 2, cy = vh / 2;
const d = Math.hypot(p.x - cx, p.y - cy);
return clamp(1 - d / Math.max(vw, vh) * 0.65, 0.35, 1);
},
beginVoice(dur = 0.1) {
this.activeVoices = (this.activeVoices || 0) + 1;
setTimeout(() => { this.activeVoices = Math.max(0, (this.activeVoices || 0) - 1); }, Math.max(60, dur * 1000 + 80));
@ -137,11 +121,6 @@ const audio = {
this.categoryEnabled[category] = Boolean(on);
this.saveSettings();
},
toggleCategory(category) {
if (!(category in this.categoryEnabled)) return false;
this.setCategory(category, !this.categoryEnabled[category]);
return this.categoryEnabled[category];
},
setEnabled(on) {
this.enabled = Boolean(on);
if (this.enabled) this.ensure();
@ -324,7 +303,6 @@ const audio = {
grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); },
drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); },
waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); },
waterClean() { this.play("sfx_water_clean", { category: "ops", minGap: 0.22 }); },
ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); },
stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); },
genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); },

View file

@ -46,150 +46,6 @@ const DECOR_ASSETS = [
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
];
const TOOL_DEFINITIONS = Object.freeze({
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
});
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]),
});
function itemHasTrait(type = "", trait = "") {
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
}
function itemTraitsFor(type = "") {
const out = [];
for (const [trait, set] of Object.entries(ITEM_TRAITS)) if (set.has(String(type || ""))) out.push(trait);
return out;
}
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
const items = worldRef?.items || tarinai.world?.items || [];
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
}
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
});
function pinBehaviorFor(type = "") {
return PIN_BEHAVIORS[String(type || "")] || null;
}
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
function toolCategories() {
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function toolDefinition(id = "") {
return TOOL_DEFINITIONS[id] || null;
}
function toolLabel(id = "") {
const def = toolDefinition(id);
return def?.label || id || "";
}
function toolItemType(id = "") {
const def = toolDefinition(id);
return def?.placeable ? (def.itemType || def.id) : null;
}
function itemDefinition(type = "") {
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
}
function itemRadiusFor(type = "", fallback = 12) {
return itemDefinition(type)?.radius ?? fallback;
}
function itemAmountFor(type = "", fallback = 80) {
return itemDefinition(type)?.amount ?? fallback;
}
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
function normalizedToolSizeFor(worldRef = null, type = "") {
const def = itemDefinition(type);
if (!def?.scalable) return "medium";
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
}
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
function scalableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
}
function toolTipsFromDefinitions() {
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
}
const ALPHA_BOUNDS = {
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
@ -237,7 +93,8 @@ const CONFIG = {
maxPopulation: Number.MAX_SAFE_INTEGER,
maxFenceCollisionChecks: 18,
effectLimit: 78,
grassLimit: 360,
grassLimit: 99,
grassLimitsByField: { cage: 19, garden: 99, park: 299 },
worldPadding: 30,
hungerPerMinute: 36.0,
lonelinessPerMinute: 7.6,

View file

@ -19,16 +19,16 @@
window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; });
window.setInterval(() => {
const w = window.world;
const perf = window.TarinaiPerformance?.debugSnapshot(w) || {};
const input = window.TarinaiInputMode?.snapshot?.() || {};
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
const behaviorCounts = diag.behavior?.counts || {};
const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", ");
const quality = {};
const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {};
const perfTop = (perf.entries || []).slice(0, 5).map(e => `${e.label}:${e.avg}ms`).join(", ");
panel.textContent = [
`tarinai v${window.TARINAI_VERSION || "?"}`,
`fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`,
`live ${diag.performance?.liveTarinai ?? (w?.tarinai || []).length} load-check removed dpr ${diag.performance?.dpr || uiCache?.canvasDpr || "?"}`,
`fps ${window.__tarinaiFps ?? "?"}`,
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} dpr ${diag.runtime?.dpr || uiCache?.canvasDpr || "?"}`,
`items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`,
`effects ${(w?.effects || []).length}`,
`terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`,
@ -36,12 +36,14 @@
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`,
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
`scheduler heap:${diag.scheduler?.heap ?? 0} realtime:${diag.scheduler?.realtime ?? 0} ran:${diag.scheduler?.lastRan ?? 0} rebuilds:${diag.scheduler?.rebuilds ?? 0}`,
`perf ${perf.tier || "?"} dprScale:${perf.dprScale || 1} ${perfTop || "-"}`,
`behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`,
`dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`,
`sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`,
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`,
`audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`,
`effects ${window.TarinaiEffectRegistry?.debugSnapshot?.().length || 0} registered`,
`effects ${window.TarinaiItemRegistry?.effect?.debugSnapshot?.().length || 0} registered`,
`events ${lastEvents.join(", ")}`,
].join("\n");
}, 500);

View file

@ -1,280 +0,0 @@
"use strict";
(function (global) {
const rawDefinitions = {
laxative: {
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
},
ammo: {
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
},
protein: {
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
},
niteropu: {
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
},
mercury: {
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply",
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
},
giant_drug: {
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
dwarf_drug: {
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
love_mochi: {
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
fight_mochi: {
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
sleep_drug: {
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
},
mystery_drug: {
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
},
zunda_juice: {
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
},
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
};
Object.assign(rawDefinitions.laxative, {
modifiers: { hungerReliefMultiplier: 0 },
tickInterval: 3,
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.ammo, {
modifiers: { speedMultiplier: 10 },
visualEffectId: "fall",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.protein, {
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
visualEffectId: "fall_up_red",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.niteropu, {
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
visualEffectId: "fall_down_purple",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.mercury, {
modifiers: { lifeMultiplierPerUse: 1.3 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.giant_drug, {
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.dwarf_drug, {
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
}
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
Object.assign(rawDefinitions.love_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") {
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.fight_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") {
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.sleep_drug, {
onApply(tarinai, context = {}) {
if (context.source === "eating") {
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100);
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true;
}
return tarinai.infectSleepDisease?.(context.item || null) || true;
},
});
Object.assign(rawDefinitions.laxative, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
},
onTick(tarinai) {
tarinai.forceLaxativePoop?.();
return true;
},
});
Object.assign(rawDefinitions.ammo, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
},
});
Object.assign(rawDefinitions.protein, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
});
Object.assign(rawDefinitions.niteropu, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
});
Object.assign(rawDefinitions.mercury, {
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
});
Object.assign(rawDefinitions.giant_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.dwarf_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.zunda_juice, {
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
});
Object.assign(rawDefinitions.disease_zunchi, {
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
});
Object.assign(rawDefinitions.disease_sleep, {
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_explosion, {
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_fight, {
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
});
class EffectRegistry extends global.TarinaiRegistryBase {
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
display(id = "", timer = 0, tarinai = null) {
const def = this.get(id);
if (!def) return "";
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
}
apply(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onApply !== "function") return false;
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
tick(id = "", tarinai = null, dt = 0, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onTick !== "function") return false;
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
}
remove(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
modifier(id = "", key = "", fallback = 1) {
const value = this.get(id)?.modifiers?.[key];
return Number.isFinite(value) ? value : fallback;
}
timerValue(tarinai = null, id = "") {
const def = this.get(id);
if (!def || !tarinai) return 0;
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
return 0;
}
isActive(tarinai = null, id = "") {
if (!tarinai) return false;
const def = this.get(id);
if (!def) return false;
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
return this.timerValue(tarinai, id) > 0.04;
}
activeSummary(tarinai = null) {
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
return order
.filter(id => this.isActive(tarinai, id))
.map(id => ({
id,
label: this.label(id),
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
}));
}
labels() { return super.labels(); }
colors() { return super.colors(); }
randomPool() {
const itemEffects = [...this.defs.entries()]
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
.map(([id]) => id)
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || [];
return [...new Set([...itemEffects, ...diseases])];
}
permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); }
timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); }
debugSnapshot() {
return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" }));
}
}
const registry = new EffectRegistry(rawDefinitions);
global.TarinaiEffectRegistry = registry;
global.TARINAI_EFFECT_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,57 +0,0 @@
"use strict";
(function (global) {
const rawDefinitions = {
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 },
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] },
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" },
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" },
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" },
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true },
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" },
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" },
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" },
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" },
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" },
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" },
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" },
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true },
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
};
class FoodRegistry extends global.TarinaiRegistryBase {
servingTypes() {
return [...this.defs.entries()]
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
.map(([id]) => id);
}
typesByInterest(...interests) {
const wanted = new Set(interests.flat().filter(Boolean));
return [...this.defs.entries()]
.filter(([, def]) => wanted.has(def.interest))
.map(([id]) => id);
}
paramEffectTypes() {
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
}
nutrition(type = "", fallback = 0) {
const value = this.get(type)?.nutrition;
return Number.isFinite(value) ? value : fallback;
}
hungerRelief(type = "", fallback = 0) {
const value = this.get(type)?.hungerRelief;
return Number.isFinite(value) ? value : fallback;
}
effectId(type = "") { return this.get(type)?.effectId || ""; }
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; }
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); }
}
const registry = new FoodRegistry(rawDefinitions);
global.TarinaiFoodRegistry = registry;
global.TARINAI_FOOD_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);

162
js/freeze_panel.js Normal file
View file

@ -0,0 +1,162 @@
"use strict";
(function (global) {
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function spritePathFor(type = "smile") {
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
const meta = sprites.find(s => s.id === type) || sprites[0];
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
}
function ensureStyles() {
if (document.getElementById("freezeSystemStyles")) return;
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
.frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
.frozen-panel.collapsed .frozen-list { display:none; }
.frozen-panel.collapsed { padding-bottom:7px; }
.frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
.frozen-card:active { cursor:grabbing; }
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
.frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
.frozen-card-main { min-width:0; flex:1; }
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
`;
document.head.appendChild(style);
}
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.innerHTML = `
<div class="frozen-panel-title"><button id="frozenPanelToggle" class="frozen-panel-toggle" type="button" aria-expanded="false"><span>\u51b7\u51cd\u5eab</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button></div>
<div id="frozenList" class="frozen-list"></div>
`;
const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed");
const collapsedByDefault = storedCollapse !== "0";
if (collapsedByDefault) {
panel.classList.add("collapsed");
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false");
} else {
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true");
}
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette");
if (operateBody) operateBody.appendChild(panel);
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
}
function render(entries = []) {
const panel = ensurePanel();
const listEl = panel.querySelector("#frozenList");
const countEl = panel.querySelector("#frozenPanelCount");
const list = Array.isArray(entries) ? entries : [];
if (countEl) countEl.textContent = `${list.length}`;
if (!listEl) return;
if (!list.length) {
listEl.innerHTML = `<div class="frozen-empty">\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002</div>`;
return;
}
listEl.innerHTML = list.map(entry => {
const data = entry.data || entry;
const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : "";
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="\u30c9\u30e9\u30c3\u30b0\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3059">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "\u305f\u308a\u306a\u3044")}</span><span class="frozen-card-meta">${htmlEscape(gen)}</span></span>
</button>`;
}).join("");
}
function markPending(id = "") {
const panel = ensurePanel();
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c.dataset.frozenId === id);
}
function clearPending() {
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
}
function bind({ onSelect, onDrop } = {}) {
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
e.dataTransfer.effectAllowed = "move";
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
});
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
panel.addEventListener("click", (e) => {
const toggle = e.target.closest("#frozenPanelToggle");
if (toggle) {
const collapsed = panel.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {}
global.audio?.uiFold?.();
return;
}
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
markPending(id);
onSelect?.(id);
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");
if (canvas && canvas.dataset.freezeDropBound !== "1") {
canvas.dataset.freezeDropBound = "1";
canvas.addEventListener("dragover", (e) => {
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
if (!text) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
canvas.classList.add("freeze-drop-ready");
});
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
canvas.addEventListener("drop", (e) => {
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
if (!id) return;
e.preventDefault();
canvas.classList.remove("freeze-drop-ready");
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const worldRef = global.world;
return worldRef?.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
})();
onDrop?.(id, p.x, p.y);
});
}
}
global.TarinaiFreezePanel = {
ensurePanel,
render,
bind,
markPending,
clearPending,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,97 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_snapshot_adapter.js");
const clonePlain = Snapshot.clonePlain;
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
const data = Snapshot.copyOwnData(entity, skip);
const targetRef = Snapshot.refFor(worldRef, entity?.target);
const panicTargetRef = Snapshot.refFor(worldRef, entity?.panicTarget);
const targetRefObject = Snapshot.refFor(worldRef, entity?.targetRef);
if (targetRef) data.__targetRef = targetRef;
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
if (targetRefObject) data.__targetRefObject = targetRefObject;
return data;
}
function restoreTarinaiData(worldRef, data, opts = {}) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const t = new TarinaiClass(worldRef, data || {});
Snapshot.applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"]));
t.world = worldRef;
if (opts.clearTransient !== false) {
t.target = null;
t.panicTarget = null;
t.sleeping = false;
t.insideNestBoxId = "";
}
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
function makeEntry(t, worldRef = global.world) {
const data = snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
data.sleeping = false;
data.insideNestBoxId = "";
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
data.targetKey = "";
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
return {
freezeId: id,
frozenAt: Date.now(),
worldTime: worldRef?.time || 0,
name: t.name || "\u305f\u308a\u306a\u3044",
familyKey: t.familyKey || "",
liveId: t.id || "",
liveToken: t.liveToken || 0,
type: t.type || "smile",
data,
};
}
function updateLiveIdAfterThaw(worldRef, t) {
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
const id = t.id || "";
const conflict = id && worldRef.liveTarinai.has(id);
if (!id || conflict) {
worldRef.assignTarinaiLiveId?.(t);
return;
}
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
const m = /^t(\d+)$/.exec(id);
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
}
function thawEntry(entry, x, y, worldRef = global.world) {
const data = clonePlain(entry?.data || entry) || {};
const t = restoreTarinaiData(worldRef, data, { clearTransient: true });
t.dead = false;
t.deathReason = "";
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
t.vx = 0;
t.vy = 0;
t.target = null;
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
global.TarinaiFreezeSnapshotAdapter = {
snapshotEntity,
restoreTarinaiData,
makeEntry,
thawEntry,
};
})(typeof window !== "undefined" ? window : globalThis);

45
js/freeze_store.js Normal file
View file

@ -0,0 +1,45 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_store.js");
const STORAGE_KEY = "tarinai_frozen_storage_v1";
const clonePlain = Snapshot.clonePlain;
function frozenList(worldRef = global.world) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
return worldRef.frozenTarinai;
}
function save(worldRef = global.world) {
try {
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
} catch (_) {}
}
function load(worldRef = global.world) {
if (!worldRef) return [];
try {
const raw = global.localStorage?.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
} catch (_) {
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
}
return frozenList(worldRef);
}
function ensure(worldRef = global.world) {
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
return frozenList(worldRef);
}
global.TarinaiFreezeStore = {
STORAGE_KEY,
frozenList,
load,
save,
ensure,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,333 +1,103 @@
"use strict";
(function (global) {
const STORAGE_KEY = "tarinai_frozen_storage_v1";
const MAX_PENDING_LOSSES = 16;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js");
const clonePlain = Snapshot.clonePlain;
const Store = global.TarinaiFreezeStore;
const Adapter = global.TarinaiFreezeSnapshotAdapter;
const Panel = global.TarinaiFreezePanel;
if (!Store || !Adapter || !Panel) throw new Error("freeze_system.js dependencies are not available");
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
let pendingThawId = "";
function frozenList(worldRef = global.world) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
return worldRef.frozenTarinai;
}
function saveFrozenStore(worldRef = global.world) {
try {
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
} catch (_) {}
}
function loadFrozenStore(worldRef = global.world) {
if (!worldRef) return [];
try {
const raw = global.localStorage?.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
} catch (_) {
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
}
return frozenList(worldRef);
}
function spritePathFor(type = "smile") {
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
const meta = sprites.find(s => s.id === type) || sprites[0];
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
}
function ensureStyles() {
if (document.getElementById("freezeSystemStyles")) return;
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
.frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
.frozen-panel.collapsed .frozen-list { display:none; }
.frozen-panel.collapsed { padding-bottom:7px; }
.frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
.frozen-card:active { cursor:grabbing; }
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
.frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
.frozen-card-main { min-width:0; flex:1; }
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
`;
document.head.appendChild(style);
}
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.innerHTML = `
<div class="frozen-panel-title"><button id="frozenPanelToggle" class="frozen-panel-toggle" type="button" aria-expanded="false"><span>\u51b7\u51cd\u5eab</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button></div>
<div id="frozenList" class="frozen-list"></div>
`;
const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed");
const collapsedByDefault = storedCollapse !== "0";
if (collapsedByDefault) {
panel.classList.add("collapsed");
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false");
} else {
panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true");
}
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette");
if (operateBody) operateBody.appendChild(panel);
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
return Store.frozenList(worldRef);
}
function renderFrozenPanel(worldRef = global.world) {
const panel = ensurePanel();
const listEl = panel.querySelector("#frozenList");
const countEl = panel.querySelector("#frozenPanelCount");
const list = frozenList(worldRef);
if (countEl) countEl.textContent = `${list.length}`;
if (!listEl) return;
if (!list.length) {
listEl.innerHTML = `<div class="frozen-empty">\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002</div>`;
return;
}
listEl.innerHTML = list.map(entry => {
const data = entry.data || entry;
const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / \u8a03\u5831${entry.pendingLosses.length}` : "";
const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : "";
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="\u30c9\u30e9\u30c3\u30b0\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3059">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "\u305f\u308a\u306a\u3044")}</span><span class="frozen-card-meta">${htmlEscape(gen)}${htmlEscape(losses)}</span></span>
</button>`;
}).join("");
Panel.render(frozenList(worldRef));
}
function makeFrozenEntry(t, worldRef = global.world) {
const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
data.sleeping = false;
data.insideNestBoxId = "";
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
data.targetKey = "";
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
return {
freezeId: id,
frozenAt: Date.now(),
worldTime: worldRef?.time || 0,
name: t.name || "\u305f\u308a\u306a\u3044",
familyKey: t.familyKey || "",
liveId: t.id || "",
liveToken: t.liveToken || 0,
type: t.type || "smile",
data,
pendingLosses: [],
};
function saveFrozenStore(worldRef = global.world) {
Store.save(worldRef);
}
function findTarinaiAt(worldRef, x, y) {
const arr = worldRef?.tarinai || [];
for (let i = arr.length - 1; i >= 0; i--) {
const t = arr[i];
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t;
}
return null;
function loadFrozenStore(worldRef = global.world) {
return Store.load(worldRef);
}
function freezeTarinai(t, worldRef = global.world) {
if (!t || t.dead || !worldRef) return false;
const entry = makeFrozenEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
function refreshWorldAfterFreezeChange(worldRef = global.world, selected = null) {
if (!worldRef) return;
if (selected !== undefined) worldRef.selected = selected;
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
worldRef.emit?.("freeze:changed", { world: worldRef, selected, renderWorld: true });
}
function freezeTarinai(t, worldRef = global.world) {
if (!t || t.dead || !worldRef) return false;
const entry = Adapter.makeEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
refreshWorldAfterFreezeChange(worldRef, worldRef.selected);
global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] });
global.renderStats?.();
global.render?.();
worldRef.emit?.("tarinai:frozen", { entry, tarinai: t });
return true;
}
function updateLiveIdAfterThaw(worldRef, t) {
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
const id = t.id || "";
const conflict = id && worldRef.liveTarinai.has(id);
if (!id || conflict) {
worldRef.assignTarinaiLiveId?.(t);
return;
}
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
const m = /^t(\d+)$/.exec(id);
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
}
function applyPendingLossesOnThaw(t, entry, worldRef) {
const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : [];
if (!losses.length) return;
const unique = [];
const seen = new Set();
for (const loss of losses) {
const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss);
if (seen.has(key)) continue;
seen.add(key);
unique.push(loss);
}
const relPriority = unique.some(l => l.relation === "\u89aa" || l.relation === "\u5b50") ? 1 : 0;
const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8);
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
else {
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 });
t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35));
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, wake: true });
}
t.bubble?.("\u3044\u306a\u3044", 1.1, "rgba(60,50,68,0.82)");
t.recordChangeCause?.("\u51b7\u51cd\u4e2d\u306e\u8a03\u5831", "\u30b9\u30c8\u30ec\u30b9", { value: stress });
t.addRecord?.(`\u51b7\u51cd\u4e2d\u306b${unique.map(l => l.name || "\u8ab0\u304b").slice(0, 3).join("\u3001")}\u304c\u6b7b\u4ea1\u3057\u3066\u3044\u305f\u3053\u3068\u306b\u6c17\u3065\u3044\u305f\u3002`, "death");
worldRef.log?.(`${t.name}\u306f\u89e3\u51cd\u6642\u306b\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u304d\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death", { participants: [t] });
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
const list = frozenList(worldRef);
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
if (index < 0 || !worldRef) return false;
const entry = list[index];
const data = clonePlain(entry.data || entry) || {};
let t = null;
try {
t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true });
t = Adapter.thawEntry(entry, x, y, worldRef);
} catch (_) {
return false;
}
t.dead = false;
t.deathReason = "";
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
t.vx = 0;
t.vy = 0;
t.target = null;
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
worldRef.tarinai.push(t);
worldRef.recordFamily?.(t);
applyPendingLossesOnThaw(t, entry, worldRef);
list.splice(index, 1);
worldRef.selected = t;
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
refreshWorldAfterFreezeChange(worldRef, t);
global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] });
global.renderStats?.();
global.render?.();
worldRef.emit?.("tarinai:thawed", { entry, tarinai: t });
return true;
}
function bindFreezeSystem() {
const worldRef = global.world;
loadFrozenStore(worldRef);
renderFrozenPanel(worldRef);
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
e.dataTransfer.effectAllowed = "move";
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
});
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
panel.addEventListener("click", (e) => {
const toggle = e.target.closest("#frozenPanelToggle");
if (toggle) {
const collapsed = panel.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {}
global.audio?.uiFold?.();
return;
}
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
pendingThawId = card.dataset.frozenId || "";
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
Panel.bind({
onSelect(id) {
pendingThawId = id || "";
global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002");
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");
if (canvas && canvas.dataset.freezeDropBound !== "1") {
canvas.dataset.freezeDropBound = "1";
canvas.addEventListener("dragover", (e) => {
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
if (!text) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
canvas.classList.add("freeze-drop-ready");
});
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
canvas.addEventListener("drop", (e) => {
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
if (!id) return;
e.preventDefault();
canvas.classList.remove("freeze-drop-ready");
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
})();
thawFrozen(id, p.x, p.y, worldRef);
});
}
},
onDrop(id, x, y) {
thawFrozen(id, x, y, global.world);
},
});
}
let pendingThawId = "";
function tryPendingThawAt(x, y, worldRef = global.world) {
if (!pendingThawId) return false;
const id = pendingThawId;
pendingThawId = "";
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
Panel.clearPending();
return thawFrozen(id, x, y, worldRef);
}
function afterSnapshotRestored(worldRef = global.world) {
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
function afterWorldRestored(worldRef = global.world) {
Store.ensure(worldRef);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
worldRef?.emit?.("freeze:changed", { world: worldRef, renderWorld: false });
}
function patchWorldPrototype() {
@ -355,6 +125,6 @@
frozenList,
loadFrozenStore,
saveFrozenStore,
afterSnapshotRestored,
afterWorldRestored,
};
})(typeof window !== "undefined" ? window : globalThis);

76
js/ground_types.js Normal file
View file

@ -0,0 +1,76 @@
"use strict";
const GROUND_TYPE_ORDER = Object.freeze(["soil", "concrete", "blanket", "foot_massage"]);
const GROUND_TYPES = Object.freeze({
soil: Object.freeze({ id: "soil", label: "土", description: "いつものじめん", stressMultiplier: 1.0, grassMultiplier: 1.0 }),
concrete: Object.freeze({ id: "concrete", label: "コンクリート", description: "草が生えない", stressMultiplier: 1.0, grassMultiplier: 0.0, grassLimit: 0 }),
blanket: Object.freeze({ id: "blanket", label: "ふわふわ毛布", description: "ストレスが増えにくい", stressMultiplier: 0.5, grassMultiplier: 1.0 }),
foot_massage: Object.freeze({ id: "foot_massage", label: "足つぼ", description: "ストレスが増えやすい", stressMultiplier: 1.5, grassMultiplier: 1.0 }),
});
function groundDefinition(id = "soil") {
return GROUND_TYPES[id] || GROUND_TYPES.soil;
}
function groundExists(id = "soil") {
return Boolean(GROUND_TYPES[id]);
}
function groundLabel(id = "soil") {
return groundDefinition(id).label || "土";
}
function groundDescription(id = "soil") {
return groundDefinition(id).description || "";
}
function normalizedGroundStressMultiplier(id = "soil") {
const mult = Number(groundDefinition(id).stressMultiplier);
return Number.isFinite(mult) && mult > 0 ? mult : 1;
}
function normalizedGroundGrassMultiplier(id = "soil") {
const mult = Number(groundDefinition(id).grassMultiplier);
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
}
function groundLimitForField(fieldType = "garden") {
const config = typeof CONFIG !== "undefined" ? CONFIG : null;
const byField = config?.grassLimitsByField || { cage: 19, garden: 99, park: 299 };
const fieldLimit = byField[fieldType || "garden"];
return Number(Number.isFinite(fieldLimit) ? fieldLimit : (config?.grassLimit ?? 99));
}
function groundGrassLimit(id = "soil", fieldType = "garden") {
const def = groundDefinition(id);
if (Number.isFinite(def.grassLimit)) return Math.max(0, Math.floor(Number(def.grassLimit)));
const mult = normalizedGroundGrassMultiplier(def.id);
if (mult <= 0) return 0;
const limit = groundLimitForField(fieldType) * mult;
return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
}
function nextGroundTypeId(current = "soil") {
const index = GROUND_TYPE_ORDER.indexOf(current || "soil");
return GROUND_TYPE_ORDER[(index + 1 + GROUND_TYPE_ORDER.length) % GROUND_TYPE_ORDER.length] || "soil";
}
function groundTooltipText(id = "soil") {
const def = groundDefinition(id);
const desc = def.description || "クリックで切り替え";
return `${def.label || "じめん"}\n${desc}`;
}
window.TarinaiGround = Object.freeze({
definitions: GROUND_TYPES,
order: GROUND_TYPE_ORDER,
definition: groundDefinition,
exists: groundExists,
label: groundLabel,
description: groundDescription,
stressMultiplier: normalizedGroundStressMultiplier,
grassMultiplier: normalizedGroundGrassMultiplier,
grassLimit: groundGrassLimit,
nextId: nextGroundTypeId,
tooltipText: groundTooltipText,
});

View file

@ -0,0 +1,626 @@
"use strict";
// Item update/lifecycle and type-specific runtime methods.
const DUPLICATOR_STORABLE_TYPES = new Set([
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug",
"laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug",
"zunda_juice", "grass"
]);
function duplicatorLoadTypeForItem(item) {
if (!item || item.dead || item.type === "duplicator") return "";
const type = String(item.type || "");
if (!type || type === "water_bowl" || type === "water") return "";
// Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が
// serving-food 判定や medicine role の揺れに左右されず複製機へ入る。
if (DUPLICATOR_STORABLE_TYPES.has(type)) return type;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return type;
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type;
const foodDef = window.TarinaiItemRegistry?.food?.get?.(type);
if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type;
if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type;
return "";
}
function syncDuplicatorRoles(item) {
if (!item || item.type !== "duplicator") return;
const type = String(item.storedFoodType || "");
item.roles.food = Boolean(type);
item.roles.medicine = Boolean(type && (type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))));
item.roles.drink = Boolean(type === "water" || type === "zunda_juice");
}
Object.assign(Item.prototype, {
update(dt, worldRef = world) {
this.age += dt;
if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt);
if (this.dropTimer > 0) {
const beforeDrop = this.dropTimer;
this.dropTimer = Math.max(0, this.dropTimer - dt);
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
this.dropImpactDone = true;
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (isServingFoodType(this.type)) {
const interval = passiveFoodDecayInterval(worldRef);
this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt;
let ticks = 0;
while (this.passiveFoodDecayTimer >= interval && ticks < 3) {
this.passiveFoodDecayTimer -= interval;
ticks++;
if (Number.isFinite(this.foodServingsRemaining)) {
const before = Math.max(0, this.foodServingsRemaining || 0);
const lost = Math.min(before, interval * passiveFoodDecayRate(this));
if (lost > 0) {
this.foodServingsRemaining = Math.max(0, before - lost);
this.amount = this.foodServingsRemaining;
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? PASSIVE_FOOD_HYGIENE_PENALTY_PER_SERVING;
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
if (this.foodServingsRemaining <= 0.015) this.amount = 0;
}
} else if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(this.type)) {
const before = this.amount || 0;
const lost = Math.min(before, interval * 0.12);
this.amount -= lost;
if (lost > 0) {
const penalty = window.TarinaiItemRegistry?.food?.hygienePenalty?.(this.type) ?? 0.002;
worldRef?.registerFoodSpoilage?.(lost * penalty, this);
worldRef?.markTerrainDirty?.("food-passive-decay");
worldRef?.emit?.("item:decayed", { item: this, type: this.type, amount: lost, passive: true });
}
}
}
}
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "ant_corpse") this.amount -= dt * 0.018;
if (this.type === "firecracker") {
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
if (this.fuseTimer <= 0) {
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
else this.amount = 0;
}
}
if (this.type === "genkotsu") {
this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt);
if (this.dropImpactDone) {
this.lingerTimer = Math.max(0, (this.lingerTimer || 0) - dt);
if ((this.lingerTimer || 0) <= 0) this.amount = 0;
} else {
this.amount = Math.max(this.amount || 0, 100);
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "duplicator") this.updateDuplicator(dt, worldRef);
if (isPinType(this.type)) this.updatePushpin(dt, worldRef);
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.type === "grass") {
// Grass is intentionally not simulated per item. World.update randomly
// advances one grass by one discrete stage every few seconds.
normalizeGrassStage(this);
} else if (this.type === "zunchi") {
this.lifecycleTimer += dt;
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
if (this.lifecycleTimer >= interval) {
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
this.lifecycleTimer = 0;
this.updateZunchi(lifecycleDt, worldRef);
}
}
},
updateDuplicator(dt, worldRef) {
this.amount = 999;
syncDuplicatorRoles(this);
if (!worldRef?.nearbyItems) return;
const pickupRadius = Math.max(96, (this.r || 34) * 3.0);
const maxContact = item => Math.max(68, (this.r || 34) + (item?.r || 14) + 24);
const candidates = Array.from(worldRef.nearbyItems(this.x, this.y, pickupRadius, true) || []);
// SpatialGrid の分類漏れがあっても、複製機の近接投入だけは取りこぼさない。
// 全件を「候補にする」のではなく、投入半径付近にあるものだけ補完する。
if (worldRef.items) {
for (const item of worldRef.items) {
if (!item || candidates.includes(item) || item === this || item.dead) continue;
const approxReach = pickupRadius + Math.max(18, item.r || 12);
if (distXY(this.x, this.y, item.x, item.y) <= approxReach) candidates.push(item);
}
}
let best = null, bestD = Infinity, bestType = "";
for (const it of candidates) {
if (!it || it === this || it.dead) continue;
const loadType = duplicatorLoadTypeForItem(it);
if (!loadType) continue;
if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue;
const d = distXY(this.x, this.y, it.x, it.y);
if (d < bestD && d <= maxContact(it)) { best = it; bestD = d; bestType = loadType; }
}
if (!best || !bestType) return;
const def = typeof itemDefinition === "function" ? itemDefinition(bestType) : null;
const newLabel = def?.label || bestType;
const changed = this.storedFoodType !== bestType;
if (!changed && this.storedFoodType) return;
this.storedFoodType = bestType;
this.storedFoodLabel = newLabel;
syncDuplicatorRoles(this);
this.loadedAt = worldRef.time || 0;
best.amount = 0;
best.foodServingsRemaining = 0;
// Item.dead is a getter based on amount. Do not assign to it.
worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded");
worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed });
worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food");
},
updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
this.prevX = this.x;
this.prevY = this.y;
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.34; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
const slow = Math.pow(0.68, dt * 2.4);
this.vx *= slow;
this.vy *= slow;
worldRef.drawListDirty = true;
}
this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed);
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
},
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) {
if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return;
const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue;
const now = worldRef.time || 0;
if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
this.lastHitTarinaiAt = this.lastHitTarinaiAt || {};
this.lastHitTarinaiAt[t.id] = now;
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
const impulse = clamp(speed * 1.15, 150, 620);
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
else {
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true });
if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 });
}
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65);
worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }));
this.vx *= -0.18;
this.vy *= -0.18;
break;
}
},
updateBall(dt, worldRef) {
this.prevX = this.x;
this.prevY = this.y;
const moving = Math.hypot(this.vx || 0, this.vy || 0);
if (moving > 0.04) {
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
this.resolveBallObstacleCollisions(dt, worldRef);
this.x = clamp(this.x, p, worldRef.w - p);
this.y = clamp(this.y, p, worldRef.h - p);
const groundFriction = Math.pow(0.72, dt);
this.vx *= groundFriction;
this.vy *= groundFriction;
}
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18);
this.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; }
},
resolveBallObstacleCollisions(dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
for (const it of items) {
if (!it || it === this || it.dead) continue;
if (it.type === "bed") {
this.applyBallHayDrag(it, dt, worldRef);
} else if (it.type === "stone") {
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
}
},
applyBallHayDrag(bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34);
const dx = this.x - bed.x;
const dy = this.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
this.vx *= slow;
this.vy *= slow;
this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) {
this.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
},
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) {
let dx = this.x - obstacle.x;
let dy = this.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = this.prevX;
const py = this.prevY;
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
const sx = this.x - px;
const sy = this.y - py;
const len2 = sx * sx + sy * sy;
if (len2 <= 0.0001) return;
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
const cx = px + sx * t;
const cy = py + sy * t;
dx = cx - obstacle.x;
dy = cy - obstacle.y;
d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
}
}
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = obstacle.x + nx * (hitRadius + 0.5);
this.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - (1 + restitution) * toward * nx;
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 24;
this.vy = (this.vy || 0) + ny * 24;
}
this.vx *= 0.96;
this.vy *= 0.96;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
this.emitBallBounce(worldRef, obstacle);
},
resolveBallRectBounce(rect, worldRef, source = null) {
if (!rect) return;
const hitRadius = (this.r || 18) + 2;
const cx = clamp(this.x, rect.left, rect.right);
const cy = clamp(this.y, rect.top, rect.bottom);
let dx = this.x - cx;
let dy = this.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) {
const px = this.prevX;
const py = this.prevY;
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
let nx = 0;
let ny = 0;
const vx = this.vx || 0;
const vy = this.vy || 0;
const candidates = [];
const sx = this.x - px;
const sy = this.y - py;
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
if (candidates.length) {
candidates.sort((a, b) => a.t - b.t);
nx = candidates[0].nx;
ny = candidates[0].ny;
} else if (Math.abs(vx) >= Math.abs(vy)) {
nx = vx >= 0 ? -1 : 1;
} else {
ny = vy >= 0 ? -1 : 1;
}
const toward = vx * nx + vy * ny;
if (nx < 0) this.x = expanded.left - 0.5;
else if (nx > 0) this.x = expanded.right + 0.5;
if (ny < 0) this.y = expanded.top - 0.5;
else if (ny > 0) this.y = expanded.bottom + 0.5;
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
if (toward < 0) {
this.vx = vx - 1.78 * toward * nx;
this.vy = vy - 1.78 * toward * ny;
} else {
this.vx = vx + nx * 18;
this.vy = vy + ny * 18;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, source || rect.item || rect);
return;
}
if (d < 0.001) {
const left = Math.abs(this.x - rect.left);
const right = Math.abs(rect.right - this.x);
const top = Math.abs(this.y - rect.top);
const bottom = Math.abs(rect.bottom - this.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; }
else if (m === right) { dx = 1; dy = 0; }
else if (m === top) { dx = 0; dy = -1; }
else { dx = 0; dy = 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = cx + nx * (hitRadius + 0.5);
this.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - 1.78 * toward * nx;
this.vy = (this.vy || 0) - 1.78 * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 18;
this.vy = (this.vy || 0) + ny * 18;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, source || rect.item || rect);
},
emitBallBounce(worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
this.lastObstacleBounceAt = now;
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" }));
},
updatePushpin(dt, worldRef) {
if (!worldRef) return;
if (this.pinState === "lodged") {
this.updateLodgedPushpin(dt, worldRef);
return;
}
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
} else {
this.prevX = this.x;
this.prevY = this.y;
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
}
this.tryStickPushpin(worldRef);
},
tryStickPushpin(worldRef) {
if (!worldRef?.tarinai?.length) return false;
if (this.pinState === "lodged") return false;
const now = worldRef.time || 0;
if ((this.noStickUntil || 0) > now) return false;
let best = null;
let bestD = Infinity;
const hitRange = Math.max(10, (this.r || 8) * 1.25);
const candidates = worldRef.nearbyTarinai?.(this.x, this.y, hitRange + 32, true) || worldRef.tarinai;
for (const t of candidates) {
if (!t || t.dead) continue;
if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue;
const d = distXY(this.x, this.y, t.x, t.y);
const hit = (t.radius || 16) * 0.86 + hitRange;
if (d <= hit && d < bestD) { best = t; bestD = d; }
}
if (!best) return false;
return this.attachPushpin(best, worldRef, bestD);
},
attachPushpin(t, worldRef, d = null) {
if (!t || t.dead) return false;
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
const dx = this.x - t.x;
const dy = this.y - t.y;
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
this.pinState = "lodged";
this.deletable = false;
this.pinTargetId = t.id;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
this.pinVisualSide = visualSide;
this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.vx = 0;
this.vy = 0;
this.spinVelocity = 0;
this.spin = this.pinAttachAngle;
t.stuckPushpinId = this.id;
t.sleeping = false;
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
if (isOshibyo) {
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
return true;
}
if (t.enterPanic) {
t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
} else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
}
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
this.pinLogAt = worldRef.time || 0;
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
}
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
return true;
},
updateLodgedPushpin(dt, worldRef) {
const t = worldRef.liveTarinaiById?.(this.pinTargetId) || null;
if (!t) {
this.detachPushpin(worldRef, "owner-lost");
return;
}
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id;
this.deletable = false;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = isOshibyo ? -faceDir : faceDir;
this.pinVisualSide = visualSide;
this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x;
this.prevY = this.y;
this.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
this.vx = t.vx || 0;
this.vy = t.vy || 0;
if (isOshibyo) {
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
return;
}
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
while (this.pinDamageTick >= 0.55) {
this.pinDamageTick -= 0.55;
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
}
t.sleeping = false;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
else {
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
}
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
while (this.pinFallCheckTimer >= 1.0) {
this.pinFallCheckTimer -= 1.0;
if (Math.random() < 0.10) {
this.detachPushpin(worldRef, "fall");
return;
}
}
},
detachPushpin(worldRef, reason = "released") {
const t = worldRef?.liveTarinaiById?.(this.pinTargetId) || null;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const wasOshibyo = Boolean(behavior?.blocksZunchi);
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
if (reason === "pinch") {
const now = worldRef?.time || 0;
this.noStickUntil = now + 1.0;
this.noStickTargetId = t?.id || "";
this.noStickTargetUntil = now + 1.0;
} else if (t) {
const now = worldRef?.time || 0;
this.noStickTargetId = t.id || "";
this.noStickTargetUntil = now + 0.55;
}
this.pinState = "loose";
this.pinTargetId = "";
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.deletable = true;
if (reason === "pinch") {
this.vx = 0;
this.vy = 0;
this.spinVelocity = 0;
} else {
this.vx = rand(-24, 24);
this.vy = rand(-10, 18);
this.spinVelocity = rand(-1.6, 1.6);
this.spin += rand(-0.25, 0.25);
if (t) {
this.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || this.x) - (CONFIG.worldPadding || 30));
this.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || this.y) - (CONFIG.worldPadding || 30));
}
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
}
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
worldRef.spawnBurstZunchi(t || this, storedZunchi, this);
}
},
updateZunchi(dt, worldRef) {
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.28 : 1;
const decayBoost = rainy ? 1.35 : 1;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
for (const it of nearby) {
if (it.type !== "water") continue;
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
}
this.stageTimer += dt * 0.70 * (rainBoost + waterBoost * 0.65);
const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt;
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
this.amount -= decayDt * 0.018 * decayBoost;
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
this.amount -= decayDt * 0.032 * decayBoost;
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= decayDt * 0.046 * decayBoost;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= decayDt * 0.090 * decayBoost;
}
}
});

682
js/item_registry.js Normal file
View file

@ -0,0 +1,682 @@
"use strict";
// Unified item definition source: tools, item traits, visuals, food metadata, and item effects.
const TOOL_DEFINITIONS = Object.freeze({
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.webp", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.webp", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" },
laxative: { id: "laxative", itemType: "laxative", label: "\u4e0b\u5264", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_laxative.webp", tooltip: "\u305a\u3093\u3061\u3092\u5927\u91cf\u306b\u6392\u6cc4\u3055\u305b\u308b\u3002\u4f55\u304b\u3092\u98df\u3079\u3066\u3082\u3059\u3050\u306b\u51fa\u3066\u884c\u3063\u3066\u3057\u307e\u3046\u3002" },
protein: { id: "protein", itemType: "protein", label: "\u30d7\u30ed\u30c6\u30a4\u30f3", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_protein.webp", tooltip: "\u75c5\u6c17\u306b\u5f37\u304f\u3001\u305f\u304f\u307e\u3057\u304f\u3002" },
niteropu: { id: "niteropu", itemType: "niteropu", label: "\u30f3\u30a4\u30c6\u30ed\u30d7", placeable: true, scalable: true, radius: 14, amount: 58, icon: "assets/ui/tool_niteropu.webp", tooltip: "\u75c5\u6c17\u306b\u5f31\u304f\u3001\u305f\u3088\u308a\u306a\u304f\u3002" },
ammo: { id: "ammo", itemType: "ammo", label: "\u5f3e\u85ac", placeable: true, scalable: true, radius: 12, amount: 48, icon: "assets/ui/tool_ammo.webp", tooltip: "\u5168\u3066\u3092\u52a0\u901f\u3055\u305b\u308b\u3002" },
mystery_drug: { id: "mystery_drug", itemType: "mystery_drug", label: "\u602a\u3057\u3044\u85ac", placeable: true, scalable: true, radius: 13, amount: 52, icon: "assets/ui/tool_mystery_drug.webp", tooltip: "\u4f55\u304b\u304c\u8d77\u3053\u308b\u3002" },
mercury: { id: "mercury", itemType: "mercury", label: "\u6c34\u9280", placeable: true, scalable: true, radius: 13, amount: 44, icon: "assets/ui/tool_mercury.webp", tooltip: "\u9577\u751f\u304d\u306e\u79d8\u8a23\u3002" },
giant_drug: { id: "giant_drug", itemType: "giant_drug", label: "\u5de8\u5927\u85ac", placeable: true, scalable: true, radius: 22, amount: 52, icon: "assets/ui/tool_giant_drug.webp", tooltip: "\u4f53\u3092\u5927\u304d\u304f\u5f37\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u304c\u5927\u304d\u3044\u3002" },
dwarf_drug: { id: "dwarf_drug", itemType: "dwarf_drug", label: "\u77ee\u5c0f\u85ac", placeable: true, scalable: true, radius: 8, amount: 52, icon: "assets/ui/tool_dwarf_drug.webp", tooltip: "\u4f53\u3092\u5c0f\u3055\u304f\u5f31\u304f\u3059\u308b\u304c\u3001\u8ca0\u62c5\u3082\u5927\u304d\u3044\u3002" },
zunda_juice: { id: "zunda_juice", itemType: "zunda_juice", label: "\u305a\u3093\u3060\u6c41", placeable: true, scalable: true, radius: 15, amount: 70, icon: "assets/ui/tool_zunda_juice.webp", tooltip: "\u98df\u3079\u308b\u3068\u5168\u3066\u306e\u30a2\u30a4\u30c6\u30e0\u52b9\u679c\u3092\u9664\u53bb\u3059\u308b\u3002" },
grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.webp", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" },
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.webp", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 4, amount: 24, simulationOnly: true },
});
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]),
});
function itemHasTrait(type = "", trait = "") {
return Boolean(ITEM_TRAITS[trait]?.has?.(String(type || "")));
}
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
const items = worldRef?.items || tarinai.world?.items || [];
return items.find(it => isLodgedPin(it) && it.id === tarinai.stuckPushpinId) || null;
}
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
});
function pinBehaviorFor(type = "") {
return PIN_BEHAVIORS[String(type || "")] || null;
}
let ITEM_VISUAL_DEFINITIONS = null;
let FOOD_REGISTRY = null;
let FOOD_DEFINITIONS = null;
let EFFECT_REGISTRY = null;
let EFFECT_DEFINITIONS = null;
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
function toolCategories() {
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
}
function toolDefinition(id = "") {
return TOOL_DEFINITIONS[id] || null;
}
function toolLabel(id = "") {
const def = toolDefinition(id);
return def?.label || id || "";
}
function toolItemType(id = "") {
const def = toolDefinition(id);
return def?.placeable ? (def.itemType || def.id) : null;
}
function itemDefinition(type = "") {
return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null;
}
function itemRadiusFor(type = "", fallback = 12) {
return itemDefinition(type)?.radius ?? fallback;
}
function itemAmountFor(type = "", fallback = 80) {
return itemDefinition(type)?.amount ?? fallback;
}
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
function normalizedToolSizeFor(worldRef = null, type = "") {
const def = itemDefinition(type);
if (!def?.scalable) return "medium";
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
}
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
function scalableToolIds() {
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
}
function toolTipsFromDefinitions() {
return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip]));
}
(function (global) {
const raw = String.raw`{
"water": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
},
"zunda_juice": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
},
"mercury": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
},
"water_bowl": {
"renderer": "water_bowl",
"shape": "bowl_with_horizontal_oval_water",
"palette": {
"bowlFill": "rgba(171,120,76,0.72)",
"bowlStroke": "rgba(94,69,48,0.55)",
"waterFill": "rgba(86,157,224,0.58)",
"waterStroke": "rgba(62,113,178,0.36)",
"highlight": "rgba(255,255,255,0.54)"
},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"]
},
"sleep_drug": {
"renderer": "sleep_tablet",
"shape": "moon_tablet",
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "moon", "purple"]
},
"laxative": {
"renderer": "laxative_tablet",
"shape": "rounded_tablet_zunchi_motif",
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
},
"mystery_drug": {
"renderer": "mystery_tablet",
"shape": "question_mark_tablet",
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
},
"ammo": {
"renderer": "bullet",
"shape": "bullet",
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bullet", "gold"]
},
"protein": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "orange"]
},
"niteropu": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "purple"]
},
"giant_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["pill", "large", "orange"]
},
"dwarf_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
"scale": 0.92,
"uiPreviewScale": 0.92,
"fieldScale": 0.92,
"accessibleShapeTraits": ["pill", "small", "purple"]
},
"ball": {
"renderer": "emoji",
"shape": "soccer_ball",
"palette": {},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
}
}`;
const defs = Object.freeze(JSON.parse(raw));
function itemVisualDefinition(type = "") {
return defs[String(type || "")] || null;
}
function itemVisualPalette(type = "") {
return itemVisualDefinition(type)?.palette || null;
}
ITEM_VISUAL_DEFINITIONS = defs;
global.itemVisualDefinition = itemVisualDefinition;
global.itemVisualPalette = itemVisualPalette;
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
const rawDefinitions = {
food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 },
sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] },
love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" },
fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" },
sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" },
laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true },
protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" },
niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" },
ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" },
mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" },
mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" },
giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" },
dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" },
zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true },
grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 },
ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 }
};
class FoodRegistry extends global.TarinaiRegistryBase {
servingTypes() {
return [...this.defs.entries()]
.filter(([, def]) => def.interest && !["grass_like", "water", "corpse"].includes(def.interest))
.map(([id]) => id);
}
typesByInterest(...interests) {
const wanted = new Set(interests.flat().filter(Boolean));
return [...this.defs.entries()]
.filter(([, def]) => wanted.has(def.interest))
.map(([id]) => id);
}
paramEffectTypes() {
return [...this.defs.entries()].filter(([, def]) => def.effectId && !def.effectId.endsWith("_mochi") && def.effectId !== "sleep_drug").map(([id]) => id);
}
nutrition(type = "", fallback = 0) {
const value = this.get(type)?.nutrition;
return Number.isFinite(value) ? value : fallback;
}
hungerRelief(type = "", fallback = 0) {
const value = this.get(type)?.hungerRelief;
return Number.isFinite(value) ? value : fallback;
}
effectId(type = "") { return this.get(type)?.effectId || ""; }
passiveDecayMultiplier(type = "") { return this.get(type)?.decayRateMultiplier ?? 1; }
hygienePenalty(type = "") { return this.get(type)?.hygienePenalty ?? 0.055; }
debugSnapshot() { return super.debugSnapshot((id, def) => ({ id, label: def.label, interest: def.interest, effectId: def.effectId || "" })); }
}
const registry = new FoodRegistry(rawDefinitions);
FOOD_REGISTRY = registry;
FOOD_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
const rawDefinitions = {
laxative: {
label: "\u4e0b\u5264", color: "rgba(119, 136, 58, 0.78)", kind: "timed", defaultDuration: 60,
tags: ["item", "behavior", "food"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / 3\u79d2\u3054\u3068\u306b\u305a\u3093\u3061 / \u98df\u4e8b\u306e\u7a7a\u8179\u56de\u5fa9\u306a\u3057`,
},
ammo: {
label: "\u5f3e\u85ac", color: "rgba(216, 142, 38, 0.82)", kind: "timed", defaultDuration: 18,
tags: ["item", "behavior", "movement"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u6b69\u884c\u901f\u5ea610\u500d / \u885d\u7a81\u3067\u5f3e\u304d\u98db\u3070\u3059`,
},
protein: {
label: "\u30d7\u30ed\u30c6\u30a4\u30f3", color: "rgba(239, 122, 51, 0.86)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b81.75\u500d / \u75c5\u6c17\u78ba\u73870.5\u500d",
},
niteropu: {
label: "\u30f3\u30a4\u30c6\u30ed\u30d7", color: "rgba(88, 76, 166, 0.78)", kind: "permanent", exclusiveGroup: "power",
tags: ["item", "behavior", "power"], display: () => "\u6c38\u7d9a / \u4e0e\u30c0\u30e1\u30fc\u30b80.5\u500d / \u75c5\u6c17\u78ba\u73872\u500d",
},
mercury: {
label: "\u6c34\u9280", color: "rgba(96, 146, 166, 0.82)", kind: "permanent", stack: "multiply",
tags: ["item", "life"], display: (_timer, t) => `\u6c38\u7d9a / \u5bff\u547dx${((t && t.mercuryLifeMultiplier) || 1.3).toFixed(2)}\uff08\u98df\u3079\u308b\u305f\u3073\u4e57\u7b97\uff09`,
},
giant_drug: {
label: "\u5de8\u5927\u85ac", color: "rgba(228, 123, 58, 0.86)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba3\u500d / \u653b\u64832\u500d / \u901f\u5ea61.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
dwarf_drug: {
label: "\u77ee\u5c0f\u85ac", color: "rgba(109, 107, 190, 0.82)", kind: "permanent", exclusiveGroup: "body_size",
tags: ["item", "body", "behavior"], display: () => "\u6c38\u7d9a / \u30b5\u30a4\u30ba0.25\u500d / \u653b\u64830.25\u500d / \u901f\u5ea60.5\u500d / \u6bce\u79d21\u30c0\u30e1\u30fc\u30b8",
},
love_mochi: {
label: "\u3078\u3053\u9905", color: "rgba(190,82,132,0.78)", kind: "timer", timerProp: "loveMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
fight_mochi: {
label: "\u3051\u3093\u304b\u9905", color: "rgba(180,86,52,0.80)", kind: "timer", timerProp: "fightMochiTimer",
tags: ["item", "mochi", "behavior"], display: timer => `${Math.ceil(timer || 0)}\u79d2 / \u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044`,
},
sleep_drug: {
label: "\u306d\u3080\u308a\u85ac", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["item", "disease", "behavior"],
},
mystery_drug: {
label: "\u602a\u3057\u3044\u85ac", color: "rgba(60, 154, 87, 0.80)", kind: "instant", tags: ["item", "behavior"],
},
zunda_juice: {
label: "\u305a\u3093\u3060\u6c41", color: "rgba(87, 180, 79, 0.86)", kind: "cleanse", tags: ["item", "cleanse"],
},
disease_zunchi: { label: "\u305a\u3093\u3061\u75c5", color: "rgba(113, 93, 59, 0.74)", kind: "disease", tags: ["disease", "behavior"] },
disease_sleep: { label: "\u306d\u3080\u308a\u75c5", color: "rgba(104,84,168,0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_explosion: { label: "\u7206\u767a\u75c5", color: "rgba(210, 90, 44, 0.78)", kind: "disease", tags: ["disease", "behavior"] },
disease_fight: { label: "\u304d\u305a\u3064\u304d\u75c5", color: "rgba(180,86,52,0.80)", kind: "disease", tags: ["disease", "behavior"] },
};
Object.assign(rawDefinitions.laxative, {
modifiers: { hungerReliefMultiplier: 0 },
tickInterval: 3,
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.ammo, {
modifiers: { speedMultiplier: 10 },
visualEffectId: "fall",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.protein, {
modifiers: { damageMultiplier: 1.75, diseaseChanceMultiplier: 0.5 },
visualEffectId: "fall_up_red",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.niteropu, {
modifiers: { damageMultiplier: 0.5, diseaseChanceMultiplier: 2 },
visualEffectId: "fall_down_purple",
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.mercury, {
modifiers: { lifeMultiplierPerUse: 1.3 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.giant_drug, {
modifiers: { sizeMultiplier: 3, damageMultiplier: 2, speedMultiplier: 1.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
Object.assign(rawDefinitions.dwarf_drug, {
modifiers: { sizeMultiplier: 0.25, damageMultiplier: 0.25, speedMultiplier: 0.5, damagePerSecond: 1 },
bubblePolicy: "none",
mysteryEligible: true,
zundaJuiceRemovable: true,
});
for (const id of ["love_mochi", "fight_mochi", "sleep_drug"]) {
Object.assign(rawDefinitions[id], { mysteryEligible: true, zundaJuiceRemovable: id !== "sleep_drug" });
}
Object.assign(rawDefinitions.zunda_juice, { removesItemEffects: true, mysteryEligible: false });
Object.assign(rawDefinitions.love_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") {
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.fight_mochi, {
onApply(tarinai, context = {}) {
const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18));
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") {
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
return true;
},
});
Object.assign(rawDefinitions.sleep_drug, {
onApply(tarinai, context = {}) {
if (context.source === "eating") {
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 });
tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100);
tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18);
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true;
}
return tarinai.infectSleepDisease?.(context.item || null) || true;
},
});
Object.assign(rawDefinitions.laxative, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("laxative", Math.round((this.defaultDuration || 60) * (context.scale ?? 1)));
},
onTick(tarinai) {
tarinai.forceLaxativePoop?.();
return true;
},
});
Object.assign(rawDefinitions.ammo, {
onApply(tarinai, context = {}) {
return tarinai.setTimedItemEffect?.("ammo", Math.round((this.defaultDuration || 18) * (context.scale ?? 1)));
},
});
Object.assign(rawDefinitions.protein, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("protein"); },
});
Object.assign(rawDefinitions.niteropu, {
onApply(tarinai) { return tarinai.setPowerItemMode?.("niteropu"); },
});
Object.assign(rawDefinitions.mercury, {
onApply(tarinai) { return tarinai.setMercuryEffect?.(); },
});
Object.assign(rawDefinitions.giant_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("giant_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("giant_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.dwarf_drug, {
onApply(tarinai) { return tarinai.setSizeItemMode?.("dwarf_drug"); },
onTick(tarinai) {
tarinai.damage?.(this.modifiers?.damagePerSecond || 1, this.label);
if (tarinai.world?.effects) tarinai.spawnPowerItemEffect?.("dwarf_drug", 0.25);
return true;
},
});
Object.assign(rawDefinitions.zunda_juice, {
onApply(tarinai) { return tarinai.clearAllItemEffects?.(); },
});
Object.assign(rawDefinitions.disease_zunchi, {
onApply(tarinai, context = {}) { return tarinai.infectZunchiDisease?.(context.item || null, true) || true; },
});
Object.assign(rawDefinitions.disease_sleep, {
onApply(tarinai, context = {}) { return tarinai.infectSleepDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_explosion, {
onApply(tarinai, context = {}) { return tarinai.infectExplosionDisease?.(context.item || null) || true; },
});
Object.assign(rawDefinitions.disease_fight, {
onApply(tarinai, context = {}) { return tarinai.infectFightDisease?.(context.item || null) || true; },
});
class EffectRegistry extends global.TarinaiRegistryBase {
label(id = "") { return super.label(id, id || "\u4e0d\u660e"); }
color(id = "") { return super.color(id, "rgba(255,242,160,0.85)"); }
display(id = "", timer = 0, tarinai = null) {
const def = this.get(id);
if (!def) return "";
return typeof def.display === "function" ? def.display(timer, tarinai, def) : (def.display || "");
}
apply(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onApply !== "function") return false;
return Boolean(def.onApply.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
tick(id = "", tarinai = null, dt = 0, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onTick !== "function") return false;
return Boolean(def.onTick.call(def, tarinai, Math.max(0, Number(dt) || 0), { ...context, effectId: id, registry: this }));
}
remove(id = "", tarinai = null, context = {}) {
const def = this.get(id);
if (!def || !tarinai || typeof def.onRemove !== "function") return false;
return Boolean(def.onRemove.call(def, tarinai, { ...context, effectId: id, registry: this }));
}
modifier(id = "", key = "", fallback = 1) {
const value = this.get(id)?.modifiers?.[key];
return Number.isFinite(value) ? value : fallback;
}
timerValue(tarinai = null, id = "") {
const def = this.get(id);
if (!def || !tarinai) return 0;
if (def.timerProp) return Number(tarinai[def.timerProp] || 0);
if (def.kind === "timed") return Number(tarinai.itemEffectTimers?.[id] || 0);
return 0;
}
isActive(tarinai = null, id = "") {
if (!tarinai) return false;
const def = this.get(id);
if (!def) return false;
if (id === "protein" || id === "niteropu") return tarinai.powerItemMode === id;
if (id === "giant_drug" || id === "dwarf_drug") return tarinai.sizeItemMode === id;
if (id === "mercury") return tarinai.lifeItemMode === "mercury";
return this.timerValue(tarinai, id) > 0.04;
}
activeSummary(tarinai = null) {
const order = ["protein", "niteropu", "mercury", "giant_drug", "dwarf_drug", "laxative", "ammo", "love_mochi", "fight_mochi"];
return order
.filter(id => this.isActive(tarinai, id))
.map(id => ({
id,
label: this.label(id),
detail: this.display(id, this.timerValue(tarinai, id), tarinai),
}));
}
labels() { return super.labels(); }
colors() { return super.colors(); }
randomPool() {
const itemEffects = [...this.defs.entries()]
.filter(([id, def]) => Boolean(def.mysteryEligible && id !== "mystery_drug" && id !== "zunda_juice" && !(def.tags || []).includes("disease")))
.map(([id]) => id)
.filter(id => id !== "mystery_drug" && id !== "zunda_juice");
const diseases = global.TarinaiDiseaseRegistry?.mysteryPool?.() || [];
return [...new Set([...itemEffects, ...diseases])];
}
permanentIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "permanent").map(([id]) => id); }
timedIds() { return [...this.defs.entries()].filter(([, def]) => def.kind === "timed" || def.kind === "timer").map(([id]) => id); }
debugSnapshot() {
return [...this.defs.entries()].map(([id, def]) => ({ id, label: def.label, kind: def.kind, group: def.exclusiveGroup || "" }));
}
}
const registry = new EffectRegistry(rawDefinitions);
EFFECT_REGISTRY = registry;
EFFECT_DEFINITIONS = Object.freeze(rawDefinitions);
})(typeof window !== "undefined" ? window : globalThis);
(function (global) {
function itemTypeDefinition(type = "") {
const id = String(type || "");
const tool = typeof itemDefinition === "function" ? itemDefinition(id) : null;
const food = FOOD_DEFINITIONS?.[id] || null;
const effectId = food?.effectId || (EFFECT_DEFINITIONS?.[id] ? id : "");
const effect = effectId ? (EFFECT_DEFINITIONS?.[effectId] || null) : null;
const visual = ITEM_VISUAL_DEFINITIONS?.[id] || null;
return {
id,
tool,
food,
effect,
effectId,
visual,
label: tool?.label || food?.label || effect?.label || id,
radius: tool?.radius,
amount: tool?.amount,
placeable: Boolean(tool?.placeable),
scalable: Boolean(tool?.scalable),
traits: Object.fromEntries(Object.keys(ITEM_TRAITS).map(trait => [trait, itemHasTrait(id, trait)])),
};
}
function itemTypeLabel(type = "") {
return itemTypeDefinition(type).label;
}
function itemTypeDebugSnapshot() {
const ids = new Set([
...Object.values(TOOL_DEFINITIONS).map(def => def.itemType || def.id),
...Object.keys(FOOD_DEFINITIONS || {}),
...Object.keys(EFFECT_DEFINITIONS || {}),
...Object.keys(ITEM_VISUAL_DEFINITIONS || {}),
]);
return [...ids].sort().map(id => itemTypeDefinition(id));
}
global.TarinaiItemRegistry = Object.freeze({
definition: itemTypeDefinition,
label: itemTypeLabel,
debugSnapshot: itemTypeDebugSnapshot,
tool: toolDefinition,
item: itemDefinition,
food: FOOD_REGISTRY,
effect: EFFECT_REGISTRY,
visual: itemVisualDefinition,
hasTrait: itemHasTrait,
radius: itemRadiusFor,
amount: itemAmountFor,
categories: toolCategories,
scalableToolIds,
pinBehavior: pinBehaviorFor,
});
})(typeof window !== "undefined" ? window : globalThis);

533
js/item_render_runtime.js Normal file
View file

@ -0,0 +1,533 @@
"use strict";
// Item field rendering. Kept separate from construction and lifecycle logic.
Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) {
const lightState = lighting || getLightingState(world);
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
const warm = lightState.warmth;
const styleNight = lightState.light < 0.42;
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
if (isPinType(this.type) && this.pinState === "lodged") {
const owner = world?.liveTarinaiById?.(this.pinTargetId) || null;
if (owner?.insideNestBoxId) return;
}
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170;
const dropY = dropT > 0 ? -dropFallHeight * dropT : 0;
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
if (this.type !== "trace" && this.type !== "splat") {
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null;
if (zunchiImg) {
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const zunchiH = this.r * 1.66 * ratio;
drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
alpha: shadow.alpha * visibleAlpha * 1.18,
widthScale: 0.92,
heightScale: 0.92,
});
} else if (this.type === "grass") {
ctx.save();
ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
ctx.fillStyle = shadow.color;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else {
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
}
}
drawItemGlow(ctx, this, lightState, visibleAlpha);
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
ctx.save();
ctx.globalAlpha = 0.32;
ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.save();
ctx.translate(this.x, this.y + dropY);
ctx.globalAlpha = visibleAlpha;
if (isConsumableSpriteType(this.type)) {
drawConsumableFieldSprite(ctx, this.type, this.r, this.seed);
} else if (this.type === "genkotsu") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
const impact = clamp(this.impactFlash || 0, 0, 1);
ctx.save();
ctx.shadowColor = "rgba(54,42,31,0.26)";
ctx.shadowBlur = 5 + impact * 8;
ctx.shadowOffsetY = 4;
const squashY = 1 - impact * 0.08;
const stretchX = 1 + impact * 0.05;
ctx.scale(stretchX, squashY);
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null;
const w = this.r * 2.55;
const h = w * (metrics?.ratio || 1.27);
ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h);
} else {
ctx.fillStyle = "rgba(255,255,255,0.94)";
ctx.strokeStyle = "rgba(64,64,64,0.78)";
ctx.lineWidth = 5;
roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(42,46,52,0.92)";
ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u6b63", 0, -this.r * 0.74);
ctx.fillText("\u7fa9", 0, -this.r * 0.27);
}
if (impact > 0.01) {
ctx.globalAlpha = impact * 0.20;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (isPinType(this.type)) {
const stuck = this.pinState === "lodged";
const isOshibyo = this.type === "oshibyo";
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1);
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78);
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else if (stuck) {
ctx.fillStyle = "#d92736";
ctx.strokeStyle = "rgba(125,18,30,0.78)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.68, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.26, 0, Math.PI * 2);
ctx.fillStyle = "rgba(176,20,30,0.82)";
ctx.fill();
} else {
ctx.fillStyle = "#d92736";
ctx.strokeStyle = "rgba(125,18,30,0.78)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(0, -this.r * 0.26, this.r * 0.70, this.r * 0.48, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.ellipse(0, this.r * 0.18, this.r * 0.82, this.r * 0.30, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.strokeStyle = "rgba(210,210,215,0.92)";
ctx.lineWidth = Math.max(1.5, this.r * 0.12);
ctx.beginPath();
ctx.moveTo(0, this.r * 0.42);
ctx.lineTo(0, this.r * 1.65);
ctx.stroke();
}
ctx.restore();
} else if (this.type === "water_bowl") {
const palette = visualPaletteFor("water_bowl");
ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)";
ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)";
ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "water") {
drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
} else if (this.type === "grass") {
const numericStage = Number.isFinite(this.grassStage) ? clampGrassStage(this.grassStage) : grassStageFromGrowth(this.growth, this.amount);
const growth = grassGrowthForStage(numericStage);
const eaten = (this.amount || 0) <= 0;
const stageLabel = numericStage <= 0 ? "sprout" : (numericStage <= 2 ? "young" : "mature");
const wither = clamp(this.wither || 0, 0, 1);
ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438")));
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineCap = "round";
ctx.lineWidth = styleNight ? 1.5 : 1.8;
const bladeCount = Math.max(2, numericStage + 2);
for (let i = 0; i < bladeCount; i++) {
const spread = stageLabel === "mature" ? 1.05 : 0.68;
const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
const len = this.r * randSeed(this.seed + i + 10, 0.42, stageLabel === "mature" ? 1.18 : 0.92) * clamp(growth, 0.18, 0.92);
const rootX = stageLabel === "mature" ? randSeed(this.seed + i + 31, -this.r * 0.28, this.r * 0.28) : 0;
ctx.beginPath();
ctx.moveTo(rootX, 8);
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
ctx.stroke();
}
if (numericStage >= 3 && !eaten) {
ctx.save();
ctx.globalAlpha = 0.14;
ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
ctx.beginPath();
ctx.ellipse(0, 1, this.r * 0.62, this.r * 0.20, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
} else if (this.type === "stone") {
ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineWidth = 2;
roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.34)";
ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "bed") {
ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
ctx.lineWidth = 1.5;
for (let i = 0; i < 13; i++) {
const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
const a = randSeed(this.seed + i + 60, -0.75, 0.75);
ctx.beginPath();
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "nest_box") {
ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237");
ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24";
ctx.lineWidth = 2.6;
roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b";
roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6);
ctx.fill();
ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)";
ctx.lineWidth = 2.0;
ctx.beginPath();
ctx.moveTo(-this.r * 0.95, -this.r * 0.50);
ctx.lineTo(this.r * 0.95, -this.r * 0.50);
ctx.moveTo(-this.r * 0.95, this.r * 0.48);
ctx.lineTo(this.r * 0.95, this.r * 0.48);
ctx.stroke();
ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f";
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "duplicator") {
ctx.save();
const loaded = !!this.storedFoodType;
const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9);
bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6");
bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7");
bodyGrad.addColorStop(1, styleNight ? "#2c343c" : "#9fafb7");
ctx.fillStyle = bodyGrad;
ctx.strokeStyle = styleNight ? "#1b252c" : "#52636b";
ctx.lineWidth = Math.max(2, this.r * 0.075);
roundedRect(ctx, -this.r * 1.10, -this.r * 0.86, this.r * 1.52, this.r * 1.52, this.r * 0.20);
ctx.fill();
ctx.stroke();
ctx.fillStyle = styleNight ? "rgba(20,28,34,0.52)" : "rgba(255,255,255,0.72)";
roundedRect(ctx, -this.r * 0.92, -this.r * 0.64, this.r * 1.12, this.r * 0.42, this.r * 0.10);
ctx.fill();
ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)";
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)";
ctx.beginPath();
ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(35,47,54,0.52)";
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(126,245,141,0.82)" : "rgba(255,255,255,0.45)";
ctx.beginPath();
ctx.arc(-this.r * 0.74, -this.r * 0.47, this.r * 0.045, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = styleNight ? "#232b30" : "#657079";
ctx.lineWidth = Math.max(1.6, this.r * 0.045);
ctx.beginPath();
ctx.moveTo(this.r * 0.18, -this.r * 0.34);
ctx.quadraticCurveTo(this.r * 0.48, -this.r * 0.52, this.r * 0.74, -this.r * 0.28);
ctx.stroke();
ctx.fillStyle = styleNight ? "#283139" : "#e6edf0";
ctx.beginPath();
ctx.arc(this.r * 0.78, -this.r * 0.28, this.r * 0.10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
const dishGrad = ctx.createLinearGradient(this.r * 0.08, -this.r * 0.06, this.r * 1.28, this.r * 0.62);
dishGrad.addColorStop(0, styleNight ? "#3f454b" : "#eef4f4");
dishGrad.addColorStop(0.58, styleNight ? "#737c82" : "#ffffff");
dishGrad.addColorStop(1, styleNight ? "#242a30" : "#adb8bd");
ctx.fillStyle = dishGrad;
ctx.strokeStyle = styleNight ? "#12181d" : "#67767e";
ctx.lineWidth = Math.max(2, this.r * 0.06);
ctx.beginPath();
ctx.ellipse(this.r * 0.62, this.r * 0.42, this.r * 0.83, this.r * 0.39, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = loaded ? "rgba(255,245,209,0.96)" : "rgba(230,235,238,0.48)";
ctx.beginPath();
ctx.ellipse(this.r * 0.62, this.r * 0.32, this.r * 0.50, this.r * 0.23, -0.08, 0, Math.PI * 2);
ctx.fill();
if (loaded) {
ctx.save();
ctx.translate(this.r * 0.62, this.r * 0.24);
ctx.scale(0.82, 0.82);
drawStoredConsumableSprite(ctx, this.storedFoodType || "food", Math.max(7, this.r * 0.35), this.seed + 241);
ctx.restore();
} else {
ctx.fillStyle = "rgba(72,80,86,0.62)";
ctx.font = `bold ${Math.max(10, Math.round(this.r * 0.34))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("?", this.r * 0.62, this.r * 0.28);
}
ctx.fillStyle = styleNight ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.45)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (this.type === "signboard") {
ctx.save();
const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12);
const boardHalfW = this.r * 1.20;
boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8");
boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6");
boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397");
ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87";
ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444";
ctx.lineWidth = 2.2;
roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04);
ctx.fill();
ctx.stroke();
ctx.fillStyle = boardGrad;
ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48";
roundedRect(ctx, -boardHalfW, -this.r * 1.28, boardHalfW * 2, this.r * 1.38, this.r * 0.18);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)";
ctx.lineWidth = 1.1;
for (let i = 0; i < 3; i++) {
const y = -this.r * 1.02 + i * this.r * 0.36;
ctx.beginPath();
ctx.moveTo(-boardHalfW * 0.82, y);
ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, boardHalfW * 0.82, y + Math.cos(this.seed + i) * 2);
ctx.stroke();
}
ctx.fillStyle = "rgba(255,255,255,0.28)";
roundedRect(ctx, -boardHalfW * 0.82, -this.r * 1.10, boardHalfW * 1.64, this.r * 0.20, this.r * 0.08);
ctx.fill();
const raw = String(this.text || "").replace(/\r/g, "").trim();
if (raw) {
const charsPerLine = 6;
const lines = [];
for (const rawLine of raw.split("\n")) {
let rest = rawLine.trim();
if (!rest && lines.length < 3) { lines.push(""); continue; }
while (rest && lines.length < 3) {
const chars = Array.from(rest);
lines.push(chars.slice(0, charsPerLine).join(""));
rest = chars.slice(charsPerLine).join("");
}
if (lines.length >= 3) break;
}
ctx.fillStyle = styleNight ? "rgba(37,31,24,0.95)" : "rgba(55,37,22,0.96)";
ctx.font = `bold ${Math.max(11, Math.round(this.r * 0.39))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const startY = -this.r * 0.66 - (lines.length - 1) * this.r * 0.16;
lines.forEach((line, index) => ctx.fillText(line, 0, startY + index * this.r * 0.34));
} else {
ctx.fillStyle = styleNight ? "rgba(55,45,33,0.58)" : "rgba(91,64,35,0.50)";
ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u2026", 0, -this.r * 0.58);
}
ctx.restore();
} else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");
ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = styleNight ? "#211a17" : "#30231d";
ctx.beginPath();
ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(38,37,35,0.88)";
for (let i = 0; i < Math.min(6, count); i++) {
const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0);
const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55);
ctx.beginPath();
ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "rgba(255,248,220,0.82)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${count}`, 0, this.r * 1.25);
} else if (this.type === "ant_corpse") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker");
ctx.save();
ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26);
ctx.scale(1, -1);
ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1);
if (img) {
const w = this.r * 3.9;
const metrics = getImageMetrics("ant_worker");
const h = w * (metrics?.ratio || 0.52);
ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h);
} else {
ctx.fillStyle = "rgba(42,39,35,0.85)";
ctx.beginPath();
ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
ctx.save();
ctx.rotate(this.spin || 0);
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("\u26bd", 0, 1);
if (moving > 0.55) {
ctx.globalAlpha = 0.14 + moving * 0.10;
ctx.shadowColor = "transparent";
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
ctx.strokeStyle = "rgba(96,48,34,0.78)";
ctx.lineWidth = 2;
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#f4d15b";
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
ctx.strokeStyle = "rgba(74,55,39,0.82)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(0, -this.r * 0.88);
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
ctx.stroke();
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
ctx.beginPath();
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(42,36,29,0.66)";
ctx.font = "bold 10px ui-rounded, sans-serif";
ctx.textAlign = "center";
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
} else if (this.type === "fence_v" || this.type === "fence_h") {
const vertical = this.type === "fence_v";
const len = Math.max(112, this.r * 3.55);
const thick = Math.max(10, this.r * 0.31);
ctx.save();
ctx.rotate(vertical ? 0 : Math.PI / 2);
const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
const edge = styleNight ? "#4d3d2f" : "#5f3f25";
const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
ctx.fillStyle = wood;
ctx.strokeStyle = edge;
ctx.lineWidth = 2.4;
ctx.beginPath();
roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = hi;
ctx.beginPath();
roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
ctx.fill();
ctx.restore();
} else if (this.type === "zunchi") {
const zunchiId = this.zunchiVariant || "zunchi";
const img = images.get(zunchiId) || images.get("zunchi");
if (img) {
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
const ratio = metrics?.ratio || 178 / 236;
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
ctx.globalAlpha *= stageAlpha;
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
}
} else if (this.type === "trace") {
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
} else if (this.type === "splat") {
const fade = clamp(this.amount / 260, 0, 1);
ctx.globalAlpha = visibleAlpha * fade;
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
ctx.lineWidth = 1.8;
const blobs = [
[-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
];
for (const [bx, by, bw, bh] of blobs) {
ctx.beginPath();
ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
for (let i = 0; i < 7; i++) {
ctx.beginPath();
ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
});

View file

@ -0,0 +1,115 @@
"use strict";
// Type-specific Item constructor state. Kept out of items.js so construction stays focused on shared fields.
function initializeItemTypeState(item, type, x, y) {
if (!item) return item;
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true;
if (["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(type)) item.roles.danger = true;
if (type === "grass") item.roles.grassMaterial = true;
if (type === "grass") {
item.grassStage = Math.floor(rand(0, 2.999));
setGrassStage(item, item.grassStage, { force: true });
item.seedTimer = rand(18, 38);
item.eatenAmount = 0;
item.fertilityBoost = 0;
item.lifeSpan = rand(420, 860);
item.wither = 0;
}
if (type === "bed") {
item.comfort = rand(0.86, 1.14);
item.wear = 0;
}
if (type === "signboard") {
item.text = "";
item.textEditedAt = -999;
}
if (type === "duplicator") {
item.storedFoodType = "";
item.storedFoodLabel = "";
item.roles.food = false;
item.roles.duplicator = true;
item.needEffects.food = 55;
item.amount = 999;
}
if (type === "ball") {
item.vx = rand(-5, 5);
item.vy = rand(-5, 5);
item.prevX = x;
item.prevY = y;
item.spin = rand(0, Math.PI * 2);
item.spinVelocity = rand(-1.6, 1.6);
item.lastKickedAt = -999;
item.lastKickerId = "";
item.lastPokedAt = -999;
item.pokeCombo = 0;
item.lastPokeAngle = rand(0, Math.PI * 2);
}
if (type === "zunchi") {
item.amount = Math.max(Number(item.amount || 0) || 0, 240);
item.stage = "fresh";
item.stageTimer = 0;
item.fertility = 0;
item.freshness = 1;
item.spawnGrace = 10.0;
item.zunchiVariant = stableUnit(item.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
}
if (type === "ant_nest") {
item.antCount = ANT_NEST_START_COUNT || 6;
item.antSpawnTimer = rand(0.6, 2.2);
item.antSpawnCooldown = rand(0.8, 2.4);
item.antWorkers = typeof buildAntWorkerPool === "function"
? buildAntWorkerPool(item.antCount)
: Array.from({ length: item.antCount }, () => ANT_WORKER_HP || 32);
item.queenSpawnAt = -999;
}
if (type === "ant_corpse") {
item.amount = 24;
item.workerSprite = "ant_worker";
item.decayTimer = 0;
}
if (type === "firecracker") {
item.fuseTimer = 5;
item.fuseMax = 5;
}
if (type === "genkotsu") {
item.amount = 100;
item.dropMax = 0;
item.dropImpactDone = false;
item.impactFlash = 0;
item.lingerTimer = 0;
item.lingerDuration = 2.0;
}
if (isPinType(type)) {
item.amount = 100;
item.vx = rand(-120, 120);
item.vy = rand(-90, 90);
item.prevX = x;
item.prevY = y;
item.spin = rand(-0.35, 0.35);
item.spinVelocity = rand(-5.4, 5.4);
item.pinState = "loose";
item.pinTargetId = "";
item.pinAttachAngle = 0;
item.pinAttachDistance = 0;
item.pinOffsetY = 0;
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.pinLogAt = -999;
item.deletable = true;
}
if (isServingFoodType(type)) {
item.toolSize = "medium";
item.foodServingScale = 1;
item.foodServingsMax = foodServingsForSize(item.toolSize);
item.foodServingsRemaining = item.foodServingsMax;
item.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval());
item.amount = item.foodServingsRemaining;
}
return item;
}
if (typeof window !== "undefined") window.initializeItemTypeState = initializeItemTypeState;

200
js/item_update_scheduler.js Normal file
View file

@ -0,0 +1,200 @@
"use strict";
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = [
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "water_bowl",
];
function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return 2.0;
if (item.type === "water") return 1.8;
if (item.type === "trace" || item.type === "splat") return 1.2;
if (item.type === "ant_corpse") return 3.5;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
return Infinity;
}
function heapPush(heap, entry) {
heap.push(entry);
let i = heap.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heap[p].time <= entry.time) break;
heap[i] = heap[p];
i = p;
}
heap[i] = entry;
}
function heapPop(heap) {
if (!heap.length) return null;
const root = heap[0];
const last = heap.pop();
if (heap.length && last) {
let i = 0;
while (true) {
let c = i * 2 + 1;
if (c >= heap.length) break;
if (c + 1 < heap.length && heap[c + 1].time < heap[c].time) c++;
if (heap[c].time >= last.time) break;
heap[i] = heap[c];
i = c;
}
heap[i] = last;
}
return root;
}
function schedulerSignature(worldRef) {
return [
(worldRef.items || []).length,
worldRef.itemBucketRebuildsTotal || 0,
worldRef.itemBucketsDirty ? 1 : 0,
].join(":");
}
function ensureBuckets(worldRef) {
if (worldRef?.ensureItemBuckets) worldRef.ensureItemBuckets("item-update-scheduler");
}
function candidateItems(worldRef) {
if (!worldRef?.itemsOfType) return (worldRef.items || []).filter(it => it && !it.dead && it.type !== "grass");
const out = [];
for (const type of SCHEDULED_ITEM_TYPES) {
const bucket = worldRef.itemsOfType(type) || [];
for (const it of bucket) if (it && !it.dead) out.push(it);
}
return out;
}
function scheduleItem(state, item, now, interval = itemUpdateInterval(item)) {
if (!item || item.dead || !Number.isFinite(interval)) return false;
if (interval <= 0) {
state.realtime.push(item);
item._schedulerRealtime = true;
return true;
}
const token = (item._schedulerToken || 0) + 1;
item._schedulerToken = token;
item._schedulerRealtime = false;
if (!Number.isFinite(item._schedulerLastAt)) item._schedulerLastAt = now;
heapPush(state.heap, { time: now + interval, item, token });
return true;
}
function rebuild(worldRef, reason = "manual") {
ensureBuckets(worldRef);
const now = worldRef.time || 0;
const state = {
heap: [],
realtime: [],
signature: schedulerSignature(worldRef),
rebuilds: ((worldRef._itemUpdateScheduler?.rebuilds || 0) + 1),
reason,
};
for (const item of candidateItems(worldRef)) {
item._schedulerLastAt = Number.isFinite(item._schedulerLastAt) ? item._schedulerLastAt : now;
scheduleItem(state, item, now, itemUpdateInterval(item));
}
worldRef._itemUpdateScheduler = state;
worldRef._itemUpdateRealtime = state.realtime;
return state;
}
function ensure(worldRef) {
const sig = schedulerSignature(worldRef);
if (!worldRef._itemUpdateScheduler || worldRef._itemUpdateScheduler.signature !== sig) return rebuild(worldRef, "signature");
return worldRef._itemUpdateScheduler;
}
function runRealtime(worldRef, state, dt) {
let ran = 0;
const now = worldRef.time || 0;
const nextRealtime = [];
for (const item of state.realtime || []) {
if (!item || item.dead) continue;
const interval = itemUpdateInterval(item);
if (!Number.isFinite(interval)) {
item._schedulerRealtime = false;
continue;
}
if (interval <= 0) {
item.update(dt, worldRef);
item._schedulerLastAt = now;
ran += 1;
if (Number.isFinite(itemUpdateInterval(item)) && itemUpdateInterval(item) <= 0) nextRealtime.push(item);
else scheduleItem(state, item, now, itemUpdateInterval(item));
} else {
scheduleItem(state, item, now, interval);
}
}
state.realtime = nextRealtime;
worldRef._itemUpdateRealtime = state.realtime;
return ran;
}
function runDue(worldRef, state) {
let ran = 0;
const now = worldRef.time || 0;
const maxDue = Math.max(24, Math.ceil((state.heap.length || 0) * 0.08));
let dueRuns = 0;
while (state.heap.length && state.heap[0].time <= now + 0.0001 && dueRuns < maxDue) {
const entry = heapPop(state.heap);
const item = entry?.item;
if (!item || item.dead || item._schedulerToken !== entry.token) continue;
const interval = itemUpdateInterval(item);
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
state.realtime.push(item);
item._schedulerRealtime = true;
continue;
}
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
item.update(elapsed, worldRef);
item._schedulerLastAt = now;
ran += 1;
dueRuns += 1;
scheduleItem(state, item, now, itemUpdateInterval(item));
}
return ran;
}
function run(worldRef, dt) {
const end = global.TarinaiPerf?.begin?.("update.items") || null;
try {
const state = ensure(worldRef);
let ran = runRealtime(worldRef, state, dt);
ran += runDue(worldRef, state);
state.signature = schedulerSignature(worldRef);
worldRef._itemUpdateSchedulerStats = {
heap: state.heap.length,
realtime: state.realtime.length,
rebuilds: state.rebuilds,
lastReason: state.reason,
lastRan: ran,
};
return ran;
} finally {
if (end) end();
}
}
function trackedDynamicItems(worldRef) {
return worldRef?._itemUpdateRealtime || [];
}
global.TarinaiItemUpdateScheduler = Object.freeze({ run, rebuild, ensure, itemUpdateInterval, trackedDynamicItems });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,144 +0,0 @@
"use strict";
(function (global) {
const raw = String.raw`{
"water": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(86,157,224,0.82)", "stroke": "rgba(45,101,168,0.52)", "highlight": "rgba(255,255,255,0.64)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "blue"]
},
"zunda_juice": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(125,218,82,0.94)", "stroke": "rgba(63,150,50,0.62)", "highlight": "rgba(238,255,226,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "green"]
},
"mercury": {
"renderer": "oval_droplet",
"shape": "horizontal_oval_liquid",
"palette": { "fill": "rgba(210,216,222,0.94)", "stroke": "rgba(112,122,132,0.62)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"]
},
"water_bowl": {
"renderer": "water_bowl",
"shape": "bowl_with_horizontal_oval_water",
"palette": {
"bowlFill": "rgba(171,120,76,0.72)",
"bowlStroke": "rgba(94,69,48,0.55)",
"waterFill": "rgba(86,157,224,0.58)",
"waterStroke": "rgba(62,113,178,0.36)",
"highlight": "rgba(255,255,255,0.54)"
},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"]
},
"sleep_drug": {
"renderer": "sleep_tablet",
"shape": "moon_tablet",
"palette": { "fill": "#ebe4ff", "stroke": "rgba(74,60,144,0.72)", "accent": "rgba(112,91,188,0.82)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "moon", "purple"]
},
"laxative": {
"renderer": "laxative_tablet",
"shape": "rounded_tablet_zunchi_motif",
"palette": { "fill": "#f4d49a", "stroke": "rgba(98,61,29,0.82)", "accent": "rgba(96,70,38,0.88)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "zunchi motif", "tan"]
},
"mystery_drug": {
"renderer": "mystery_tablet",
"shape": "question_mark_tablet",
"palette": { "fill": "#f3ecff", "stroke": "rgba(91,55,150,0.84)", "accent": "rgba(91,55,150,0.92)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["tablet", "question mark", "purple"]
},
"ammo": {
"renderer": "bullet",
"shape": "bullet",
"palette": { "fill": "#f4c46a", "stroke": "rgba(87,51,22,0.78)", "highlight": "rgba(255,237,176,0.62)" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["bullet", "gold"]
},
"protein": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#f4a24f", "stroke": "#b45b27", "highlight": "#ffe2b9" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "orange"]
},
"niteropu": {
"renderer": "powder_pile",
"shape": "powder_pile",
"palette": { "fill": "#8a79d0", "stroke": "#4c3f91", "highlight": "#d8d1ff" },
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["powder", "purple"]
},
"giant_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#ffe3cf", "stroke": "#c9632f", "highlight": "#ffb47b" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["pill", "large", "orange"]
},
"dwarf_drug": {
"renderer": "split_pill",
"shape": "split_pill",
"palette": { "fill": "#e9e9ff", "stroke": "#6967ac", "highlight": "#b8b5f4" },
"scale": 0.92,
"uiPreviewScale": 0.92,
"fieldScale": 0.92,
"accessibleShapeTraits": ["pill", "small", "purple"]
},
"ball": {
"renderer": "emoji",
"shape": "soccer_ball",
"palette": {},
"scale": 1,
"uiPreviewScale": 1,
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
}
}`;
const defs = Object.freeze(JSON.parse(raw));
function itemVisualDefinition(type = "") {
return defs[String(type || "")] || null;
}
function itemVisualPalette(type = "") {
return itemVisualDefinition(type)?.palette || null;
}
global.TARINAI_ITEM_VISUALS = defs;
global.itemVisualDefinition = itemVisualDefinition;
global.itemVisualPalette = itemVisualPalette;
})(typeof window !== "undefined" ? window : globalThis);

File diff suppressed because it is too large Load diff

View file

@ -78,16 +78,22 @@ function loop() {
const rawDt = t - last;
const dt = clamp(rawDt, 0, 0.05);
last = t;
window.TarinaiPerf?.beginFrame?.();
fpsTimer += rawDt;
fpsFrames += 1;
if (fpsTimer >= 0.5) {
window.__tarinaiFps = Math.round(fpsFrames / Math.max(fpsTimer, 0.001));
window.TarinaiPerformance?.updateFps?.(window.__tarinaiFps);
fpsTimer = 0;
fpsFrames = 0;
}
const endUpdate = window.TarinaiPerf?.begin?.("update.total") || null;
world.update(dt);
if (endUpdate) endUpdate();
const endRender = window.TarinaiPerf?.begin?.("render.total") || null;
render();
if (endRender) endRender();
window.TarinaiPerf?.endFrame?.(rawDt, window.__tarinaiFps || 0);
if (window.TarinaiPerf?.consumeResizeRequest?.() && typeof resizeCanvas === "function") resizeCanvas();
statsTimer += dt;
const statsInterval = 2.5;
if (statsTimer > statsInterval) {

115
js/perf_profiler.js Normal file
View file

@ -0,0 +1,115 @@
"use strict";
(function (global) {
const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now());
const buckets = new Map();
let frameOpen = null;
let frameCount = 0;
let lastQualityCheckAt = 0;
let tier = "high";
let dprScale = 1;
let resizeRequested = false;
function bucketFor(label) {
let b = buckets.get(label);
if (!b) {
b = { label, ms: 0, avg: 0, max: 0, calls: 0, last: 0 };
buckets.set(label, b);
}
return b;
}
function begin(label) {
const start = nowMs();
return () => {
const elapsed = Math.max(0, nowMs() - start);
const b = bucketFor(label);
b.ms += elapsed;
b.calls += 1;
b.last = elapsed;
if (elapsed > b.max) b.max = elapsed;
return elapsed;
};
}
function beginFrame() {
frameOpen = nowMs();
return frameOpen;
}
function smoothBuckets() {
for (const b of buckets.values()) {
b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms;
b.ms = 0;
b.calls = 0;
}
}
function qualityFromFps(fps) {
const f = Number(fps) || 0;
if (f > 55) return { tier: "high", dpr: 1 };
if (f < 32) return { tier: "low", dpr: 0.72 };
if (f < 45) return { tier: "mid", dpr: 0.86 };
return { tier: tier === "low" ? "mid" : tier, dpr: tier === "low" ? 0.86 : dprScale };
}
function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) {
frameCount += 1;
if (frameOpen != null) {
const b = bucketFor("frame.total");
const elapsed = Math.max(0, nowMs() - frameOpen);
b.ms += elapsed;
b.calls += 1;
b.last = elapsed;
if (elapsed > b.max) b.max = elapsed;
frameOpen = null;
}
const t = nowMs();
if (t - lastQualityCheckAt >= 750) {
lastQualityCheckAt = t;
smoothBuckets();
const next = qualityFromFps(fps);
const nextDpr = next.dpr;
if (next.tier !== tier || Math.abs(nextDpr - dprScale) > 0.04) {
tier = next.tier;
dprScale = nextDpr;
resizeRequested = true;
}
}
}
function renderQualityTier() {
return tier;
}
function dprScaleValue() {
return dprScale;
}
function consumeResizeRequest() {
const v = resizeRequested;
resizeRequested = false;
return v;
}
function snapshot() {
const entries = [...buckets.values()].map(b => ({
label: b.label,
avg: Number((b.avg || 0).toFixed(2)),
max: Number((b.max || 0).toFixed(2)),
last: Number((b.last || 0).toFixed(2)),
calls: b.calls || 0,
})).sort((a, b) => b.avg - a.avg);
return { tier, dprScale, frameCount, entries };
}
global.TarinaiPerf = Object.freeze({
begin,
beginFrame,
endFrame,
renderQualityTier,
dprScale: dprScaleValue,
consumeResizeRequest,
snapshot,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,55 +0,0 @@
"use strict";
// Build 15.6.2-lag-pass:
// Dynamic load-based quality switching was removed. The old implementation
// counted tarinai/ants/items/effects from many hot paths, so the load check
// itself became a source of frame spikes. Keep a tiny compatibility object for
// legacy optional calls, but never scan the world and never change quality.
class TarinaiPerformanceManager {
constructor() {
this.fps = 60;
this.level = 0;
this.lastLevel = 0;
}
updateFps(fps) {
const n = Number(fps);
if (Number.isFinite(n) && n > 0) this.fps = Math.round(n);
return 0;
}
quality() {
return TarinaiPerformanceManager.STATIC_QUALITY;
}
debugSnapshot() {
return { version: window.TARINAI_VERSION || "", fps: this.fps, level: 0, quality: TarinaiPerformanceManager.STATIC_QUALITY };
}
}
TarinaiPerformanceManager.STATIC_QUALITY = Object.freeze({
fps: 60,
pressure: 0,
level: 0,
effectDensity: 1,
tarinaiCount: 0,
antStride: 2,
itemCount: 0,
antCount: 0,
effectCount: 0,
workUnits: 0,
maxDpr: 2,
effectLimit: 96,
aiIntervalScale: 1,
envIntervalScale: 1,
socialScanLimit: 8,
itemScanLimit: 16,
collisionInterval: 1.8,
bedConflictInterval: 3.2,
statsInterval: 1.2,
terrainRedrawInterval: 3.0,
backgroundLightStep: 180,
uiUpdateInterval: 1.2,
});
window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager();

View file

@ -1,90 +0,0 @@
"use strict";
(function (global) {
const MAX_PENDING_LOSSES = 16;
function relationWithDead(observer, dead, worldRef) {
if (!observer || !dead || observer === dead) return "";
const deadFamily = worldRef?.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || "");
const observerFamily = observer.familyKey || observer.archiveKey || observer.id || "";
const deadId = dead.id || dead.releasedLiveId || "";
const observerId = observer.id || observer.liveId || "";
if (deadFamily && (observer.parents || []).includes(deadFamily)) return "\u89aa";
if (deadFamily && (observer.children || []).includes(deadFamily)) return "\u5b50";
if (observerFamily && (dead.parents || []).includes(observerFamily)) return "\u5b50";
if (observerFamily && (dead.children || []).includes(observerFamily)) return "\u89aa";
const rel = deadId ? observer.relationships?.[deadId] : null;
const reverse = observerId ? dead.relationships?.[observerId] : null;
if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "\u53cb\u9054";
return "";
}
function applyLiveDeathShock(observer, dead, relation, worldRef) {
const strong = relation === "\u89aa" || relation === "\u5b50";
if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u3044\u305f");
const stress = strong ? 18 : 11;
const fear = strong ? 2.1 : 1.45;
if (observer.enterPanic) {
const relLabel = relation || "\u8eab\u8fd1\u306a\u500b\u4f53";
observer.lastPanicDetail = `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u305f`;
observer.enterPanic({ threat: dead, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" });
}
else {
observer.sleeping = false;
observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relation || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, wake: true });
observer.fearTimer = Math.max(observer.fearTimer || 0, fear);
if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 });
observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)");
}
observer.recordChangeCause?.(`${relation}\u306e\u6b7b\u4ea1`, "\u30b9\u30c8\u30ec\u30b9", { value: stress });
observer.addRecord?.(`${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\u306e\u6b7b\u306b\u6c17\u3065\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death");
}
function notifyDeathToRelations(dead, reason = "") {
const worldRef = this || global.world;
if (!worldRef || !dead) return;
const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || "");
const deadId = dead.id || dead.releasedLiveId || "";
let liveHits = 0;
for (const other of worldRef.tarinai || []) {
if (!other || other === dead || other.dead) continue;
const relation = relationWithDead(other, dead, worldRef);
if (!relation) continue;
applyLiveDeathShock(other, dead, relation, worldRef);
liveHits += 1;
}
for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) {
const data = entry.data || entry;
const relation = relationWithDead(data, dead, worldRef);
if (!relation) continue;
if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = [];
const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId));
if (!duplicate) {
entry.pendingLosses.unshift({
relation,
name: dead.name || "\u305f\u308a\u306a\u3044",
familyKey: deadFamily,
liveId: deadId,
reason: reason || dead.deathReason || "\u6b7b\u4ea1",
time: worldRef.time || 0,
});
if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES;
}
}
if (liveHits) worldRef.log?.(`${dead.name || "\u305f\u308a\u306a\u3044"}\u306e\u6b7b\u306b\u3001\u8eab\u8fd1\u306a\u500b\u4f53\u304c\u52d5\u63fa\u3057\u305f\u3002`, "death", { participants: [dead] });
global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef);
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}
function patchWorldPrototype() {
const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; };
if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return;
const World = global.World;
if (!World) return;
apply(World.prototype);
}
global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype };
patchWorldPrototype();
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -147,7 +147,7 @@ function placementPreviewFor(world) {
return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) };
}
if (type === "grass") {
blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
blocked = !(world.canAddGrass?.(1) ?? true) || !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55));
}
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp);
return { type, x, y, r, blocked };
@ -227,18 +227,20 @@ const backgroundCache = {
function backgroundCacheKey(world, lighting) {
const weather = world.weather || "sunny";
const field = world.fieldType || "garden";
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const step = perf >= 3 ? 64 : perf === 2 ? 96 : 180;
const progressBucket = Math.round((lighting.progress || 0) * step);
const weatherBucket = Math.floor((world.time || 0) / (perf >= 2 ? 4 : 2));
return `${field}:${weather}:${progressBucket}:${weatherBucket}`;
const ground = world.groundType || "soil";
// Keep the heavy garden/sky redraw coarse. Fine light changes are handled by overlays.
const lightBucket = Math.round((lighting.light || 0) * 8);
const warmthBucket = Math.round((lighting.goldenStrength || 0) * 5);
const weatherBucket = Math.floor((world.time || 0) / 8);
return `${field}:${ground}:${weather}:${lightBucket}:${warmthBucket}:${weatherBucket}`;
}
// Cached low-priority terrain layer: grass, footprints and splats are mostly
// static visual noise. Rendering them into an offscreen layer avoids repeating
// thousands of small strokes every frame while preserving simulation behavior.
// static visual noise. Rendering them into visible chunks avoids repeating
// thousands of small strokes every frame and avoids redrawing the entire field
// when a small terrain area changes.
const terrainCache = {
canvas: null,
ctx: null,
@ -247,6 +249,12 @@ const terrainCache = {
key: "",
};
const terrainChunkCache = {
size: 256,
key: "",
chunks: new Map(),
};
function isCachedTerrainItem(it) {
if (!it || it.dead) return false;
if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true;
@ -273,16 +281,104 @@ function renderLayerKindRank(entity) {
}
function terrainCacheKey(worldRef, lighting) {
const perf = worldRef.performanceLevel ? worldRef.performanceLevel() : 0;
const lightBucket = Math.round((lighting.light || 0) * (perf >= 2 ? 8 : 14));
const version = worldRef.terrainVersion || 0;
return `${worldRef.fieldType || "garden"}:${worldRef.w}x${worldRef.h}:${lightBucket}:v${version}`;
const lightBucket = Math.round((lighting.light || 0) * 6);
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${worldRef.w}x${worldRef.h}:${lightBucket}`;
}
function terrainChunkKey(cx, cy) {
return `${cx}:${cy}`;
}
function terrainChunkFor(cx, cy, worldRef) {
const key = terrainChunkKey(cx, cy);
let chunk = terrainChunkCache.chunks.get(key);
const size = terrainChunkCache.size;
const x = cx * size;
const y = cy * size;
const w = Math.max(1, Math.min(size, Math.ceil((worldRef.w || 1) - x)));
const h = Math.max(1, Math.min(size, Math.ceil((worldRef.h || 1) - y)));
if (!chunk) {
const canvas = document.createElement("canvas");
chunk = { canvas, ctx: canvas.getContext("2d"), x, y, w, h, dirty: true, key: "" };
terrainChunkCache.chunks.set(key, chunk);
}
if (chunk.w !== w || chunk.h !== h) {
chunk.w = w;
chunk.h = h;
chunk.dirty = true;
}
return chunk;
}
function redrawTerrainChunk(chunk, worldRef, lighting) {
const c = chunk.ctx;
chunk.canvas.width = Math.max(1, chunk.w);
chunk.canvas.height = Math.max(1, chunk.h);
c.clearRect(0, 0, chunk.w, chunk.h);
c.save();
c.translate(-chunk.x, -chunk.y);
const oldPointer = worldRef.pointer;
try {
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
const cx = chunk.x + chunk.w / 2;
const cy = chunk.y + chunk.h / 2;
const radius = Math.hypot(chunk.w, chunk.h) / 2 + 90;
const candidates = worldRef.nearbyItems ? worldRef.nearbyItems(cx, cy, radius, false) : (worldRef.items || []);
const rect = { left: chunk.x - 80, top: chunk.y - 80, right: chunk.x + chunk.w + 80, bottom: chunk.y + chunk.h + 80 };
for (const it of candidates || []) {
if (!isCachedTerrainItem(it) || !isEntityVisibleInRect(it, rect, 80)) continue;
it.draw(c, worldRef.time || 0, lighting);
}
} catch (err) {
console.error("terrain chunk redraw failed", err);
} finally {
if (oldPointer) worldRef.pointer = oldPointer;
c.restore();
}
chunk.dirty = false;
chunk.key = terrainChunkCache.key;
}
function drawTerrainLayer(ctx, worldRef, lighting, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.terrain") || null;
try {
const key = terrainCacheKey(worldRef, lighting);
if (terrainChunkCache.key !== key || worldRef.terrainDirtyGlobal || (worldRef.terrainDirty && !worldRef.terrainDirtyChunks?.size)) {
terrainChunkCache.key = key;
terrainChunkCache.chunks.clear();
worldRef.terrainDirty = false;
worldRef.terrainDirtyGlobal = false;
worldRef.terrainDirtyChunks?.clear?.();
} else if (worldRef.terrainDirty && worldRef.terrainDirtyChunks?.size) {
for (const chunkKey of worldRef.terrainDirtyChunks) {
const chunk = terrainChunkCache.chunks.get(chunkKey);
if (chunk) chunk.dirty = true;
}
worldRef.terrainDirty = false;
worldRef.terrainDirtyChunks.clear();
}
const size = terrainChunkCache.size;
const minX = Math.max(0, Math.floor((visibleRect.left || 0) / size));
const maxX = Math.min(Math.floor(((worldRef.w || 1) - 1) / size), Math.floor((visibleRect.right || 0) / size));
const minY = Math.max(0, Math.floor((visibleRect.top || 0) / size));
const maxY = Math.min(Math.floor(((worldRef.h || 1) - 1) / size), Math.floor((visibleRect.bottom || 0) / size));
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const chunk = terrainChunkFor(cx, cy, worldRef);
if (chunk.dirty || chunk.key !== terrainChunkCache.key) redrawTerrainChunk(chunk, worldRef, lighting);
ctx.drawImage(chunk.canvas, chunk.x, chunk.y, chunk.w, chunk.h);
}
}
} finally {
if (end) end();
}
}
function ensureTerrainCache(worldRef, lighting) {
// Compatibility fallback for older callers. The main renderer uses chunks.
const w = Math.max(1, Math.floor(worldRef.w || 1));
const h = Math.max(1, Math.floor(worldRef.h || 1));
const key = terrainCacheKey(worldRef, lighting);
const key = `${terrainCacheKey(worldRef, lighting)}:fallback:v${worldRef.terrainVersion || 0}`;
if (!terrainCache.canvas) {
terrainCache.canvas = document.createElement("canvas");
terrainCache.ctx = terrainCache.canvas.getContext("2d");
@ -297,11 +393,8 @@ function ensureTerrainCache(worldRef, lighting) {
c.clearRect(0, 0, w, h);
const oldPointer = worldRef.pointer;
try {
// Pointer hover rings should not be baked into the cache.
if (oldPointer) worldRef.pointer = { ...oldPointer, inside: false };
for (const it of worldRef.items || []) {
if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
}
for (const it of worldRef.items || []) if (isCachedTerrainItem(it)) it.draw(c, worldRef.time || 0, lighting);
worldRef.terrainDirty = false;
return terrainCache.canvas;
} catch (err) {
@ -397,6 +490,64 @@ function ensureWorldRenderStack(worldRef) {
return stack;
}
function collectVisibleRenderStack(worldRef, visibleRect) {
const end = window.TarinaiPerf?.begin?.("render.stackBuild") || null;
try {
if (!worldRef._visibleRenderStack) worldRef._visibleRenderStack = { backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
const stack = worldRef._visibleRenderStack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
const seen = new Set();
const addItem = (it) => {
if (!it || it.dead || seen.has(it) || isCachedTerrainItem(it) || !isEntityVisibleInRect(it, visibleRect)) return;
seen.add(it);
if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
return;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
return;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
};
const addLayered = (entity) => {
if (!entity || entity.dead || seen.has(entity) || !isEntityVisibleInRect(entity, visibleRect)) return;
seen.add(entity);
stack.layered.push({ entity, y: renderLayerSortY(entity), rank: renderLayerKindRank(entity) });
};
worldRef.ensureSpatial?.("render-visible");
if (worldRef.spatial?.nearbyRectInto) {
const itemScratch = worldRef._renderVisibleItemsScratch || (worldRef._renderVisibleItemsScratch = []);
itemScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.staticItemCells || worldRef.spatial.itemCells, visibleRect, itemScratch);
if (worldRef.spatial.dynamicItemCells) worldRef.spatial.nearbyRectInto(worldRef.spatial.dynamicItemCells, visibleRect, itemScratch);
for (const it of itemScratch) addItem(it);
const tarinaiScratch = worldRef._renderVisibleTarinaiScratch || (worldRef._renderVisibleTarinaiScratch = []);
tarinaiScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.tarinaiCells, visibleRect, tarinaiScratch);
for (const t of tarinaiScratch) addLayered(t);
const antScratch = worldRef._renderVisibleAntScratch || (worldRef._renderVisibleAntScratch = []);
antScratch.length = 0;
worldRef.spatial.nearbyRectInto(worldRef.spatial.antCells, visibleRect, antScratch);
for (const a of antScratch) addLayered(a);
} else {
for (const it of worldRef.items || []) addItem(it);
for (const t of worldRef.tarinai || []) addLayered(t);
for (const a of worldRef.ants || []) addLayered(a);
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
return stack;
} finally {
if (end) end();
}
}
function ensureBackgroundCache(w, h, lighting) {
const key = backgroundCacheKey(world, lighting);
if (!backgroundCache.canvas) {
@ -484,10 +635,11 @@ function drawGardenBackdrop(w, h, lighting) {
function drawGardenBed(w, h, lighting) {
const light = lighting.light;
const fieldType = world.fieldType || "garden";
const groundType = world.groundType || "soil";
const x = 18, y = 22, bw = w - 36, bh = h - 44;
const border = activeCtx.createLinearGradient(0, y, 0, y + bh);
const borderTop = fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
const borderTop = groundType === "concrete" ? "#8b8f98" : groundType === "blanket" ? "#d6a6bc" : groundType === "foot_massage" ? "#b78a62" : fieldType === "park" ? "#6f8f55" : fieldType === "cage" ? "#8b8f98" : (lighting.goldenStrength > 0.25 ? "#bf8f5e" : "#a88c66");
const borderBottom = groundType === "concrete" ? "#5f6670" : groundType === "blanket" ? "#b77d9f" : groundType === "foot_massage" ? "#815d43" : fieldType === "park" ? "#486d3d" : fieldType === "cage" ? "#5f6670" : (lighting.goldenStrength > 0.25 ? "#9c7043" : "#8a6f4b");
border.addColorStop(0, light > 0.42 ? borderTop : "#6f6c72");
border.addColorStop(1, light > 0.42 ? borderBottom : "#565a68");
activeCtx.fillStyle = border;
@ -503,11 +655,17 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
const soil = activeCtx.createLinearGradient(0, y + 14, 0, y + bh - 14);
const palette = fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
const palette = groundType === "concrete"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: groundType === "blanket"
? ["#ffe1ec", "#f6c6dc", "#e3a2c6"]
: groundType === "foot_massage"
? ["#e6c59c", "#cfa677", "#a77a52"]
: fieldType === "park"
? ["#cfe5a8", "#b8d990", "#91bd74"]
: fieldType === "cage"
? ["#d7d2c5", "#c7c2b6", "#aaa79e"]
: [lighting.goldenStrength > 0.25 ? "#f1dfbc" : "#ece6d2", lighting.goldenStrength > 0.25 ? "#e5d4b5" : "#dfdccb", lighting.goldenStrength > 0.25 ? "#d3c5a5" : "#d6d2bf"];
soil.addColorStop(0, light > 0.42 ? palette[0] : "#bbbcc6");
soil.addColorStop(0.38, light > 0.42 ? palette[1] : "#afb1bd");
soil.addColorStop(1, light > 0.42 ? palette[2] : "#a0a3af");
@ -520,19 +678,34 @@ function drawGardenBed(w, h, lighting) {
roundedRect(activeCtx, x + 16, y + 14, bw - 32, bh - 30, 28);
activeCtx.clip();
const speckCount = fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
const speckCount = groundType === "blanket" ? 28 : groundType === "foot_massage" ? 86 : fieldType === "park" ? 72 : fieldType === "cage" ? 34 : 46;
for (let i = 0; i < speckCount; i++) {
const px = randSeed(900 + i, x + 24, x + bw - 24);
const py = randSeed(1200 + i, y + 22, y + bh - 24);
const r = randSeed(1600 + i, 1.2, 3.8);
const a = randSeed(1900 + i, 0.03, 0.09) * (light > 0.4 ? 1.05 + lighting.goldenStrength * 0.35 : 0.70);
activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
if (groundType === "foot_massage") {
activeCtx.fillStyle = `rgba(${Math.round(randSeed(2000 + i, 118, 166))}, ${Math.round(randSeed(2300 + i, 75, 112))}, ${Math.round(randSeed(2600 + i, 48, 78))}, ${a * 1.7})`;
activeCtx.beginPath();
activeCtx.arc(px, py, r * 1.25, 0, Math.PI * 2);
activeCtx.fill();
} else {
activeCtx.fillStyle = groundType === "blanket" ? `rgba(255,255,255,${a * 1.35})` : `rgba(${Math.round(randSeed(2000 + i, 166, 196))}, ${Math.round(randSeed(2300 + i, 156, 182))}, ${Math.round(randSeed(2600 + i, 128, 150))}, ${a})`;
activeCtx.beginPath();
activeCtx.ellipse(px, py, r * 1.5, r, randSeed(3000 + i, -0.8, 0.8), 0, Math.PI * 2);
activeCtx.fill();
}
}
if (fieldType === "cage") {
if (groundType === "blanket") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.16)";
activeCtx.lineWidth = 1.25;
for (let gy = y + 38; gy < y + bh - 26; gy += 32) {
activeCtx.beginPath(); activeCtx.moveTo(x + 28, gy); activeCtx.lineTo(x + bw - 28, gy + Math.sin(gy * 0.05) * 3); activeCtx.stroke();
}
activeCtx.restore();
} else if (fieldType === "cage") {
activeCtx.save();
activeCtx.strokeStyle = light > 0.42 ? "rgba(98, 102, 108, 0.20)" : "rgba(220, 225, 235, 0.16)";
activeCtx.lineWidth = 1.1;
@ -555,7 +728,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const patchCount = fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
const patchCount = groundType === "concrete" ? 3 : groundType === "blanket" ? 0 : fieldType === "park" ? 14 : fieldType === "cage" ? 3 : 8;
for (let i = 0; i < patchCount; i++) {
const px = randSeed(3300 + i, x + 40, x + bw - 40);
const py = randSeed(3600 + i, y + 44, y + bh - 46);
@ -568,7 +741,7 @@ function drawGardenBed(w, h, lighting) {
}
// edge grasses and tiny flowers
const edgeGrassCount = fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
const edgeGrassCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14;
for (let i = 0; i < edgeGrassCount; i++) {
const side = i % 2;
const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30);
@ -588,7 +761,7 @@ function drawGardenBed(w, h, lighting) {
activeCtx.restore();
}
const flowerCount = fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
const flowerCount = groundType === "concrete" || groundType === "blanket" || groundType === "foot_massage" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 0 : 9;
for (let i = 0; i < flowerCount; i++) {
const px = randSeed(7000 + i, x + 44, x + bw - 44);
const py = randSeed(7400 + i, y + 46, y + bh - 44);
@ -607,7 +780,7 @@ function drawGardenBed(w, h, lighting) {
}
}
const leafCount = fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
const leafCount = groundType === "concrete" || groundType === "blanket" ? 0 : fieldType === "park" ? 18 : fieldType === "cage" ? 4 : 10;
for (let i = 0; i < leafCount; i++) {
const px = randSeed(7800 + i, x + 36, x + bw - 36);
const py = randSeed(8200 + i, y + 34, y + bh - 34);
@ -797,15 +970,13 @@ function render() {
drawLightRays(ctx, screenW, screenH, lighting);
const visibleRect = visibleWorldRect(world, 180);
const terrainLayer = ensureTerrainCache(world, lighting);
beginFieldTransform();
drawPlacementPreview(ctx, world);
if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH);
drawTerrainLayer(ctx, world, lighting, visibleRect);
// Dynamic render stacks are sorted on a short interval rather than every paint.
// Visibility is still checked per frame, so camera movement remains responsive.
const renderStack = ensureWorldRenderStack(world);
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
const renderStack = collectVisibleRenderStack(world, visibleRect);
for (const it of renderStack.backItems) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
@ -890,30 +1061,51 @@ function render() {
}
function drawPointerItemTooltip(ctx, world) {
const nest = world.pointerNestBoxInfo?.();
const bedInfo = !nest ? world.pointerOwnedBedInfo?.() : null;
let target = null;
let lines = [];
if (nest?.box) {
target = nest.box;
const names = (nest.occupants || []).map(t => t?.name).filter(Boolean).slice(0, 5);
lines = ["\u5de3\u7bb1"];
if (names.length) lines.push(`${"\u5229\u7528\u4e2d"}: ${names.join("\u3001")}`);
else lines.push("\u5229\u7528\u4e2d: \u306a\u3057");
} else if (bedInfo?.bed) {
target = bedInfo.bed;
lines = ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `${"\u6301\u3061\u4e3b"}: ${bedInfo.owner?.name || "\u4e0d\u660e"}`];
}
if (!target || !lines.length) return;
const info = world.pointerItemTooltipInfo?.();
const target = info?.target || null;
const rawLines = (info?.lines || []).filter(Boolean);
if (!target || !rawLines.length) return;
ctx.save();
ctx.font = "12px Yomogi, sans-serif";
const maxTextW = 238;
const ellipsize = (text, maxW) => {
let s = String(text || "");
if (ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(`${s}`).width > maxW) s = s.slice(0, -1);
return `${s}`;
};
const wrapLine = (text, maxW) => {
const s = String(text || "");
if (ctx.measureText(s).width <= maxW) return [s];
if (s.includes("、")) {
const parts = s.split("、");
const out = [];
let line = "";
for (const part of parts) {
const candidate = line ? `${line}${part}` : part;
if (ctx.measureText(candidate).width <= maxW) line = candidate;
else {
if (line) out.push(line);
line = part;
}
}
if (line) out.push(line);
return out.slice(0, 3).map(line => ellipsize(line, maxW));
}
return [ellipsize(s, maxW)];
};
let lines = [];
for (const line of rawLines) lines.push(...wrapLine(line, maxTextW));
if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("、"), maxTextW)];
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
const lineH = 17;
const w = Math.min(280, Math.max(92, textW + 22));
const h = Math.max(38, 16 + lines.length * lineH);
const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y };
const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, ctx.canvas.height - h - 8);
const canvasW = world.viewportW || world.w || ctx.canvas.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas.height || 1;
const x = clamp(pos.x - w / 2, 8, canvasW - w - 8);
const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8);
ctx.fillStyle = "rgba(46,38,30,0.66)";
ctx.strokeStyle = "rgba(255,248,220,0.74)";
ctx.lineWidth = 1.2;

35
js/restore_coordinator.js Normal file
View file

@ -0,0 +1,35 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for restore_coordinator.js");
function renderWorldAfterRestore(worldRef = global.world) {
if (!worldRef) return;
if (typeof renderLog === "function") renderLog(worldRef.logs || []);
if (typeof resetArchiveRenderState === "function") resetArchiveRenderState();
if (typeof renderArchive === "function") renderArchive();
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
if (typeof syncTopButtons === "function") syncTopButtons();
}
function syncRestoreDependents(worldRef = global.world, context = {}) {
global.TarinaiFreezeSystem?.afterWorldRestored?.(worldRef, context);
worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" });
renderWorldAfterRestore(worldRef);
}
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
const result = Snapshot.restoreSnapshot(snapshot, worldRef);
if (options.syncUi !== false) syncRestoreDependents(worldRef, { source: options.source || "snapshot", snapshotVersion: result?.snapshotVersion || Snapshot.version });
return result;
}
global.TarinaiRestoreCoordinator = {
restoreSnapshot,
syncRestoreDependents,
renderWorldAfterRestore,
};
})(typeof window !== "undefined" ? window : globalThis);

139
js/save_codec.js Normal file
View file

@ -0,0 +1,139 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
const EXPORT_PREFIX = "TN3!";
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function encodeSnapshot(snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("snapshot version mismatch");
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
const data = JSON.parse(json);
if (data?.v !== Snapshot.version) throw new Error("unsupported save version");
return data;
}
global.TarinaiSaveCodec = {
EXPORT_PREFIX,
SAVE_TEXT_ALPHABET,
base91Encode,
base91Decode,
encodeSnapshot,
decodeSnapshot,
};
})(typeof window !== "undefined" ? window : globalThis);

47
js/save_storage.js Normal file
View file

@ -0,0 +1,47 @@
"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_storage.js");
const STORAGE_PREFIX = "tarinai_save_slot_v3_";
const SLOT_COUNT = 9;
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function writeSlot(slot, snapshot) {
if (!snapshot || snapshot.v !== Snapshot.version) throw new Error("unsupported slot version");
global.localStorage?.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
function readSlot(slot) {
const raw = global.localStorage?.getItem(slotKey(slot));
if (!raw) return null;
try {
const data = JSON.parse(raw);
return data?.v === Snapshot.version ? data : null;
} catch (_) {
return null;
}
}
function requireSlot(slot) {
const snapshot = readSlot(slot);
if (!snapshot) throw new Error("empty slot");
return snapshot;
}
function deleteSlot(slot) {
global.localStorage?.removeItem(slotKey(slot));
}
global.TarinaiSaveStorage = {
STORAGE_PREFIX,
SLOT_COUNT,
slotKey,
writeSlot,
readSlot,
requireSlot,
deleteSlot,
};
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,164 +1,42 @@
"use strict";
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v2_";
const EXPORT_PREFIX = "TN2!";
const SLOT_COUNT = 9;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js");
const createSnapshot = (...args) => Snapshot.createSnapshot(...args);
const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args);
const Codec = global.TarinaiSaveCodec;
const Storage = global.TarinaiSaveStorage;
const Restore = global.TarinaiRestoreCoordinator;
if (!Snapshot || !Codec || !Storage || !Restore) throw new Error("save_system.js dependencies are not available");
const SLOT_COUNT = Storage.SLOT_COUNT;
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
function createSnapshot(worldRef = global.world) {
return Snapshot.createSnapshot(worldRef);
}
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi });
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
async function encodeSnapshot(snapshot) {
const compact = snapshot && snapshot.v === 2 ? snapshot : createSnapshot(global.world);
const json = JSON.stringify(compact);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
async function encodeSnapshot(snapshot = createSnapshot(global.world)) {
return Codec.encodeSnapshot(snapshot);
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
const data = JSON.parse(json);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
return Codec.decodeSnapshot(text);
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
async function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
return Storage.writeSlot(slot, snapshot);
}
async function loadSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) throw new Error("empty slot");
const snapshot = JSON.parse(raw);
if (snapshot?.v !== 2) throw new Error("unsupported slot version");
return restoreSnapshot(snapshot, global.world);
const snapshot = Storage.requireSlot(slot);
return restoreSnapshot(snapshot, global.world, { source: "slot" });
}
function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); }
function readSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function deleteSlot(slot) { Storage.deleteSlot(slot); }
function readSlot(slot) { return Storage.readSlot(slot); }
function formatSlotSummary(snapshot) {
if (!snapshot) return "\u7a7a\u304d";
@ -240,6 +118,8 @@
openDialog();
});
const dialog = ensureDialog();
if (dialog.dataset.saveDialogBound === "1") return;
dialog.dataset.saveDialogBound = "1";
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
const saveBtn = e.target.closest("[data-save-slot]");
@ -297,7 +177,7 @@
if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
try {
restoreSnapshot(await decodeSnapshot(text), global.world);
restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" });
closeDialog();
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (error) {

View file

@ -122,11 +122,6 @@ function scaleForGrowth(adultScale = 0.28, growth = 1) {
const PERSONALITIES = Object.freeze({});
const PERSONALITY_IDS = [];
function personalityForSeed(seed) {
return "";
}
const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"];
const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 };
const FRIEND_AFFINITY_THRESHOLD = 14;
@ -371,13 +366,6 @@ function relationDefaults() {
return { affinity: 0, fear: 0, fightsWon: 0, fightsLost: 0, lastEvent: "", lastTime: 0 };
}
function isParentChild(a, b) {
if (!a || !b) return false;
const aKey = a.familyKey || a.id;
const bKey = b.familyKey || b.id;
return Boolean(a.parents?.includes(bKey) || b.parents?.includes(aKey));
}
function relationDisplayName(worldRef, id) {
if (!id) return "\u306a\u3057";
const live = worldRef?.tarinai?.find(t => t.id === id || t.familyKey === id);
@ -539,20 +527,64 @@ class SpatialGrid {
constructor(cellSize = 96) {
this.cellSize = cellSize;
this.itemCells = new Map();
this.staticItemCells = new Map();
this.dynamicItemCells = new Map();
this.tarinaiCells = new Map();
this.antCells = new Map();
this.obstacleCells = new Map();
this.foodCells = new Map();
this.hazardCells = new Map();
this.staticObstacleCells = new Map();
this.dynamicObstacleCells = new Map();
this.staticFoodCells = new Map();
this.dynamicFoodCells = new Map();
this.staticHazardCells = new Map();
this.dynamicHazardCells = new Map();
}
clear() {
this.clearItems();
this.clearTarinai();
this.clearAnts();
}
clearItems() {
this.itemCells.clear();
this.tarinaiCells.clear();
this.antCells.clear();
this.staticItemCells.clear();
this.dynamicItemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.staticObstacleCells.clear();
this.dynamicObstacleCells.clear();
this.staticFoodCells.clear();
this.dynamicFoodCells.clear();
this.staticHazardCells.clear();
this.dynamicHazardCells.clear();
}
clearStaticItems(opts = {}) {
this.staticItemCells.clear();
this.staticObstacleCells.clear();
this.staticFoodCells.clear();
this.staticHazardCells.clear();
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
clearDynamicItems(opts = {}) {
this.dynamicItemCells.clear();
this.dynamicObstacleCells.clear();
this.dynamicFoodCells.clear();
this.dynamicHazardCells.clear();
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
clearTarinai() {
this.tarinaiCells.clear();
}
clearAnts() {
this.antCells.clear();
}
keyFor(x, y) {
@ -572,24 +604,115 @@ class SpatialGrid {
bucket.push(entity);
}
isDynamicItem(it) {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if ((it.dropTimer || 0) > 0) return true;
if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true;
if (it.isStructure && it.type === "plushie" && it.carriedById) return true;
return false;
}
addItemToTraitCells(it, dynamic = this.isDynamicItem(it)) {
const type = it.type || "";
const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells;
const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells;
const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells;
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(obstacleMap, it);
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(foodMap, it);
if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it);
}
classifyItem(it) {
if (!it || it.dead) return;
const dynamic = this.isDynamicItem(it);
const target = dynamic ? this.dynamicItemCells : this.staticItemCells;
this.add(target, it);
this.add(this.itemCells, it);
const type = it.type || "";
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h")) this.add(this.obstacleCells, it);
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(this.foodCells, it);
if (itemHasTrait(type, "hazard")) this.add(this.hazardCells, it);
this.addItemToTraitCells(it, dynamic);
}
rebuildItems(items) {
this.clearItems();
for (const it of items || []) this.classifyItem(it);
this.rebuildCombinedItemCells();
}
rebuildStaticItems(items, opts = {}) {
this.staticItemCells.clear();
this.staticObstacleCells.clear();
this.staticFoodCells.clear();
this.staticHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || this.isDynamicItem(it)) continue;
this.add(this.staticItemCells, it);
this.addItemToTraitCells(it, false);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
rebuildDynamicItems(items, opts = {}) {
this.dynamicItemCells.clear();
this.dynamicObstacleCells.clear();
this.dynamicFoodCells.clear();
this.dynamicHazardCells.clear();
for (const it of items || []) {
if (!it || it.dead || !this.isDynamicItem(it)) continue;
this.add(this.dynamicItemCells, it);
this.addItemToTraitCells(it, true);
}
if (opts.rebuildCombined) this.rebuildCombinedItemCells();
}
copyCells(src, dst) {
for (const [key, bucket] of src.entries()) {
let out = dst.get(key);
if (!out) {
out = [];
dst.set(key, out);
}
for (const entity of bucket) out.push(entity);
}
}
rebuildCombinedItemCells() {
this.itemCells.clear();
this.obstacleCells.clear();
this.foodCells.clear();
this.hazardCells.clear();
this.copyCells(this.staticItemCells, this.itemCells);
this.copyCells(this.dynamicItemCells, this.itemCells);
this.copyCells(this.staticObstacleCells, this.obstacleCells);
this.copyCells(this.dynamicObstacleCells, this.obstacleCells);
this.copyCells(this.staticFoodCells, this.foodCells);
this.copyCells(this.dynamicFoodCells, this.foodCells);
this.copyCells(this.staticHazardCells, this.hazardCells);
this.copyCells(this.dynamicHazardCells, this.hazardCells);
}
rebuildTarinai(tarinai) {
this.tarinaiCells.clear();
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
}
rebuildAnts(ants = []) {
this.antCells.clear();
for (const a of ants || []) if (!a.dead) this.add(this.antCells, a);
}
rebuild(items, tarinai, ants = []) {
this.clear();
for (const it of items || []) this.classifyItem(it);
for (const t of tarinai || []) if (!t.dead) this.add(this.tarinaiCells, t);
for (const a of ants || []) if (!a.dead) this.add(this.antCells, a);
this.rebuildItems(items);
this.rebuildTarinai(tarinai);
this.rebuildAnts(ants);
}
nearby(map, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
return this.nearbyInto(map, x, y, radius, out, filterDistance);
}
nearbyInto(map, x, y, radius, out = [], filterDistance = false) {
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
const minX = Math.floor((x - r) / this.cellSize);
const maxX = Math.floor((x + r) / this.cellSize);
@ -612,6 +735,29 @@ class SpatialGrid {
}
return out;
}
nearbySplitInto(staticMap, dynamicMap, x, y, radius, out = [], filterDistance = false) {
out.length = 0;
if (staticMap) this.nearbyInto(staticMap, x, y, radius, out, filterDistance);
if (dynamicMap) this.nearbyInto(dynamicMap, x, y, radius, out, filterDistance);
return out;
}
nearbyRectInto(map, rect, out = []) {
if (!rect) return out;
const minX = Math.floor((rect.left || 0) / this.cellSize);
const maxX = Math.floor((rect.right || 0) / this.cellSize);
const minY = Math.floor((rect.top || 0) / this.cellSize);
const maxY = Math.floor((rect.bottom || 0) / this.cellSize);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000);
if (!bucket) continue;
for (const entity of bucket) out.push(entity);
}
}
return out;
}
}
function drawHeartShape(ctx, x, y, size, opts = {}) {

View file

@ -1,7 +1,7 @@
"use strict";
(function (global) {
const SNAPSHOT_VERSION = 2;
const SNAPSHOT_VERSION = 3;
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
@ -67,17 +67,6 @@
return Number.isFinite(n) ? n / scale : fallback;
}
function compactNumberArray(obj, keys, scale = 1) {
return keys.map(k => q(obj?.[k], scale));
}
function expandNumberArray(arr, keys, scale = 1) {
const out = {};
if (!Array.isArray(arr)) return out;
keys.forEach((key, i) => { out[key] = u(arr[i], scale); });
return out;
}
function needsToArray(needs = {}) {
return NEED_KEYS.map(k => q(needs[k], 1));
}
@ -135,6 +124,7 @@
function worldData(worldRef) {
return {
fieldType: worldRef?.fieldType || "garden",
groundType: worldRef?.groundType || "soil",
time: Number(worldRef?.time) || 0,
day: Number(worldRef?.day) || 1,
cameraX: Number(worldRef?.cameraX) || 0,
@ -166,6 +156,7 @@
q(worldRef?.lastBirthAt, 10, -9990),
q(worldRef?.maxGeneration, 1, 1),
mood,
worldRef?.groundType || "soil",
];
}
@ -184,26 +175,50 @@
worldRef.maxGeneration = Math.max(1, u(arr[11], 1, 1));
const moodId = arr[12] || "relaxed";
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || worldRef.colonyMoodDefinition?.("relaxed") || { id: moodId, label: moodId, effects: { personality: {} } };
worldRef.setGroundType?.(arr[13] || "soil", { silent: true, force: true });
}
function compactBehaviorTarget(ref = null) {
if (!ref || typeof ref !== "object") return null;
const id = ref.id != null ? String(ref.id) : "";
if ((ref.kind === "tarinai" || ref.familyKey || ref.name) && id) return ["t", id];
if ((ref.kind === "item" || ref.type) && id) return ["i", id];
if (Number.isFinite(ref.x) && Number.isFinite(ref.y)) return ["p", q(ref.x, 1), q(ref.y, 1)];
return null;
}
function expandBehaviorTarget(row = null) {
if (!Array.isArray(row) || !row[0]) return null;
if (row[0] === "t") return { kind: "tarinai", id: String(row[1] || "") };
if (row[0] === "i") return { kind: "item", id: String(row[1] || "") };
if (row[0] === "p") return { kind: "position", x: u(row[1], 1), y: u(row[2], 1) };
return null;
}
function compactBehavior(b = null, tarinaiIndex = new Map(), itemIndex = new Map()) {
if (!b || typeof b !== "object") return null;
const ref = b.targetRef || b.target || null;
let target = null;
if (ref?.kind === "tarinai") target = ["t", tarinaiIndex.get(ref.id) ?? -1];
else if (ref?.kind === "item") target = ["i", itemIndex.get(ref.id) ?? -1];
else if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) target = ["p", q(ref.x, 1), q(ref.y, 1)];
return [b.id || "", b.need || "", b.subNeed || "", b.phase || "", b.forced ? 1 : 0, target].filter((v, i) => i < 5 || v);
const target = compactBehaviorTarget(b.targetRef || b.target || null);
const forced = typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.source === "forced" || b.forcedId || b.forcedRequest);
const source = forced ? "forced" : String(b.source || "need");
return [
b.actionId || "", b.need || "", b.subNeed || "", b.phase || "", source,
b.reason || "", b.text || "", b.label || "", b.forcedId || "", target,
].filter((v, i) => i < 5 || v);
}
function expandBehavior(arr = null) {
if (!Array.isArray(arr) || !arr[0]) return null;
return {
id: arr[0] || "",
actionId: arr[0] || "",
need: arr[1] || "",
subNeed: arr[2] || "",
phase: arr[3] || "",
forced: !!arr[4],
source: arr[4] || "need",
reason: arr[5] || "",
text: arr[6] || arr[5] || "",
label: arr[7] || "",
forcedId: arr[8] || "",
target: expandBehaviorTarget(arr[9]),
};
}
@ -306,7 +321,8 @@
return;
}
if (type === "grass") {
item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); item.grassLimitExempt = !!extra[7]; item.manualGrass = !!extra[7];
item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); item.grassLimitExempt = false; item.manualGrass = false;
global.TarinaiGrass?.normalize?.(item);
} else if (type === "bed") {
item.comfort = u(extra[0], 1000); item.wear = u(extra[1], 1000);
} else if (type === "signboard") {
@ -335,18 +351,6 @@
return [ant.kind || "worker", itemIndex.get(ant.homeId) ?? -1, tarinaiIndex.get(ant.targetId) ?? -1, q(ant.x, 1), q(ant.y, 1), q(ant.vx, 10), q(ant.vy, 10), q(ant.hp, 10), q(ant.maxHp, 10), ant.state || "search", q(ant.age, 10), q(ant.seed, 10), q(ant.returnTimer, 10), q(ant.foundingX, 1), q(ant.foundingY, 1), q(ant.foundingDelayUntil, 10), q(ant.foundingAttempts, 1), ant.dead ? 1 : 0];
}
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
// Compatibility helper for non-save subsystems such as freeze. The exported save format no longer uses this broad snapshot.
const data = copyOwnData(entity, skip);
const targetRef = refFor(worldRef, entity?.target);
const panicTargetRef = refFor(worldRef, entity?.panicTarget);
const targetRefObject = refFor(worldRef, entity?.targetRef);
if (targetRef) data.__targetRef = targetRef;
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
if (targetRefObject) data.__targetRefObject = targetRefObject;
return data;
}
function createSnapshot(worldRef = global.world) {
if (!worldRef) throw new Error("world is not ready");
const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead);
@ -364,7 +368,7 @@
v: SNAPSHOT_VERSION,
a: "tcg",
c: now,
m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length],
m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length, worldRef.groundType || "soil"],
w: compactWorld(worldRef),
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
i: itemsLive.map((it, i) => compactItem(it, i, tarinaiIndex)),
@ -373,22 +377,6 @@
};
}
function restoreTarinaiData(worldRef, data, opts = {}) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const t = new TarinaiClass(worldRef, data || {});
applyOwnData(t, data || {}, new Set(["world", "target", "panicTarget", "targetRef"]));
t.world = worldRef;
if (opts.clearTransient !== false) {
t.target = null;
t.panicTarget = null;
t.sleeping = false;
t.insideNestBoxId = "";
}
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
function expandTarinai(row, index, worldRef) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
@ -432,7 +420,7 @@
}
function restoreSnapshot(snapshot, worldRef = global.world) {
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid v2 save data");
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data");
if (!worldRef) throw new Error("world is not ready");
const fieldType = snapshot.w?.[0] || "garden";
if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType);
@ -474,6 +462,7 @@
item = new Item(type, u(row[1], 1), u(row[2], 1));
}
item.id = `si${idx}`;
item.world = worldRef;
item.r = u(row[3], 10, item.r || 12);
item.amount = u(row[4], 10, item.amount || 0);
item.age = u(row[5], 10, 0);
@ -516,19 +505,17 @@
worldRef.resolvedFightIds = {};
worldRef.eventCounters = {};
if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts();
if (typeof worldRef.enforceGrassLimit === "function") {
worldRef.enforceGrassLimit("load-grass-limit");
worldRef.compactItems?.();
worldRef.updateItemCounts?.();
}
if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts();
if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true);
if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load-v2");
if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load");
if (typeof worldRef.clampCamera === "function") worldRef.clampCamera();
if (typeof renderLog === "function") renderLog(worldRef.logs || []);
if (typeof resetArchiveRenderState === "function") resetArchiveRenderState();
if (typeof renderArchive === "function") renderArchive();
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
if (typeof syncTopButtons === "function") syncTopButtons();
global.TarinaiFreezeSystem?.afterSnapshotRestored?.(worldRef);
return true;
worldRef.emit?.("snapshot:restored", { snapshotVersion: SNAPSHOT_VERSION });
return { world: worldRef, snapshotVersion: SNAPSHOT_VERSION };
}
global.TarinaiSnapshot = {
@ -539,9 +526,7 @@
refFor,
resolveRef,
worldData,
snapshotEntity,
createSnapshot,
restoreSnapshot,
restoreTarinaiData,
};
})(typeof window !== "undefined" ? window : globalThis);

90
js/structure_lifecycle.js Normal file
View file

@ -0,0 +1,90 @@
"use strict";
(function (global) {
function markWorldAfterStructureChange(worldRef, reason = "structure-change") {
if (!worldRef) return;
worldRef.drawListDirty = true;
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.markTerrainDirty?.(reason);
}
function markStructureGone(structure, reason = "structure-removed") {
if (!structure || structure.dead) return false;
structure.dead = true;
structure.hp = 0;
structure.amount = 0;
structure.removedReason = String(reason || "structure-removed");
return true;
}
function releaseStructureUsers(worldRef, structure, opts = {}) {
if (!worldRef || !structure) return 0;
const reason = opts.reason || "なくなった";
const wake = opts.wake !== false;
let released = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
const targetMatch = t.target === structure;
const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id;
const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id;
if (!targetMatch && !sleepTargetMatch && !nestMatch) continue;
if (nestMatch) {
t.insideNestBoxId = null;
t.nestBoxSleepTimer = 0;
}
if (sleepTargetMatch && t.sleepSession) {
t.sleepSession.targetId = null;
t.sleepSession.consumesGrassBedOnWake = false;
}
if (targetMatch) t.target = null;
if (t.state === "sleep" || t.sleeping || t.state === "seek_bed" || nestMatch) {
t.setActionState?.("idle", { clearTarget: true, reason, wake, sleeping: false });
}
released += 1;
}
return released;
}
function deleteStructure(worldRef, structure, opts = {}) {
if (!structure || structure.dead) return false;
const reason = opts.reason || "structure-deleted";
releaseStructureUsers(worldRef, structure, { reason: opts.userReason || "置きものがなくなった", wake: opts.wake !== false });
const changed = markStructureGone(structure, reason);
if (!changed) return false;
markWorldAfterStructureChange(worldRef, reason);
worldRef?.emit?.("structure:deleted", { structure, type: structure.type, reason });
return true;
}
function consumeGrassBedOnWake(worldRef, bed, user = null, reason = "wake") {
if (!bed || bed.dead || bed.type !== "grass_bed") return false;
bed.singleUsePending = false;
bed.singleUseWakeReason = String(reason || "wake");
bed.singleUseConsumedById = user?.id || bed.singleUseConsumedById || null;
if (user?.target === bed) user.target = null;
const changed = markStructureGone(bed, "grass-bed-wake-used");
if (!changed) return false;
markWorldAfterStructureChange(worldRef, "grass-bed-wake-used");
worldRef?.emit?.("structure:usedUp", { structure: bed, user, reason: "wake" });
return true;
}
function deleteItem(worldRef, item, opts = {}) {
if (!item || item.dead) return false;
if (item.isStructure) return deleteStructure(worldRef, item, { reason: opts.reason || "delete-tool", userReason: opts.userReason, wake: opts.wake });
item.amount = 0;
if (item.type !== "grass") item.dead = Boolean(opts.killNonStructure);
markWorldAfterStructureChange(worldRef, opts.reason || "delete-tool");
return true;
}
global.TarinaiStructureLifecycle = Object.freeze({
markWorldAfterStructureChange,
releaseStructureUsers,
deleteStructure,
consumeGrassBedOnWake,
deleteItem,
});
})(window);

View file

@ -72,9 +72,9 @@
this.amount = Math.max(0.001, this.hp);
}
use(user, worldRef = this.world) {
use(user, worldRef = this.world, options = {}) {
const definition = global.StructureRegistry?.get?.(this.type);
return definition?.onUse?.(user, this, worldRef) || false;
return definition?.onUse?.(user, this, worldRef, options) || false;
}
damage(amount = 1, worldRef = this.world, breaker = null) {
@ -191,11 +191,11 @@
buildNeed: "fulfill",
buildTime: 4,
maxHp: 100,
materials: { grassMaterial: 1 },
materials: { grassMaterial: 4 },
roles: { sleepPlace: true, ownedStructure: true },
needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 },
create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); },
onUse(user, structure, worldRef) {
onUse(user, structure, worldRef, options = {}) {
if (user?.id && !structure.ownerId) {
structure.ownerId = user.id;
structure.ownerName = user.name || "";
@ -207,6 +207,18 @@
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 (options?.purpose === "sleep" || options?.sleep === true) {
// かんたんベッドは「寝た瞬間」ではなく「起きた時」に消える。
// 睡眠中は寝場所として残すことで、見た目とホバー情報が自然になる。
structure.singleUsePending = true;
structure.singleUseConsumedById = user?.id || "";
structure.singleUseConsumedAt = worldRef?.time || 0;
structure.singleUseWakeReason = "sleep";
structure.amount = Math.max(0.001, structure.hp || structure.amount || 1);
worldRef?.markItemBucketsDirty?.("grass-bed-sleep-reserved");
worldRef?.markTerrainDirty?.("grass-bed-sleep-reserved");
worldRef?.emit?.("structure:useReserved", { structure, user });
}
return true;
},
onDestroyed(structure, world, breaker) {

View file

@ -16,18 +16,17 @@ function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
class Tarinai { constructor(world, opts = {}) {
this.world = world;
this.familyKey = opts.familyKey || opts.archiveKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.familyKey = opts.familyKey || opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.id = opts.id || "";
this.liveToken = opts.liveToken || 0;
this.name = opts.name || makeName(world);
// Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model.
this.personality = "";
this.type = opts.type || baseSpriteId();
const typeMeta = SPRITES.find(s => s.id === this.type);
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId();
this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth);
if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.familyKey);
this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality);
this.birthPersonality = normalizePersonality(opts.birthPersonality);
if (!opts.birthPersonality) this.birthPersonality = randomBirthPersonality(this.familyKey);
this.currentPersonality = normalizePersonality(opts.currentPersonality || this.birthPersonality);
this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null;
ensurePersonality(this);
this.genetics = normalizeGenetics(opts.genetics, this.familyKey);
@ -77,7 +76,7 @@ class Tarinai { constructor(world, opts = {}) {
this.behaviorSerial = opts.behaviorSerial ?? 0;
this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function"
? globalThis.normalizeTarinaiBehaviorState(opts, this)
: (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : (opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null));
: (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : null);
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {};
this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {};
@ -87,6 +86,7 @@ class Tarinai { constructor(world, opts = {}) {
this.stressBarTimer = opts.stressBarTimer ?? 0;
this.affection = opts.affection ?? rand(0, 30);
this.dead = !!opts.dead;
this.lifeStatus = opts.lifeStatus || (this.dead ? "dead" : "alive");
this.deathReason = opts.deathReason || "";
this.lastDamageCause = opts.lastDamageCause || "";
this.lastDamageAmount = opts.lastDamageAmount ?? 0;

View file

@ -0,0 +1,558 @@
"use strict";
// ActionSpec factories and TARINAI_ACTIONS registry.
function selectRoleTarget(role, range) {
return function selectTarget(tarinai, world) {
return findNearestItemWithRole(world, tarinai, role, range);
};
}
function startMoveToTarget(state, fallbackLabel = "行動している") {
return function start(tarinai, world, ctx = {}) {
const target = ctx.target ?? this.selectTarget?.(tarinai, world, ctx) ?? null;
return moveToOrUse(tarinai, target, state, this.label || fallbackLabel);
};
}
function selectOwnedStructure(type, range = Infinity) {
return function selectTarget(tarinai, world) {
return findOwnedStructure(world, tarinai, type, range);
};
}
function selectMateTarget(tarinai, world, range = 440) {
return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null;
}
function basicCanStartWithTarget(t, world, ctx = {}) {
return !!(ctx.target ?? this.selectTarget?.(t, world, ctx));
}
function createConsumableActionSpec({ id, subNeed = "", state, label, phrase, weight, role, range }) {
return {
id,
need: role === "medicine" ? "health" : "food",
subNeed,
state,
label,
phrase,
weight,
timerBacked: true,
selectTarget: selectRoleTarget(role, range),
canStart: basicCanStartWithTarget,
start: startMoveToTarget(state, label),
tick(t, world, dt) { return updateConsumableBehavior(t, world, dt, role); },
finish(t) {
if (role === "drink" && typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: 8, health: 2 }, "drink");
return true;
},
fail(t) {
if (t) t.target = null;
return false;
},
};
}
function createSleepInBedActionSpec() {
return {
id: "sleep_in_bed",
need: "sleep",
state: "seek_bed",
label: "寝る場所を探している",
phrase: "寝る場所へ戻った",
weight: 42,
timerBacked: true,
selectTarget(t, world) { return findNearestSleepPlace(world, t, 760); },
canStart: basicCanStartWithTarget,
start(t, world, ctx = {}) {
const bed = ctx.target ?? this.selectTarget(t, world, ctx);
if (!bed) return false;
if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label);
const isEasyBed = bed.type === "grass_bed";
bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined);
t.startSleeping?.(bed, bed.type === "nest_box" ? "巣箱の中で寝ている" : (isEasyBed ? "かんたんベッドで寝ている" : "ベッドで寝ている"));
return true;
},
tick(t) {
if (t?.state === "sleep" || t?.sleeping) return true;
return basicCanStartWithTarget.call(this, t, t?.world || null, {}) ? true : false;
},
};
}
function createSleepBuildGrassBedActionSpec() {
return {
id: "sleep_build_grass_bed",
need: "sleep",
subNeed: "bed",
state: "build",
label: "寝る場所がないのでかんたんベッドを作っている",
phrase: "かんたんベッドを作った",
weight: 18,
timerBacked: true,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) {
return canBuildOwnedStructureByAge(t)
&& !findNearestSleepPlace(world, t, 760)
&& !findOwnedStructure(world, t, "grass_bed")
&& !!(ctx.target ?? this.selectTarget(t, world, ctx))
&& !["fight", "panic", "intimidate", "birth_ritual"].includes(t.state);
},
start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); },
tick(t, world, dt) {
if (findOwnedStructure(world, t, "grass_bed")) return "finished";
return continueBuildPlan(t, world, dt) ? true : false;
},
};
}
function createSleepAnywhereActionSpec() {
return {
id: "sleep_anywhere",
need: "sleep",
state: "sleep",
label: "なにもない所で寝ている",
phrase: "そのまま寝た",
weight: 8,
timerBacked: true,
canStart(t, world) {
if (findNearestSleepPlace(world, t, 760)) {
if (t) t.noBedNoBuildSince = NaN;
return false;
}
const now = world?.time || 0;
const failedRecently = (t?.lastFailedGrassBedBuildAt || -999) + 14 > now;
const buildTarget = this.selectBuildTarget ? this.selectBuildTarget(t, world, {}) : findNearbyMaterial(world, t, "grassMaterial", 520);
const canTryBuild = canBuildOwnedStructureByAge(t) && !!buildTarget && !(Number(t?.actionIdCooldowns?.sleep_build_grass_bed || 0) > 0.01);
if (canTryBuild && !failedRecently) {
if (t) t.noBedNoBuildSince = NaN;
return false;
}
if (t && !Number.isFinite(t.noBedNoBuildSince)) t.noBedNoBuildSince = now;
const since = Number.isFinite(t?.noBedNoBuildSince) ? t.noBedNoBuildSince : now;
const waited = now - since;
const urgent = (t?.energy || 100) < 18 || (t?.needRaw?.sleep || t?.needs?.sleep || 0) >= 92;
return failedRecently || urgent || waited >= 6.0;
},
selectBuildTarget: selectRoleTarget("grassMaterial", 520),
start(t) { t.startSleeping?.(null, this.label); return true; },
tick(t) { return (t?.state === "sleep" || t?.sleeping) ? true : false; },
};
}
function createRestActionSpec() {
return {
id: "rest_to_recover",
need: "health",
state: "idle",
label: "元気になるまで休んでいる",
phrase: "休んだ",
weight: 24,
canStart() { return true; },
start(t) {
t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false });
t.energy = clamp((t.energy || 0) + 1.6, 0, 100);
return true;
},
tick(t) {
t.energy = clamp((t.energy || 0) + 0.8, 0, 100);
return true;
},
};
}
function createSunbathActionSpec() {
return {
id: "sunbath",
need: "health",
subNeed: "sunbath",
state: "sunbath",
label: "日光浴している",
phrase: "日光浴している",
weight: 32,
timerBacked: true,
canStart(t, world, ctx = {}) {
const healthNeed = Number(t?.needRaw?.health ?? t?.needs?.health ?? 0) || 0;
return (ctx.leisure || healthNeed >= needThreshold("health", "start"))
&& !!t?.canStartSunbath?.({ leisure: !!ctx.leisure });
},
start(t, world, ctx = {}) {
return !!t?.startSunbath?.({ leisure: !!ctx.leisure });
},
tick(t) {
return (t?.sunbathTimer || 0) > 0.04 ? true : "finished";
},
finish(t) {
if ((t?.sunbathTimer || 0) <= 0 && t?.state === "sunbath") t.finishSunbath?.();
return true;
},
fail(t) {
if (t && t.state === "sunbath") {
t.sunbathTimer = 0;
t.goIdle?.("日光浴をやめた");
}
return false;
},
};
}
function createWanderActionSpec() {
function targetFor(t, world) {
const radius = rand(80, 180);
return {
x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)),
y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)),
dead: false,
detour: true,
};
}
return {
id: "wander_lightly",
need: "fulfill",
state: "wander",
label: "少し歩いている",
phrase: "少し歩いた",
weight: 14,
canStart() { return true; },
selectTarget: targetFor,
start(t, world, ctx = {}) {
t.wanderAngle += rand(-1.2, 1.2);
const target = ctx.target && Number.isFinite(ctx.target.x) ? ctx.target : targetFor(t, world);
t.wanderTarget = target;
t.setActionState?.("wander", { target, reason: this.label, sleeping: false });
return true;
},
tick(t, world) {
const target = t?.wanderTarget || currentBehaviorTarget(t) || null;
if (!target || !Number.isFinite(target.x) || dist(t, target) <= Math.max(28, (t.radius || 20) + 10)) return "finished";
moveToOrUse(t, target, "wander", this.label);
return true;
},
};
}
function selectFightRivalTarget(tarinai, world, range = 360) {
const candidate = conflictTargetFor(tarinai, world, (tarinai?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(tarinai) ? 18 : 34);
const current = currentBehaviorTarget(tarinai);
return isLiveTarinaiEntity(candidate?.target)
? candidate.target
: (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(tarinai, world, isCurrentBehaviorForced(tarinai) ? 760 : range));
}
function createPanicEscapeActionSpec() {
return {
id: "panic_escape",
need: "safety",
subNeed: "panic",
state: "panic",
label: "怖くて逃げている",
phrase: "逃げた",
weight: 62,
timerBacked: true,
selectTarget(t, world) {
return t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.liveTarinaiById?.(t.defeatedById) : null);
},
canStart(t, world, ctx = {}) {
const threat = ctx.target ?? this.selectTarget(t, world, ctx);
const safety = Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) || 0;
// Do not start panic from ordinary safety pressure alone. Panic needs a
// concrete trigger, lingering fear, defeat, or an extreme safety spike.
return !!threat
|| (t?.defeatedTimer || 0) > 0.04
|| (safety >= 92 && !!activePanicBreaker(t, world, 260));
},
start(t, world, ctx = {}) {
const threat = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 1.6);
return true;
},
tick: updatePanicBehavior,
};
}
function createFleeActionSpec() {
return {
id: "flee",
need: "safety",
subNeed: "avoid",
state: "flee",
label: "危ないものから離れている",
phrase: "離れた",
weight: 40,
selectTarget(t, world) { return findNearbyDanger(world, t, 220); },
canStart: basicCanStartWithTarget,
start(t, world, ctx = {}) {
const danger = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false });
return true;
},
tick: updatePanicBehavior,
};
}
function createApproachFriendActionSpec() {
return {
id: "approach_friend",
need: "social",
subNeed: "bond",
state: "seek_friend",
label: "仲間に近づいている",
phrase: "仲間に近づいた",
weight: 46,
selectTarget(t, world) { return t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420) || null; },
canStart: basicCanStartWithTarget,
start: startMoveToTarget("seek_friend", "仲間に近づいている"),
tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); },
};
}
function createApproachFamilyActionSpec() {
return {
id: "approach_parent_or_child",
need: "social",
subNeed: "family",
state: "follow_parent",
label: "家族に近づいている",
phrase: "家族に近づいた",
weight: 28,
selectTarget(t) { return t.parentToFollow?.() || null; },
canStart: basicCanStartWithTarget,
start: startMoveToTarget("follow_parent", "家族に近づいている"),
tick(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); },
};
}
function createApproachMateActionSpec() {
return {
id: "approach_mate",
need: "social",
subNeed: "mate",
state: "seek_friend",
label: "繁殖できる相手に近づいている",
phrase: "繁殖できる相手に近づいている",
weight: 48,
selectTarget(t, world) { return selectMateTarget(t, world, 520); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const other = ctx.target ?? this.selectTarget(t, world, ctx);
if (!other) return false;
if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true;
return moveToOrUse(t, other, "seek_friend", this.label);
},
tick: updateMateBehavior,
};
}
function createFightRivalActionSpec() {
return {
id: "fight_rival",
need: "social",
subNeed: "conflict",
state: "seek_enemy",
label: "気に入らない相手に向かっている",
phrase: "喧嘩を始めた",
weight: 18,
timerBacked: true,
selectTarget(t, world) { return selectFightRivalTarget(t, world); },
canStart(t, world, ctx = {}) {
if ((t.fightCooldown || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
const forced = (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t);
const candidate = conflictTargetFor(t, world, forced ? 24 : 42);
if (!forced) {
const parts = t?.socialReasonParts || {};
const conflict = Number(parts.conflict || 0) || 0;
const socialPull = Math.max(Number(parts.bond || 0) || 0, Number(parts.mate || 0) || 0, Number(parts.family || 0) || 0);
if (!candidate?.target || conflict < 30 || conflict < socialPull * 0.82) return false;
}
return !!(ctx.target ?? candidate?.target ?? this.selectTarget(t, world, ctx));
},
start(t, world, ctx = {}) {
const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 18 : 34);
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
if (!isLiveTarinaiEntity(rival)) return false;
t.conflictTargetId = rival.id;
t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48);
if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label);
if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival);
world.startConflict?.(t, rival);
return true;
},
tick: updateFightBehavior,
};
}
function createIntimidateEnemyActionSpec() {
return {
id: "intimidate_enemy",
need: "social",
subNeed: "conflict",
state: "intimidate",
label: "気になる相手を威嚇している",
phrase: "威嚇した",
weight: 8,
timerBacked: true,
selectTarget(t) { return t.recentFightRival?.(80, 180) || null; },
canStart(t, world, ctx = {}) {
if ((t.intimidateTimer || 0) > 0.04 || (t.postConflictPeaceTimer || 0) > 0.04) return false;
const parts = t?.socialReasonParts || {};
if ((t.fightMochiTimer || 0) <= 0.04 && Number(parts.conflict || 0) < 24) return false;
return basicCanStartWithTarget.call(this, t, world, ctx);
},
start(t, world, ctx = {}) {
const rival = ctx.target ?? this.selectTarget(t, world, ctx);
t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false });
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85);
return true;
},
tick(t, world) {
if ((t.intimidateTimer || 0) > 0.04) return true;
const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180);
if (!rival) return false;
return world?.startIntimidation?.(t, rival) || true;
},
};
}
function createBirthRitualActionSpec() {
return {
id: "birth_ritual",
need: "social",
subNeed: "mate",
label: "繁殖の前ぶれをしている",
phrase: "繁殖の前ぶれをしている",
weight: 0,
state: "birth_ritual",
timerBacked: true,
canStart(t) { return (t.birthRitualTimer || 0) > 0.04; },
start() { return true; },
tick(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; },
};
}
const TARINAI_ACTION_DEFINITIONS = [
createConsumableActionSpec({
id: "eat_food",
subNeed: "meal",
state: "seek_food",
label: "食べ物を探している",
phrase: "食べ物を食べている",
weight: 62,
role: "food",
range: 760,
}),
createConsumableActionSpec({
id: "drink_water",
subNeed: "water",
state: "seek_water",
label: "水を探している",
phrase: "水を飲んだ",
weight: 24,
role: "drink",
range: 560,
}),
createSleepInBedActionSpec(),
createSleepBuildGrassBedActionSpec(),
createSleepAnywhereActionSpec(),
createConsumableActionSpec({
id: "use_medicine",
subNeed: "medicine",
state: "seek_food",
label: "元気になるものを探している",
phrase: "元気になった",
weight: 34,
role: "medicine",
range: 620,
}),
createRestActionSpec(),
createSunbathActionSpec(),
createPanicEscapeActionSpec(),
createFleeActionSpec(),
{
id: "hide_at_owned_structure",
need: "safety",
state: "seek_bed",
label: "自分の場所に隠れている",
phrase: "自分の場所へ戻った",
weight: 30,
selectTarget: selectOwnedStructure("grass_bed", 620),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("seek_bed", "自分の場所に隠れている"),
},
createApproachFriendActionSpec(),
createApproachFamilyActionSpec(),
createApproachMateActionSpec(),
createFightRivalActionSpec(),
createIntimidateEnemyActionSpec(),
{
id: "play",
need: "fulfill",
state: "play_ball",
label: "遊んでいる",
phrase: "遊んだ",
weight: 24,
selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; },
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("play_ball", "遊んでいる"),
},
{
id: "build_grass_bed",
need: "fulfill",
state: "build",
label: "かんたんベッドを作っている",
phrase: "かんたんベッドを作った",
weight: 34,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "grass_bed") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); },
},
{
id: "build_plushie",
need: "fulfill",
state: "build",
label: "ぬいぐるみを作っている",
phrase: "ぬいぐるみを作った",
weight: 28,
selectTarget: selectRoleTarget("grassMaterial", 520),
canStart(t, world, ctx = {}) { return canBuildOwnedStructureByAge(t) && !findOwnedStructure(world, t, "plushie") && !!(ctx.target ?? this.selectTarget(t, world, ctx)) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); },
start(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); },
},
{
id: "return_owned_structure",
need: "fulfill",
state: "seek_bed",
label: "自分の場所へ戻っている",
phrase: "自分の場所へ戻った",
weight: 22,
selectTarget: selectOwnedStructure("grass_bed", 620),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("seek_bed", "自分の場所へ戻っている"),
},
{
id: "use_plushie",
need: "fulfill",
state: "idle",
label: "ぬいぐるみを抱えている",
phrase: "ぬいぐるみを抱えた",
weight: 18,
selectTarget: selectOwnedStructure("plushie", Infinity),
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const plushie = ctx.target ?? this.selectTarget(t, world, ctx);
plushie?.use?.(t, world);
t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false });
return true;
},
},
createBirthRitualActionSpec(),
createWanderActionSpec(),
];
const TARINAI_ACTIONS = typeof registerTarinaiActionSpecs === "function"
? registerTarinaiActionSpecs(TARINAI_ACTION_DEFINITIONS)
: TARINAI_ACTION_DEFINITIONS;
if (typeof window !== "undefined") Object.assign(window, { TARINAI_ACTION_DEFINITIONS, TARINAI_ACTIONS });

View file

@ -41,8 +41,6 @@
if (!id) throw new TypeError("ActionSpec.id is required");
// Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text.
// Legacy hooks (`condition`, `run`, `update`) remain accepted so old code can be
// migrated incrementally, but newly migrated actions should use the lifecycle names.
const spec = {
...input,
id,
@ -59,12 +57,9 @@
};
const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null;
const originalCondition = hasOwnFn(input, "condition") ? input.condition : null;
const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null;
const originalStart = hasOwnFn(input, "start") ? input.start : null;
const originalRun = hasOwnFn(input, "run") ? input.run : null;
const originalTick = hasOwnFn(input, "tick") ? input.tick : null;
const originalUpdate = hasOwnFn(input, "update") ? input.update : null;
const originalFinish = hasOwnFn(input, "finish") ? input.finish : null;
const originalFail = hasOwnFn(input, "fail") ? input.fail : null;
const originalText = hasOwnFn(input, "text") ? input.text : null;
@ -76,19 +71,16 @@
spec.canStart = function canStart(tarinai, world, ctx = {}) {
if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx));
if (originalCondition) return Boolean(originalCondition.call(spec, tarinai, world, ctx));
return true;
};
spec.start = function start(tarinai, world, ctx = {}) {
if (originalStart) return originalStart.call(spec, tarinai, world, ctx);
if (originalRun) return originalRun.call(spec, tarinai, world, ctx);
return true;
};
spec.tick = function tick(tarinai, world, dt = 0, needs = null, ctx = {}) {
if (originalTick) return originalTick.call(spec, tarinai, world, dt, needs, ctx);
if (originalUpdate) return originalUpdate.call(spec, tarinai, world, dt, needs, ctx);
return undefined;
};

View file

@ -7,11 +7,14 @@
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
setActionState(state, opts = {}) {
if (this.dead) return false;
const wasSleeping = this.state === "sleep" || this.sleeping;
const nextState = state || "idle";
this.state = nextState;
if ("target" in opts) this.target = opts.target;
if (opts.reason != null) this.thought = String(opts.reason || "");
if (opts.clearTarget) this.target = null;
const wakingNow = Boolean(opts.wake) || (wasSleeping && nextState !== "sleep" && opts.sleeping === false);
if (wakingNow) this.consumePendingGrassBedOnWake?.(opts.reason || "wake");
if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping);
if (opts.wake) {
this.sleeping = false;
@ -20,6 +23,63 @@
this.insideNestBoxId = "";
}
if (nextState !== "sleep" && opts.sleeping === false) this.sleepSession = null;
const reason = String(opts.reason ?? this.thought ?? "");
if (nextState === "idle") {
if (typeof clearBehavior === "function") clearBehavior(this, reason || "待っている");
else if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { clearTarget: !!opts.clearTarget });
return true;
}
const stateActionIds = {
seek_food: "eat_food",
eat: "eat_food",
seek_water: "drink_water",
seek_bed: "sleep_in_bed",
sleep: "sleep_anywhere",
panic: "panic_escape",
flee: "flee",
fight: "fight_rival",
intimidate: "intimidate_enemy",
birth_ritual: "birth_ritual",
sunbath: "sunbath",
wander: "wander_lightly",
play_ball: "play",
seek_material: "seek_material",
seek_enemy: "fight_rival",
build: "build_structure",
};
const actionId = String(opts.actionId || opts.behaviorId || stateActionIds[nextState] || nextState);
const target = opts.clearTarget ? null : ("target" in opts ? opts.target : this.target);
const phase = opts.phase || (nextState === "sleep" || nextState === "eat" || nextState === "fight" || nextState === "intimidate" || nextState === "birth_ritual" || nextState === "sunbath" ? "acting" : "approaching");
if (typeof setBehaviorText === "function") {
setBehaviorText(this, {
need: opts.need || null,
subNeed: opts.subNeed || "",
actionId,
actionLabel: opts.actionLabel || reason || nextState,
reasonText: reason || opts.actionLabel || nextState,
target,
phase,
source: opts.source || "state",
forced: opts.forced || null,
forcedId: opts.forcedId || "",
sourceReasonText: opts.sourceReasonText || opts.forcedReasonText || "",
causeText: opts.causeText || "",
});
} else if (typeof patchTarinaiBehavior === "function") {
patchTarinaiBehavior(this, {
actionId,
phase,
target,
source: opts.source || "state",
reason,
label: opts.actionLabel || reason || nextState,
text: reason || opts.actionLabel || nextState,
need: opts.need || "fulfill",
subNeed: opts.subNeed || "",
});
}
return true;
},
@ -30,7 +90,7 @@
reason: text,
sleeping: false,
});
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
return ok;
},
@ -48,8 +108,7 @@
},
goIdle(reason = "") {
if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b");
return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", sleeping: false });
return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", wake: this.state === "sleep" || this.sleeping, sleeping: false });
},
startSleeping(target = null, reason = "") {
@ -62,13 +121,38 @@
targetEnergy: inFurniture ? rand(84, 94) : rand(74, 86),
maxDuration: inFurniture ? rand(42, 68) : rand(28, 48),
targetId: target?.id || null,
consumesGrassBedOnWake: target?.type === "grass_bed",
};
}
if (target?.type === "grass_bed") {
this.sleepSession.targetId = target.id || this.sleepSession.targetId || null;
this.sleepSession.consumesGrassBedOnWake = true;
this.pendingGrassBedWakeId = target.id || this.pendingGrassBedWakeId || "";
target.singleUsePending = true;
target.singleUseConsumedById = this.id || target.singleUseConsumedById || "";
target.singleUseWakeReason = "sleep";
}
const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b");
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
if (typeof setBehaviorText === "function") setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
return this.setActionState("sleep", { target, reason: text, sleeping: true });
},
consumePendingGrassBedOnWake(reason = "wake") {
const worldRef = this.world || null;
const sessionTargetId = this.sleepSession?.targetId || this.pendingGrassBedWakeId || null;
const bySession = sessionTargetId && worldRef?.itemById ? worldRef.itemById(sessionTargetId) : null;
const byItems = sessionTargetId ? (worldRef?.items || []).find(it => it && it.id === sessionTargetId) : null;
const candidates = [this.target, bySession, byItems];
let bed = candidates.find(it => it && !it.dead && it.type === "grass_bed" && (it.singleUsePending || it.singleUseConsumedById === this.id || (this.sleepSession?.consumesGrassBedOnWake && it.id === sessionTargetId)));
if (!bed && (this.sleepSession?.consumesGrassBedOnWake || sessionTargetId)) {
bed = (worldRef?.items || []).find(it => it && !it.dead && it.type === "grass_bed"
&& (it.id === sessionTargetId || (it.singleUsePending && (it.singleUseConsumedById === this.id || it.ownerId === this.id))));
}
if (!bed) return false;
this.pendingGrassBedWakeId = "";
return window.TarinaiStructureLifecycle?.consumeGrassBedOnWake?.(worldRef, bed, this, reason) || false;
},
seekTarget(state, target = null, reason = "") {
return this.setActionState(state, { target, reason, sleeping: false });
},
@ -79,7 +163,7 @@
},
currentBehaviorText() {
if (typeof activeBehaviorText === "function") return activeBehaviorText(this);
if (typeof behaviorText === "function") return behaviorText(this);
return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim();
},
@ -111,9 +195,9 @@
wake: opts.wake !== false,
sleeping: false,
});
if (typeof setLiveActionText === "function") {
if (typeof setBehaviorText === "function") {
const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044");
setLiveActionText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" });
setBehaviorText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" });
}
this.fearTimer = Math.max(this.fearTimer || 0, 2.4);
}
@ -127,6 +211,8 @@
if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)");
this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic";
this.lastPanicAt = this.world?.time || 0;
if (this.state === "panic" && !Number.isFinite(this.panicStartedAt)) this.panicStartedAt = this.lastPanicAt;
if (this.state === "panic") this.panicHardStopAt = Math.max(this.panicHardStopAt || 0, this.lastPanicAt + 7.5);
return true;
},
}));

View file

@ -4,8 +4,10 @@
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
const PHASES = new Set(["starting", "seeking", "approaching", "acting", "recovering", "finished", "failed", "idle", "start", "active", "perform"]);
function isObject(value) {
return Boolean(value && typeof value === "object");
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function finiteOr(value, fallback) {
@ -13,30 +15,41 @@
return Number.isFinite(n) ? n : fallback;
}
function localBehaviorTargetId(target = null) {
if (!target) return null;
if (target.id != null) return target.id;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function nowFor(tarinai = null) {
return finiteOr(tarinai?.world?.time, 0);
}
function hidden(obj, key, value) {
if (!obj) return value;
Object.defineProperty(obj, key, {
value,
writable: true,
configurable: true,
enumerable: false,
});
return value;
function normalizePhase(value, fallback = "acting") {
const phase = String(value || fallback || "acting");
return PHASES.has(phase) ? phase : phase;
}
function actionIdFrom(input = {}, legacyIntent = null, legacyAction = null, previous = null) {
return String(input.id || input.actionId || legacyIntent?.actionId || legacyAction?.id || previous?.id || "");
function targetIdFor(target = null) {
if (!target) return null;
if (target.id != null) return String(target.id);
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function normalizeBehaviorTarget(input = undefined, fallback = undefined) {
const target = input !== undefined ? input : fallback;
if (!target) return null;
if (target.kind) {
const out = { kind: String(target.kind) };
if (target.id != null) out.id = String(target.id);
if (Number.isFinite(Number(target.x))) out.x = Number(target.x);
if (Number.isFinite(Number(target.y))) out.y = Number(target.y);
return out;
}
if (target.id != null) {
const type = String(target.type || "");
const kind = target.kind === "tarinai" || target.familyKey || type === "tarinai" ? "tarinai" : "item";
return { kind, id: String(target.id) };
}
if (Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y))) {
return { kind: "position", x: Number(target.x), y: Number(target.y) };
}
return null;
}
function normalizeForcedRequestValue(value = null, fallback = null) {
@ -45,13 +58,13 @@
const uid = String(src.uid || src.forcedId || prev.uid || prev.forcedId || "");
const id = String(src.id || src.actionId || prev.id || prev.actionId || "");
if (!uid && !id) return null;
const request = {
return {
uid,
id,
source: String(src.source || prev.source || "external"),
priority: finiteOr(src.priority, finiteOr(prev.priority, 0)),
status: String(src.status || prev.status || "active"),
reasonText: String(src.reasonText || src.forcedReasonText || prev.reasonText || prev.forcedReasonText || ""),
reasonText: String(src.reasonText || src.reason || prev.reasonText || prev.reason || ""),
causeText: String(src.causeText || prev.causeText || ""),
targetId: src.targetId !== undefined ? src.targetId : (prev.targetId !== undefined ? prev.targetId : null),
createdAt: finiteOr(src.createdAt, finiteOr(prev.createdAt, 0)),
@ -63,148 +76,85 @@
attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)),
failReason: String(src.failReason || prev.failReason || ""),
};
return request;
}
function isBehaviorForcedValue(behavior = null) {
if (!isObject(behavior)) return false;
return Boolean(behavior.source === "forced" || behavior.forcedId || behavior.forcedRequest || behavior.data?.forcedSource || behavior.forced === true);
}
function forcedBehaviorSource(behavior = null) {
if (!isObject(behavior)) return "";
return String(behavior.forcedRequest?.source || behavior.data?.forcedSource || behavior.sourceDetail || "");
}
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
if (!isObject(input)) return null;
const legacyIntent = isObject(input.intent) ? input.intent : null;
const legacyAction = isObject(input.currentAction) ? input.currentAction : null;
const now = nowFor(tarinai);
const id = actionIdFrom(input, legacyIntent, legacyAction, previous) || String(tarinai?.state || "idle");
const need = String(input.need || legacyIntent?.need || legacyAction?.need || previous?.need || "fulfill");
const label = String(input.label || input.actionLabel || legacyIntent?.actionLabel || previous?.label || id || "行動している");
const reasonText = String(input.reasonText || input.presentText || legacyIntent?.reasonText || previous?.reasonText || label || "");
const target = input.target !== undefined ? input.target : tarinai?.target;
const startedAt = finiteOr(input.startedAt ?? legacyIntent?.startedAt ?? legacyAction?.startedAt, finiteOr(previous?.startedAt, now));
const minDuration = finiteOr(input.minDuration ?? legacyAction?.minDuration, finiteOr(previous?.minDuration, 0.45));
const lockSeconds = finiteOr(input.lockSeconds ?? legacyAction?.lockSeconds, finiteOr(previous?.lockSeconds, 1.0));
const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length
? [...input.tiedNeeds]
: (Array.isArray(legacyIntent?.tiedNeeds) && legacyIntent.tiedNeeds.length
? [...legacyIntent.tiedNeeds]
: (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need]));
const behavior = {
id,
need,
subNeed: String(input.subNeed || legacyAction?.subNeed || previous?.subNeed || ""),
phase: String(input.phase || legacyAction?.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "active")),
state: String(input.state || tarinai?.state || previous?.state || "idle"),
label,
presentText: String(input.presentText || reasonText || ""),
reasonText,
targetId: input.targetId !== undefined ? input.targetId : localBehaviorTargetId(target),
tiedNeeds,
startedAt,
updatedAt: finiteOr(input.updatedAt, now),
minDuration,
lockSeconds,
deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt,
source: String(input.source || previous?.source || "need"),
sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || previous?.sourceReasonText || previous?.forcedReasonText || ""),
forcedReasonText: String(input.forcedReasonText || input.sourceReasonText || previous?.forcedReasonText || previous?.sourceReasonText || ""),
causeText: String(input.causeText || legacyIntent?.causeText || previous?.causeText || ""),
forced: Boolean(input.forced ?? previous?.forced ?? false),
forcedId: String(input.forcedId || previous?.forcedId || ""),
priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)),
specId: String(input.specId || input.actionSpecId || previous?.specId || id),
specKind: String(input.specKind || previous?.specKind || "need_action"),
};
const previousActionId = previous?.actionId || "";
const actionId = String(input.actionId || previousActionId || tarinai?.state || "idle");
const targetInput = input.target !== undefined ? input.target : (input.targetRef !== undefined ? input.targetRef : undefined);
const target = normalizeBehaviorTarget(targetInput, tarinai?.target);
const forcedRequest = normalizeForcedRequestValue(
input.forcedRequest || input.request || null,
behavior.forced || behavior.forcedId || previous?.forcedRequest
? { ...(previous?.forcedRequest || {}), uid: behavior.forcedId || previous?.forcedRequest?.uid || "", id: behavior.id, source: behavior.source, priority: behavior.priority, reasonText: behavior.forcedReasonText || behavior.sourceReasonText, causeText: behavior.causeText, targetId: behavior.targetId }
(input.forced || input.forcedId || previous?.forcedRequest)
? { ...(previous?.forcedRequest || {}), uid: input.forcedId || previous?.forcedId || previous?.forcedRequest?.uid || "", id: actionId, source: input.sourceDetail || input.source || previous?.data?.forcedSource || previous?.forcedRequest?.source || "external", priority: input.priority ?? previous?.priority ?? 0, reasonText: input.sourceReasonText || input.forcedReasonText || input.reason || input.reasonText || previous?.sourceReasonText || previous?.forcedRequest?.reasonText || "", causeText: input.causeText || previous?.causeText || "", targetId: input.targetId ?? targetIdFor(target) }
: null
);
behavior.forcedRequest = forcedRequest;
if (forcedRequest) {
behavior.forced = true;
behavior.forcedId = forcedRequest.uid || behavior.forcedId;
behavior.source = forcedRequest.source || behavior.source || "external";
behavior.priority = finiteOr(forcedRequest.priority, behavior.priority);
behavior.forcedReasonText = behavior.forcedReasonText || forcedRequest.reasonText || "";
behavior.sourceReasonText = behavior.sourceReasonText || forcedRequest.reasonText || behavior.forcedReasonText || "";
behavior.causeText = behavior.causeText || forcedRequest.causeText || "";
}
const forced = Boolean(forcedRequest || input.forced || input.forcedId || previous?.forcedId && input.source === "forced");
const source = forced ? "forced" : String(input.source || previous?.source || "need");
const data = isObject(previous?.data) ? { ...previous.data } : {};
if (isObject(input.data)) Object.assign(data, input.data);
if (forcedRequest) data.forcedSource = forcedRequest.source || data.forcedSource || "external";
else if (forced && input.source && input.source !== "forced") data.forcedSource = String(input.source);
const reason = String(input.reason ?? input.reasonText ?? input.presentText ?? previous?.reason ?? previous?.reasonText ?? previous?.presentText ?? input.label ?? previous?.label ?? "");
const label = String(input.label || input.actionLabel || previous?.label || actionId || "行動している");
const need = String(input.need || previous?.need || "fulfill");
const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length
? [...input.tiedNeeds].filter(Boolean).map(String)
: (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need]);
const behavior = {
actionId,
phase: normalizePhase(input.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "acting")),
target,
source,
reason,
priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)),
startedAt: finiteOr(input.startedAt, finiteOr(previous?.startedAt, now)),
elapsed: finiteOr(input.elapsed, Math.max(0, now - finiteOr(input.startedAt ?? previous?.startedAt, now))),
timeout: Number.isFinite(Number(input.timeout ?? input.deadlineAt)) ? Number(input.timeout ?? input.deadlineAt) : (previous?.timeout ?? null),
interruptible: input.interruptible !== undefined ? input.interruptible !== false : previous?.interruptible !== false,
data,
need,
subNeed: String(input.subNeed || previous?.subNeed || ""),
tiedNeeds,
label,
text: String(input.text ?? input.presentText ?? input.reasonText ?? previous?.text ?? previous?.presentText ?? reason ?? label ?? ""),
lockSeconds: finiteOr(input.lockSeconds, finiteOr(previous?.lockSeconds, 1.0)),
minDuration: finiteOr(input.minDuration, finiteOr(previous?.minDuration, 0.45)),
deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt,
forcedId: forcedRequest?.uid || String(input.forcedId || previous?.forcedId || ""),
forcedRequest,
sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || forcedRequest?.reasonText || previous?.sourceReasonText || ""),
causeText: String(input.causeText || forcedRequest?.causeText || previous?.causeText || ""),
specId: String(input.specId || input.actionSpecId || previous?.specId || actionId),
specKind: String(input.specKind || previous?.specKind || "need_action"),
};
if (Number.isFinite(Number(input.serial))) behavior.serial = Number(input.serial);
return syncLegacyViews(behavior);
}
function behaviorFromLegacyIntent(intent, currentAction = null, tarinai = null, previous = null) {
if (!isObject(intent) && !isObject(currentAction)) return null;
const now = nowFor(tarinai);
return normalizeBehaviorValue({
id: currentAction?.id || intent?.actionId || tarinai?.state || "idle",
need: intent?.need || currentAction?.need || previous?.need || "fulfill",
subNeed: currentAction?.subNeed || previous?.subNeed || "",
phase: currentAction?.phase || previous?.phase || "active",
state: tarinai?.state || previous?.state || "idle",
label: intent?.actionLabel || previous?.label || "行動している",
presentText: intent?.reasonText || previous?.presentText || "",
reasonText: intent?.reasonText || previous?.reasonText || intent?.actionLabel || "",
causeText: intent?.causeText || previous?.causeText || "",
tiedNeeds: Array.isArray(intent?.tiedNeeds) && intent.tiedNeeds.length ? [...intent.tiedNeeds] : undefined,
startedAt: intent?.startedAt ?? currentAction?.startedAt ?? now,
minDuration: currentAction?.minDuration,
lockSeconds: currentAction?.lockSeconds,
priority: previous?.priority,
source: previous?.source || "legacy",
forced: previous?.forced,
forcedId: previous?.forcedId,
sourceReasonText: previous?.sourceReasonText,
forcedReasonText: previous?.forcedReasonText,
}, tarinai, previous);
}
function syncLegacyViews(behavior) {
if (!isObject(behavior)) return null;
const intent = {
need: behavior.need || "fulfill",
tiedNeeds: Array.isArray(behavior.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [behavior.need || "fulfill"],
actionId: behavior.id || "idle",
actionLabel: behavior.label || behavior.presentText || behavior.reasonText || "行動している",
reasonText: behavior.reasonText || behavior.presentText || behavior.label || "",
causeText: behavior.causeText || "",
startedAt: finiteOr(behavior.startedAt, 0),
};
const currentAction = {
id: behavior.id || "idle",
need: behavior.need || intent.need,
subNeed: behavior.subNeed || "",
startedAt: finiteOr(behavior.startedAt, 0),
minDuration: finiteOr(behavior.minDuration, 0.45),
lockSeconds: finiteOr(behavior.lockSeconds, 1.0),
phase: behavior.phase || "active",
specId: behavior.specId || behavior.id || "idle",
specKind: behavior.specKind || "need_action",
};
const forcedRequest = normalizeForcedRequestValue(behavior.forcedRequest || null,
behavior.forced || behavior.forcedId ? { uid: behavior.forcedId || "", id: behavior.id || "", source: behavior.source || "external", priority: finiteOr(behavior.priority, 0), reasonText: behavior.forcedReasonText || behavior.sourceReasonText || "", causeText: behavior.causeText || "", targetId: behavior.targetId } : null);
behavior.forcedRequest = forcedRequest;
const activeForcedBehavior = forcedRequest
? { uid: forcedRequest.uid || behavior.forcedId || "", id: forcedRequest.id || behavior.id || "", source: forcedRequest.source || behavior.source || "external", priority: finiteOr(forcedRequest.priority, finiteOr(behavior.priority, 0)) }
: null;
hidden(behavior, "_intent", intent);
hidden(behavior, "_currentAction", currentAction);
hidden(behavior, "_activeForcedBehavior", activeForcedBehavior);
return behavior;
}
function normalizeBehaviorState(opts = {}, tarinai = null) {
const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null;
let behavior = normalizeBehaviorValue(opts.behavior || opts.activeBehavior || null, tarinai, previous);
if (!behavior) behavior = behaviorFromLegacyIntent(opts.intent || null, opts.currentAction || null, tarinai, previous);
if (!behavior) return null;
if (isObject(opts.intent)) mergeIntent(tarinai, opts.intent, behavior);
if (isObject(opts.currentAction)) mergeCurrentAction(tarinai, opts.currentAction, behavior);
if (isObject(opts.activeForcedBehavior)) mergeActiveForcedBehavior(tarinai, opts.activeForcedBehavior, behavior);
return syncLegacyViews(behavior);
return normalizeBehaviorValue(opts.behavior || null, tarinai, previous);
}
function ensureBehavior(tarinai) {
if (!tarinai) return null;
if (isObject(tarinai.behavior)) return syncLegacyViews(tarinai.behavior);
return null;
if (!tarinai || !isObject(tarinai.behavior)) return null;
const b = tarinai.behavior;
return b;
}
function assignBehavior(tarinai, value) {
@ -214,31 +164,6 @@
return next;
}
function getTarinaiBehavior(tarinai) {
return ensureBehavior(tarinai);
}
function getTarinaiBehaviorId(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.id || fallback || "");
}
function getTarinaiBehaviorNeed(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.need || fallback || "");
}
function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") {
const b = ensureBehavior(tarinai);
if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds];
return [String(b?.need || fallbackNeed || "fulfill")];
}
function getTarinaiBehaviorText(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.presentText || b?.reasonText || b?.label || fallback || "").trim();
}
function patchTarinaiBehavior(tarinai, fields = {}) {
if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai);
const previous = ensureBehavior(tarinai) || {};
@ -251,167 +176,71 @@
if (!tarinai) return null;
tarinai.behavior = null;
if (opts.clearTarget) tarinai.target = null;
if (opts.reason) tarinai.thought = String(opts.reason || "");
return null;
}
function getTarinaiBehavior(tarinai) { return ensureBehavior(tarinai); }
function getTarinaiBehaviorId(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.actionId || fallback || ""); }
function getTarinaiBehaviorNeed(tarinai, fallback = "") { return String(ensureBehavior(tarinai)?.need || fallback || ""); }
function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") {
const b = ensureBehavior(tarinai);
if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds];
return [String(b?.need || fallbackNeed || "fulfill")];
}
function getTarinaiBehaviorText(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.text || b?.reason || b?.label || fallback || "").trim();
}
function setTarinaiForcedBehavior(tarinai, value = null) {
return mergeActiveForcedBehavior(tarinai, value || null);
}
function mergeIntent(tarinai, value, behavior = null) {
if (!tarinai || !isObject(value)) return null;
const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({
id: value.actionId || tarinai.state || "idle",
need: value.need || "fulfill",
label: value.actionLabel || value.reasonText || "行動している",
reasonText: value.reasonText || value.actionLabel || "",
tiedNeeds: value.tiedNeeds,
causeText: value.causeText || "",
startedAt: value.startedAt,
source: "intent",
}, tarinai, null);
if (!b) return null;
if (value.actionId != null) b.id = String(value.actionId || b.id || "idle");
if (value.need != null) b.need = String(value.need || b.need || "fulfill");
if (value.actionLabel != null) b.label = String(value.actionLabel || b.label || "");
if (value.reasonText != null) {
b.reasonText = String(value.reasonText || "");
b.presentText = b.presentText || b.reasonText;
}
if (value.causeText != null) b.causeText = String(value.causeText || "");
if (Array.isArray(value.tiedNeeds) && value.tiedNeeds.length) b.tiedNeeds = [...value.tiedNeeds];
if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt);
if (value.specId != null) b.specId = String(value.specId || b.id || "idle");
if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action");
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._intent;
}
function mergeCurrentAction(tarinai, value, behavior = null) {
if (!tarinai || !isObject(value)) return null;
const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({
id: value.id || tarinai.state || "idle",
need: value.need || "fulfill",
subNeed: value.subNeed || "",
startedAt: value.startedAt,
minDuration: value.minDuration,
lockSeconds: value.lockSeconds,
phase: value.phase,
source: "currentAction",
}, tarinai, null);
if (!b) return null;
if (value.id != null) b.id = String(value.id || b.id || "idle");
if (value.need != null) b.need = String(value.need || b.need || "fulfill");
if (value.subNeed != null) b.subNeed = String(value.subNeed || "");
if (value.phase != null) b.phase = String(value.phase || b.phase || "active");
if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt);
if (Number.isFinite(Number(value.minDuration))) b.minDuration = Number(value.minDuration);
if (Number.isFinite(Number(value.lockSeconds))) b.lockSeconds = Number(value.lockSeconds);
if (value.specId != null) b.specId = String(value.specId || b.id || "idle");
if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action");
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._currentAction;
}
function mergeActiveForcedBehavior(tarinai, value, behavior = null) {
if (!tarinai) return null;
const b = behavior || ensureBehavior(tarinai);
const b = ensureBehavior(tarinai);
if (!b) return null;
if (!value) {
b.forced = false;
b.forcedId = "";
b.forcedRequest = null;
b.sourceReasonText = b.sourceReasonText || "";
b.forcedReasonText = b.forcedReasonText || "";
tarinai.behavior = syncLegacyViews(b);
return null;
const data = isObject(b.data) ? { ...b.data } : {};
delete data.forcedSource;
const next = normalizeBehaviorValue({ ...b, source: b.source === "forced" ? "need" : (b.source || "need"), forcedId: "", forcedRequest: null, sourceReasonText: "", data }, tarinai, null);
tarinai.behavior = next;
return next;
}
const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.id || "idle", source: b.source || "external", priority: b.priority || 0, reasonText: b.forcedReasonText || b.sourceReasonText || "", causeText: b.causeText || "", targetId: b.targetId });
b.forced = true;
b.forcedRequest = request;
b.forcedId = String(request?.uid || value.uid || value.forcedId || b.forcedId || "");
if (request?.id || value.id != null) b.id = String(request?.id || value.id || b.id || "idle");
b.source = String(request?.source || value.source || b.source || "external");
b.priority = finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0)));
b.forcedReasonText = b.forcedReasonText || request?.reasonText || "";
b.sourceReasonText = b.sourceReasonText || request?.reasonText || b.forcedReasonText || "";
b.causeText = b.causeText || request?.causeText || "";
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._activeForcedBehavior;
const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.actionId || "idle", source: b.data?.forcedSource || "external", priority: b.priority || 0, reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "", targetId: targetIdFor(b.target) });
return patchTarinaiBehavior(tarinai, {
...b,
actionId: request?.id || value.actionId || value.id || b.actionId || "idle",
source: "forced",
priority: finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0))),
forcedId: String(request?.uid || value.uid || value.forcedId || b.forcedId || ""),
forcedRequest: request,
sourceReasonText: request?.reasonText || b.sourceReasonText || "",
causeText: b.causeText || request?.causeText || "",
data: { ...(b.data || {}), forcedSource: request?.source || value.source || "external" },
});
}
function getTarinaiForcedBehavior(tarinai) {
const b = ensureBehavior(tarinai);
if (!b || !b.forced) return null;
return b.forcedRequest || b._activeForcedBehavior || null;
if (!isBehaviorForcedValue(b)) return null;
return b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: forcedBehaviorSource(b) || "external", priority: finiteOr(b.priority, 0), reasonText: b.sourceReasonText || b.reason || "", causeText: b.causeText || "" };
}
function isTarinaiBehaviorForced(tarinai) {
const b = ensureBehavior(tarinai);
return Boolean(b?.forced || b?.forcedId || b?.forcedRequest);
}
Object.defineProperties(Tarinai.prototype, {
activeBehavior: {
configurable: true,
get() {
return ensureBehavior(this);
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
assignBehavior(this, value);
},
},
intent: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._intent : null;
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
mergeIntent(this, value);
},
},
currentAction: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._currentAction : null;
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
mergeCurrentAction(this, value);
},
},
activeForcedBehavior: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._activeForcedBehavior : null;
},
set(value) {
mergeActiveForcedBehavior(this, value || null);
},
},
Object.assign(global, {
normalizeTarinaiBehaviorState: normalizeBehaviorState,
normalizeTarinaiBehaviorValue: normalizeBehaviorValue,
getTarinaiBehavior,
currentTarinaiBehavior: getTarinaiBehavior,
getTarinaiBehaviorId,
getTarinaiBehaviorNeed,
getTarinaiBehaviorTiedNeeds,
getTarinaiBehaviorText,
patchTarinaiBehavior,
setTarinaiBehavior: assignBehavior,
clearTarinaiBehavior,
normalizeTarinaiForcedRequest: normalizeForcedRequestValue,
getTarinaiForcedBehavior,
setTarinaiForcedBehavior,
clearTarinaiForcedBehavior: function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); },
isTarinaiBehaviorForced: function isTarinaiBehaviorForced(tarinai) { return isBehaviorForcedValue(ensureBehavior(tarinai)); },
isTarinaiBehaviorValueForced: isBehaviorForcedValue,
tarinaiBehaviorForcedSource: forcedBehaviorSource,
});
global.normalizeTarinaiBehaviorState = normalizeBehaviorState;
global.normalizeTarinaiBehaviorValue = normalizeBehaviorValue;
global.syncTarinaiBehaviorLegacyViews = syncLegacyViews;
global.getTarinaiBehavior = getTarinaiBehavior;
global.currentTarinaiBehavior = getTarinaiBehavior;
global.getTarinaiBehaviorId = getTarinaiBehaviorId;
global.getTarinaiBehaviorNeed = getTarinaiBehaviorNeed;
global.getTarinaiBehaviorTiedNeeds = getTarinaiBehaviorTiedNeeds;
global.getTarinaiBehaviorText = getTarinaiBehaviorText;
global.patchTarinaiBehavior = patchTarinaiBehavior;
global.setTarinaiBehavior = assignBehavior;
global.clearTarinaiBehavior = clearTarinaiBehavior;
global.normalizeTarinaiForcedRequest = normalizeForcedRequestValue;
global.getTarinaiForcedBehavior = getTarinaiForcedBehavior;
global.isTarinaiBehaviorForced = isTarinaiBehaviorForced;
global.setTarinaiForcedBehavior = setTarinaiForcedBehavior;
global.clearTarinaiForcedBehavior = function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); };
})(typeof window !== "undefined" ? window : globalThis);

168
js/tarinai_behavior_text.js Normal file
View file

@ -0,0 +1,168 @@
"use strict";
function fulfillPrimaryReason(tarinai, action = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
const actionId = action?.id || behavior?.actionId || "";
if (actionId === "build_grass_bed") return "\u5bdd\u5e8a\u304c\u307b\u3057\u3044";
if (actionId === "build_plushie") return "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044";
if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b";
if (actionId === "use_plushie") return "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f\u3044";
if (actionId === "play") return "\u9000\u5c48\u3057\u3066\u3044\u308b";
if (actionId === "wander_lightly") return "\u5c11\u3057\u9000\u5c48\u3057\u3066\u3044\u308b";
const parts = tarinai?.fulfillReasonParts || {};
const candidates = [
["bed", "\u5bdd\u5e8a\u304c\u307b\u3057\u3044", parts.bed],
["plushie", "\u306c\u3044\u3050\u308b\u307f\u304c\u307b\u3057\u3044", parts.plushie],
["boredom", "\u9000\u5c48\u3057\u3066\u3044\u308b", parts.boredom],
["material", "\u8349\u3067\u306a\u306b\u304b\u4f5c\u308c\u305d\u3046", parts.material],
["openness", "\u65b0\u3057\u3044\u3053\u3068\u304c\u6c17\u306b\u306a\u308b", parts.openness],
].filter(([, , v]) => Number(v || 0) > 0);
candidates.sort((a, b) => Number(b[2] || 0) - Number(a[2] || 0));
return candidates[0]?.[1] || TARINAI_NEED_PHRASES.fulfill || "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044";
}
function needPhraseForReason(need, tarinai = null, action = null) {
if (need === "fulfill") return fulfillPrimaryReason(tarinai, action);
if (need === "social" && action?.subNeed === "mate") return "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u304c\u6c17\u306b\u306a\u308b";
if (need === "social" && action?.id === "birth_ritual") return "\u76f8\u624b\u3068\u7e41\u6b96\u3057\u3066\u3044\u308b";
if (need === "social" && action?.subNeed === "conflict") return "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b";
if (need === "social" && action?.subNeed === "family") return "\u5bb6\u65cf\u304c\u6c17\u306b\u306a\u308b";
if (need === "social" && action?.subNeed === "bond") return "\u4ef2\u9593\u304c\u6c17\u306b\u306a\u308b";
if (need === "safety" && action?.id === "panic_escape") return "\u6016\u3044";
return TARINAI_NEED_PHRASES[need] || need;
}
function actionTextForReason(action = null, tarinai = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
const id = action?.id || behavior?.actionId || "";
const target = tarinai?.target || currentBehaviorTarget?.(tarinai) || null;
const name = target?.name || "\u76f8\u624b";
if (id === "birth_ritual") return target?.name ? `${name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
if (id === "fight_rival") {
const cause = String(behavior?.causeText || "");
if (cause.includes("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")) return "\u53cd\u6483\u3057\u3066\u3044\u308b";
return target?.name ? `${name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b";
}
if (id === "panic_escape" || id === "flee") return "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
return String(action?.label || action?.phrase || behavior?.label || "\u5f85\u3063\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "");
}
function forcedCausePhrase(source = "", fallback = "") {
const key = String(source || "");
if (key === "love_mochi") return "\u3078\u3053\u9905\u306e\u52b9\u679c";
if (key === "fight_mochi") return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
if (key === "sleep_drug") return "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c";
if (key === "drug") return "\u85ac\u306e\u52b9\u679c";
return String(fallback || "").replace(/[\u3002\uff01\uff1f]+$/, "");
}
function formatCauseActionText(cause = "", actionText = "\u884c\u52d5\u3057\u3066\u3044\u308b") {
const c = String(cause || "").replace(/[\u3002\uff01\uff1f]+$/, "").trim();
const a = String(actionText || "\u884c\u52d5\u3057\u3066\u3044\u308b").replace(/[\u3002\uff01\uff1f]+$/, "").trim();
if (!c) return `${a}\u3002`;
if (c.endsWith("\u52b9\u679c") || c.endsWith("\u5f71\u97ff")) return `${c}\u3067\u3001${a}\u3002`;
if (c.endsWith("\u6c17\u4ed8\u3044\u305f")) return `${c.replace(/\u6c17\u4ed8\u3044\u305f$/, "\u6c17\u4ed8\u3044\u3066")}\u3001${a}\u3002`;
if (c.endsWith("\u6c17\u3065\u3044\u305f")) return `${c.replace(/\u6c17\u3065\u3044\u305f$/, "\u6c17\u3065\u3044\u3066")}\u3001${a}\u3002`;
if (c.endsWith("\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f")) return `${c.replace(/\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f$/, "\u3051\u3093\u304b\u3092\u58f2\u3089\u308c")}\u3001${a}\u3002`;
if (c.endsWith("\u5a01\u5687\u3055\u308c\u305f")) return `${c.replace(/\u5a01\u5687\u3055\u308c\u305f$/, "\u5a01\u5687\u3055\u308c")}\u3001${a}\u3002`;
return `${c}\u306e\u3067\u3001${a}\u3002`;
}
function causePhraseForReason(chosenNeed, action, tarinai = null, options = {}) {
if (options?.causeText) return String(options.causeText || "").replace(/[\u3002\uff01\uff1f]+$/, "");
const b = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || {};
if (b.causeText) return String(b.causeText).replace(/[\u3002\uff01\uff1f]+$/, "");
if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(b) : Boolean(b.forcedId || b.forcedRequest)) || b.source) {
const forced = forcedCausePhrase(b.source || "", b.sourceReasonText || b.sourceReasonText || "");
if (forced) return forced;
}
if (action?.id === "panic_escape" && tarinai?.lastPanicDetail) return String(tarinai.lastPanicDetail).replace(/[\u3002\uff01\uff1f]+$/, "");
if (action?.id === "fight_rival") {
const target = currentBehaviorTarget?.(tarinai) || tarinai?.target || null;
if (tarinai?.counterAttackFromId && target?.id === tarinai.counterAttackFromId) return `${target.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
if (target?.name) return `${target.name}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
}
if (action?.id === "birth_ritual" || action?.subNeed === "mate") {
if ((tarinai?.loveMochiTimer || 0) > 0.04) return "\u3078\u3053\u9905\u306e\u52b9\u679c";
}
return needPhraseForReason(chosenNeed, tarinai, action);
}
function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null, options = {}) {
const actionText = actionTextForReason(action, tarinai);
const cause = causePhraseForReason(chosenNeed, action, tarinai, options);
const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : [];
const main = formatCauseActionText(cause, actionText);
if (tied.length) {
const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0];
return `${needPhraseForReason(ignored, tarinai, action)}\u3051\u3069\u3001${main}`;
}
return main;
}
function needsForBehaviorText(tarinai, behavior = {}, fallbackNeed = "fulfill") {
const primary = String(behavior?.need || fallbackNeed || "fulfill");
const raw = tarinai?.needRaw || tarinai?.needs || {};
const list = Array.isArray(behavior?.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [primary];
if (!list.includes(primary)) list.unshift(primary);
return list.filter((key, idx) => key && (idx === 0 || Number(raw?.[key] || 0) > 0.5));
}
function causeForBehaviorText(tarinai, behavior = {}, action = null, options = {}) {
const request = behavior?.forcedRequest || (typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(tarinai) : null) || null;
const directCause = options?.causeText || behavior?.causeText || request?.causeText || "";
if (directCause) return String(directCause || "").replace(/[。!?]+$/, "");
if ((typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.forcedId || behavior?.forcedRequest)) || request) {
const source = request?.source || behavior?.source || "external";
const sourceReason = request?.reasonText || behavior?.sourceReasonText || behavior?.sourceReasonText || "";
const forcedCause = forcedCausePhrase(source, sourceReason);
if (forcedCause) return forcedCause;
}
return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options);
}
function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) {
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null;
if (!b) return "";
const stored = String(b.text || b.reason || "").trim();
if (stored && options.regenerate !== true) return stored;
const action = options.action || actionById(b.specId || b.actionId) || actionById(b.actionId) || null;
const need = String(options.need || b.need || action?.need || "fulfill");
const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, need);
const causeText = causeForBehaviorText(tarinai, { ...b, need }, action, options);
const generated = action ? buildReasonText(need, tiedNeeds, action, tarinai, tarinai?.world || null, { ...options, causeText }) : "";
const actionLabel = typeof textTarinaiAction === "function" ? textTarinaiAction(action || b.actionId, tarinai, tarinai?.world || null, { ...options, behavior: b, need, tiedNeeds, causeText, phase: options.phase || b.phase || "active" }) : "";
const rawFallback = String(actionLabel || b.label || b.reason || b.text || "").trim();
const catalog = globalThis.TEXT_CATALOG || (typeof window !== "undefined" ? window.TEXT_CATALOG : null);
const fallback = catalog?.cleanBehaviorText
? catalog.cleanBehaviorText(rawFallback, b.actionId || b.specId || "")
: rawFallback;
return String(generated || fallback || "").trim();
}
function patchBehaviorTextFromComposer(tarinai, behavior = null, options = {}) {
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior) || null;
if (!b) return "";
const prevReason = String(b.reason || "");
const text = composeTarinaiBehaviorText(tarinai, b, { ...options, regenerate: true });
if (!text) return "";
const tiedNeeds = Array.isArray(options.tiedNeeds) && options.tiedNeeds.length ? [...options.tiedNeeds] : needsForBehaviorText(tarinai, b, b.need || options.need || "fulfill");
if (text !== b.reason || text !== b.text || JSON.stringify(tiedNeeds) !== JSON.stringify(b.tiedNeeds || [])) {
if (typeof patchTarinaiBehavior === "function") patchTarinaiBehavior(tarinai, { reason: text, text: text, tiedNeeds });
else { b.reason = text; b.text = text; b.tiedNeeds = tiedNeeds; }
}
if (text && (!tarinai.thought || tarinai.thought === prevReason || tarinai.thought === b.label)) tarinai.thought = text;
return text;
}
function refreshBehaviorReasonText(tarinai, action = null) {
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
if (!behavior?.need) return "";
return patchBehaviorTextFromComposer(tarinai, behavior, { action });
}

View file

@ -0,0 +1,116 @@
"use strict";
// Owned-structure building behavior runtime.
function structureBuildProbe(type, x, y) {
const def = globalThis.StructureRegistry?.get?.(type) || {};
const r = type === "plushie" ? 6.5 : (type === "grass_bed" ? 18 : 24);
return { type, x, y, r, dead: false, isStructure: true, roles: def.roles || {}, ownerId: "" };
}
function canBuildOwnedStructureByAge(tarinai) {
if (!tarinai || tarinai.dead) return false;
if (tarinai.generation <= 1) return true;
const growth = Number.isFinite(tarinai.growth)
? tarinai.growth
: clamp(((tarinai.world?.time || 0) - (tarinai.birthTime || 0)) / Math.max(1, CONFIG.childGrowTime || 58), 0, 1);
return growth >= 0.55;
}
function stopFailedBuildPlan(t, world, type = "", reason = "") {
if (!t) return false;
t.buildPlan = null;
t.actionCooldowns = t.actionCooldowns || {};
t.actionIdCooldowns = t.actionIdCooldowns || {};
const actionId = type === "grass_bed" ? "build_grass_bed" : (type === "plushie" ? "build_plushie" : "build");
t.actionCooldowns.fulfill = Math.max(t.actionCooldowns.fulfill || 0, 5.0);
t.actionCooldowns.sleep = Math.max(t.actionCooldowns.sleep || 0, type === "grass_bed" ? 3.8 : 0);
t.actionIdCooldowns[actionId] = Math.max(t.actionIdCooldowns[actionId] || 0, 9.0);
if (type === "grass_bed") {
t.actionIdCooldowns.sleep_build_grass_bed = Math.max(t.actionIdCooldowns.sleep_build_grass_bed || 0, 9.0);
t.lastFailedGrassBedBuildAt = world?.time || 0;
}
t.behaviorSwitchCooldownUntil = Math.max(t.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 1.2);
const text = reason || "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044";
if (typeof clearBehavior === "function") clearBehavior(t, text);
t.goIdle?.(text);
return false;
}
function findStructureBuildSpot(t, world, type) {
const baseX = Number.isFinite(t?.x) ? t.x : 0;
const baseY = Number.isFinite(t?.y) ? t.y : 0;
const candidates = [];
for (let i = 0; i < 28; i++) {
const angle = (i / 28) * Math.PI * 2 + rand(-0.18, 0.18);
const ring = type === "grass_bed" ? rand(34, 72) : rand(18, 42);
candidates.push({ x: clamp(baseX + Math.cos(angle) * ring, CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + Math.sin(angle) * ring, CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) });
}
candidates.unshift({ x: clamp(baseX + rand(-18, 18), CONFIG.worldPadding || 30, (world?.w || 1000) - (CONFIG.worldPadding || 30)), y: clamp(baseY + rand(16, 28), CONFIG.worldPadding || 30, (world?.h || 720) - (CONFIG.worldPadding || 30)) });
let best = null;
let bestScore = -Infinity;
for (const c of candidates) {
const probe = structureBuildProbe(type, c.x, c.y);
if (world?.placementBlocked?.(probe)) continue;
let nearestSame = Infinity;
if (type === "grass_bed") {
for (const it of world?.nearbyItems?.(c.x, c.y, 160) || world?.items || []) {
if (!it || it.dead || it.type !== "grass_bed") continue;
nearestSame = Math.min(nearestSame, distXY(c.x, c.y, it.x, it.y));
}
}
const score = (Number.isFinite(nearestSame) ? nearestSame : 160) - distXY(c.x, c.y, baseX, baseY) * 0.12;
if (score > bestScore) { best = c; bestScore = score; }
}
return best || candidates[0] || { x: baseX, y: baseY };
}
function buildOwnedStructureFromGrass(t, world, type, label) {
const material = findNearbyMaterial(world, t, "grassMaterial", 520);
if (!material || !globalThis.StructureRegistry) return false;
t.buildPlan = {
type,
materialId: material.id,
timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4),
};
if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label);
t.setActionState?.("build", { target: material, reason: label, sleeping: false });
return true;
}
function continueBuildPlan(t, world, dt) {
const plan = t.buildPlan;
if (!plan?.type || !globalThis.StructureRegistry) return false;
if (findOwnedStructure(world, t, plan.type)) {
t.buildPlan = null;
return false;
}
let material = (world?.itemById?.(plan.materialId) || null);
if (material && (material.dead || !roleMatches(material, "grassMaterial"))) material = null;
if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520);
if (!material) {
return stopFailedBuildPlan(t, world, plan.type, "\u6750\u6599\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
plan.materialId = material.id;
const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269";
if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) {
moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`);
return true;
}
plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1));
t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false });
if (plan.timer > 0) return true;
consumeGrassMaterial(material, plan.type === "grass_bed" ? 4 : 1);
const spot = findStructureBuildSpot(t, world, plan.type);
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure);
world.drawListDirty = true;
structure.use?.(t, world);
t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false });
applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type);
t.buildPlan = null;
return true;
}

View file

@ -0,0 +1,150 @@
"use strict";
// Consumable/drink/medicine behavior runtime.
function finishBehaviorConsumption(t, item, foodType, eating, dt, label, color = "#f1dfb7") {
if (!t || !item || !(eating > 0)) return false;
t.beginEating?.(item, label || "\u98df\u3079\u3066\u3044\u308b");
t.vx *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60);
t.vy *= Math.pow(0.25, Math.max(0.1, dt || 0.1) * 60);
t.eatTimer = Math.max(t.eatTimer || 0, 1.0);
t.eatCooldown = rand(0.96, 1.12);
t.foodReactTimer = Math.max(t.foodReactTimer || 0, 0.16);
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(t, { food: Math.max(18, Math.min(54, (Number(eating) || 0) * 1.1)) }, "meal");
t.lastMealColor = color;
if (Math.random() < Math.max(0.1, dt || 0.1) * 2.2) {
const mouth = t.mouthPosition?.(item) || { x: t.x, y: t.y };
t.world?.spawnEatEffect?.(mouth.x, mouth.y, color);
}
if ((t.world?.time || 0) > (t.nextEatSound || -999)) {
audio.eat?.();
t.nextEatSound = (t.world?.time || 0) + 0.42;
}
return true;
}
function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) {
if (!t || !world || !target || target.dead) return false;
const foodType = effectiveFoodTypeForItem(target);
const labelFor = type => typeof toolLabel === "function" ? toolLabel(type || target.type) : "\u98df\u3079\u7269";
const effectiveDt = Math.max(0.10, Number(dt) || 0.12);
if (isDuplicatorFoodSource(target) && (t.eatCooldown || 0) > 0.04) {
t.setActionState?.(role === "medicine" ? "seek_food" : "eat", { target, reason: `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, sleeping: false });
return true;
}
if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) {
const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b";
t.setActionState?.("seek_water", { target, reason: text, sleeping: false });
t.thought = text;
t.applyWaterEffect?.(0.55, target.type);
if (target.type === "water") target.amount -= 1.2;
if ((world.time || 0) > (t.nextDrinkSound || -999)) {
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
t.nextDrinkSound = (world.time || 0) + 0.72;
}
applyNeedRelief(t, role === "medicine" ? { food: -2, health: -5, safety: -1 } : { food: -4, health: -1 });
return true;
}
if (target.type === "ant_corpse" && (target.amount || 0) > 0) {
const bite = Math.min(target.amount, rand(3.2, 5.4));
if (!(bite > 0)) return false;
target.amount -= bite;
finishBehaviorConsumption(t, target, "ant_corpse", bite, effectiveDt, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b", "#4a3f36");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.62) : (t.hunger -= bite * 0.62);
t.recoverHealth?.(bite * 0.12);
applyNeedShock(t, { safety: bite * 0.12 });
t.makePoop?.(normalPoopUnitsFor("ant_corpse", bite));
return true;
}
if ((foodType === "sweet" || foodType === "food" || (isDuplicatorFoodSource(target) && foodType === "grass") || (typeof isServingFoodType === "function" && isServingFoodType(foodType)) || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && t.foodItemHasServingLeft?.(target)) {
const eating = t.consumeFoodServing?.(target);
if (!(eating > 0)) return false;
const isParam = typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType);
const isSpecial = foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || isParam;
const color = ({ sweet: "#78b957", love_mochi: "#ff7bab", fight_mochi: "#e07c43", sleep_drug: "#b89cff", protein: "#f28d3d", niteropu: "#6255a8", ammo: "#d0942f", laxative: "#788c3b", mercury: "#6e95a8", giant_drug: "#e47b3a", dwarf_drug: "#7773c8", zunda_juice: "#61b94f" })[foodType] || "#f1dfb7";
finishBehaviorConsumption(t, target, foodType, eating, effectiveDt, `${labelFor(foodType)}\u3092\u98df\u3079\u3066\u3044\u308b`, color);
const hungerRelief = t.foodHungerReliefFor?.(foodType || target.type, eating, foodType === "sweet" ? 0.75 : (isSpecial ? 0.82 : 0.95)) ?? eating * 0.8;
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(hungerRelief) : (t.hunger -= hungerRelief);
t.recoverHealth?.(eating * (foodType === "sweet" ? 0.28 : (isSpecial ? 0.24 : 0.46)));
t.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012;
if (isSpecial && t.applyParamItemEffect) t.applyParamItemEffect(foodType || target.type, target, { source: "eating", nutrition: eating, silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" });
if (foodType === "sweet") {
applyNeedRelief(t, { food: -eating * 0.4, fulfill: -eating * 0.2 });
if (t.explosionDisease) t.recoverExplosionDisease?.("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
if (t.fightDisease) t.recoverFightDisease?.("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
}
const fromDuplicator = isDuplicatorFoodSource(target);
t.makePoop?.(normalPoopUnitsFor(foodType || target.type, eating));
if (fromDuplicator) {
t.mealCooldownUntil = Math.max(t.mealCooldownUntil || 0, (world.time || 0) + 1.2);
t.behaviorLockTimer = Math.max(0, Math.min(t.behaviorLockTimer || 0, 0.18));
}
return true;
}
if (target.type === "grass" && (target.amount || 0) > 0) {
const bite = window.TarinaiGrass?.regress?.(target, 1) ?? 0;
if (!(bite > 0)) return false;
target.eatenAmount = (target.eatenAmount || 0) + bite;
world.markTerrainDirty?.("grass-eaten-stage");
world.markItemBucketsDirty?.("grass-eaten-stage");
finishBehaviorConsumption(t, target, "grass", bite, effectiveDt, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b", "#5b9d39");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * 0.46) : (t.hunger -= bite * 0.46);
t.recoverHealth?.(bite * 0.18);
applyNeedRelief(t, { food: -2, fulfill: -1 });
t.makePoop?.(normalPoopUnitsFor("grass", bite));
return true;
}
if (target.type === "zunchi" && (target.amount || 0) > 0) {
const foodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0;
if (zunchiEatingBlocked(t, target, world)) return false;
if (!t.isZunchiSlave && hasBetterFoodThanZunchiNearby(t, world, 240)) return false;
const emergencyHunger = (t.hunger || 0) >= 104 || foodNeed >= 94;
if (!emergencyHunger && !t.isZunchiSlave && target.stage === "fresh" && (target.age || 0) < 150 && foodNeed < 94 && (t.hunger || 0) < 104) return false;
if (!t.isZunchiSlave && foodNeed < 90 && (t.hunger || 0) < 100) return false;
const servingScale = Number.isFinite(target.foodServingScale) ? target.foodServingScale : (target.toolSize === "large" ? 1.18 : (target.toolSize === "small" ? 0.82 : 1));
const bite = Math.min(target.amount, (t.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale);
if (!(bite > 0)) return false;
target.amount -= bite;
finishBehaviorConsumption(t, target, "zunchi", bite, effectiveDt, t.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b", "#6b8d3f");
t.applyFoodHungerRelief ? t.applyFoodHungerRelief(bite * (t.isZunchiSlave ? 0.66 : 0.18)) : (t.hunger -= bite * (t.isZunchiSlave ? 0.66 : 0.18));
t.recoverHealth?.(bite * (t.isZunchiSlave ? 0.10 : 0.02));
applyNeedShock(t, { health: bite * (t.isZunchiSlave ? 0.2 : 1.2), safety: bite * (t.isZunchiSlave ? 0.1 : 0.6) });
t.makePoop?.(normalPoopUnitsFor("zunchi", bite));
return true;
}
return false;
}
function behaviorFoodTarget(t, world, role = "food", maxDist = 860) {
const best = findNearestItemWithRole(world, t, role, maxDist);
if (role === "food") return best;
const current = currentBehaviorTarget(t) || t.target;
if (current && !current.dead && (roleMatches(current, role) || (role === "food" && current.type === "duplicator" && current.storedFoodType))) return current;
return best;
}
function updateConsumableBehavior(t, world, dt, role = "food") {
if (!t || !world) return false;
const target = behaviorFoodTarget(t, world, role, role === "food" ? 900 : 700);
if (!target || target.dead) return false;
const state = role === "drink" ? "seek_water" : "seek_food";
const label = role === "drink" ? "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : (role === "medicine" ? "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b");
const contactDistance = target.type === "duplicator" ? foodInteractionDistance(t, target) : dist(t, target);
const contactReach = foodInteractionReach(t, target, role);
if (contactDistance > contactReach) {
const approachTarget = target.type === "duplicator" ? { x: foodInteractionPoint(target).x, y: foodInteractionPoint(target).y, id: `${target.id || "duplicator"}:dish`, dead: false, hostItem: target } : target;
t.target = target;
moveToOrUse(t, approachTarget, state, label);
return true;
}
t.target = target;
if (consumeBehaviorTarget(t, world, target, role, dt)) return "finished";
t.setActionState?.(state, { target, reason: label, sleeping: false });
return t.foodItemHasServingLeft?.(target) ? true : false;
}

View file

@ -70,15 +70,6 @@
}
}
},
hasWaterCurableDisease() {
return Boolean(this.zunchiDisease || this.explosionDisease);
},
hasZundaCurableDisease() {
return Boolean(this.explosionDisease || this.fightDisease);
},
infectSleepDisease(source = null) {
if (this.dead || this.sleepDisease) return false;
this.sleepDisease = true;
@ -204,11 +195,6 @@
if (!id) return null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null;
},
isInsideNestBox() {
return Boolean(this.insideNestBoxId);
},
wantsNestBox(box = null) {
if (!box || box.dead || box.type !== "nest_box") return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;

View file

@ -0,0 +1,466 @@
"use strict";
function actionById(id = "") {
const key = String(id || "");
return (typeof getTarinaiActionSpec === "function" ? getTarinaiActionSpec(key) : null)
|| (Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null);
}
function behaviorTargetId(target = null) {
if (!target) return null;
if (target.id != null) return target.id;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function currentBehaviorTarget(tarinai, behavior = null) {
if (!tarinai) return null;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || null;
if (!b) return tarinai.target || null;
const ref = b.target || null;
const id = ref?.id || b.targetId || null;
if (tarinai.target && !tarinai.target.dead && (!id || behaviorTargetId(tarinai.target) === id)) return tarinai.target;
if (id) {
return tarinai.world?.liveTarinaiById?.(id)
|| tarinai.world?.itemById?.(id)
|| (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id)
|| null;
}
if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) return { x: ref.x, y: ref.y, dead: false };
return null;
}
function isLiveTarinaiEntity(value) {
return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y));
}
function relationFearSafe(holder, other) {
if (!holder || !other || typeof holder.relationTo !== "function") return 0;
return Number(holder.relationTo(other.id)?.fear || 0) || 0;
}
function nearestForcedFightTarget(tarinai, world, maxDist = 720) {
if (!tarinai || !world) return null;
const current = currentBehaviorTarget(tarinai) || tarinai.target || null;
const ok = o => isLiveTarinaiEntity(o) && o !== tarinai && !!o.isZunchiSlave === !!tarinai.isZunchiSlave && !world.areParentChild?.(tarinai, o) && !world.areCoParents?.(tarinai, o) && !o.sleepDisease;
if (ok(current) && dist(tarinai, current) <= maxDist + 120) return current;
const conflict = conflictTargetFor(tarinai, world, 6);
if (ok(conflict?.target)) return conflict.target;
return world.nearestOther?.(tarinai, maxDist, ok) || null;
}
function resolveForcedBehaviorTarget(tarinai, world, entry = {}, action = null) {
if (!tarinai || !world) return null;
if (entry.targetId) {
const found = world.liveTarinaiById?.(entry.targetId)
|| world.itemById?.(entry.targetId)
|| (world.items || []).find(item => item && !item.dead && item.id === entry.targetId)
|| null;
if (found) return found;
}
const id = String(entry.id || action?.id || "");
if (id === "fight_rival" || id === "intimidate_enemy") return nearestForcedFightTarget(tarinai, world, Number(entry.searchRange || 760));
if (id === "approach_mate" || id === "birth_ritual") return mateTargetFor(tarinai, world, Number(entry.searchRange || 760));
if (id === "eat_food") return behaviorFoodTarget(tarinai, world, "food", Number(entry.searchRange || 900));
if (id === "drink_water") return behaviorFoodTarget(tarinai, world, "drink", Number(entry.searchRange || 680));
if (id === "use_medicine") return behaviorFoodTarget(tarinai, world, "medicine", Number(entry.searchRange || 760));
if (id === "sleep_in_bed") return findNearestSleepPlace(world, tarinai, Number(entry.searchRange || 760));
return null;
}
function forcedBehaviorSourcePriority(source = "external") {
return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130;
}
function forcedBehaviorQueueOf(tarinai) {
if (!tarinai) return [];
if (!Array.isArray(tarinai.forcedBehaviorQueue)) tarinai.forcedBehaviorQueue = [];
return tarinai.forcedBehaviorQueue;
}
function setForcedBehaviorQueue(tarinai, queue = []) {
if (!tarinai) return [];
tarinai.forcedBehaviorQueue = (Array.isArray(queue) ? queue : []).filter(Boolean);
return tarinai.forcedBehaviorQueue;
}
function normalizeForcedBehaviorEntry(tarinai, actionId, opts = {}) {
const now = tarinai?.world?.time || 0;
const source = opts.source || "external";
const basePriority = forcedBehaviorSourcePriority(source);
const raw = {
uid: opts.uid || `${String(actionId)}:${source}:${now.toFixed(3)}:${Math.random().toString(36).slice(2, 8)}`,
id: String(actionId),
targetId: opts.targetId ?? behaviorTargetId(opts.target),
reason: opts.reasonText || opts.reason || "",
causeText: opts.causeText || "",
source,
priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority,
force: opts.force !== false,
replaceSameSource: opts.replaceSameSource !== false,
interrupt: opts.interrupt !== false,
createdAt: now,
status: opts.status || "queued",
attempts: Number(opts.attempts || 0) || 0,
lastTriedAt: Number(opts.lastTriedAt ?? -999) || -999,
retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25,
expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18),
duration: Number(opts.duration ?? 0) || 0,
minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0,
searchRange: Number(opts.searchRange ?? 0) || 0,
};
return typeof normalizeTarinaiForcedRequest === "function" ? { ...raw, ...normalizeTarinaiForcedRequest(raw, raw), force: raw.force, replaceSameSource: raw.replaceSameSource, interrupt: raw.interrupt, retryDelay: raw.retryDelay, attempts: raw.attempts, lastTriedAt: raw.lastTriedAt, searchRange: raw.searchRange } : raw;
}
function markForcedBehaviorActive(tarinai, entry, action = null) {
if (!tarinai || !entry) return null;
entry.status = "active";
entry.activeSince = entry.activeSince || tarinai.world?.time || 0;
entry.startedAt = entry.startedAt || entry.activeSince;
const payload = { ...entry, id: action?.id || entry.id, status: "active" };
return typeof setTarinaiForcedBehavior === "function" ? setTarinaiForcedBehavior(tarinai, payload) : payload;
}
function behaviorIsForced(behavior = null) {
return typeof isTarinaiBehaviorValueForced === "function"
? isTarinaiBehaviorValueForced(behavior)
: Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest);
}
function isCurrentBehaviorForced(tarinai) {
return behaviorIsForced(typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior);
}
function currentForcedBehaviorRequest(tarinai) {
if (typeof getTarinaiForcedBehavior === "function") return getTarinaiForcedBehavior(tarinai);
const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
return behaviorIsForced(b) ? (b.forcedRequest || { uid: b.forcedId || "", id: b.actionId || "", source: b.data?.forcedSource || b.source || "external", priority: Number(b.priority || 0) || 0 }) : null;
}
function clearForcedBehaviorQueue(tarinai, predicate = null) {
if (!tarinai) return 0;
const queue = forcedBehaviorQueueOf(tarinai);
const before = queue.length;
const next = typeof predicate !== "function" ? [] : queue.filter(entry => !predicate(entry));
setForcedBehaviorQueue(tarinai, next);
const active = currentForcedBehaviorRequest(tarinai);
if (active?.uid && !next.some(entry => entry && entry.uid === active.uid) && typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return before - next.length;
}
function behaviorText(tarinai) {
const b = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
if (!b) return "";
const state = String(tarinai.state || "");
const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04;
const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state);
if (!validByState && !behaviorIsForced(b)) return "";
return composeTarinaiBehaviorText(tarinai, b) || String(b.text || b.reason || b.label || "").trim();
}
function setBehavior(tarinai, fields = {}) {
if (!tarinai || tarinai.dead) return null;
const now = tarinai.world?.time || 0;
const previous = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const previousActionId = previous.actionId || "";
const actionId = fields.actionId || fields.id || previousActionId || tarinai.state || "idle";
const action = fields.action || actionById(actionId) || null;
const need = fields.need || action?.need || previous.need || "fulfill";
const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している";
const reason = fields.reason || fields.text || label;
const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previousActionId === actionId ? previous.startedAt : now);
const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0;
const minDuration = Number(fields.minDuration ?? previous.minDuration ?? Math.max(0.45, lockSeconds * 0.72)) || 0.45;
const target = fields.target !== undefined ? fields.target : tarinai.target;
const forced = Boolean(fields.forced ?? (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(previous) : Boolean(previous.forcedId || previous.forcedRequest)));
const source = forced ? "forced" : (fields.source || previous.source || "need");
const forcedRequest = fields.forcedRequest || previous.forcedRequest || null;
const next = {
actionId,
need,
subNeed: fields.subNeed || action?.subNeed || previous.subNeed || "",
phase: fields.phase || previous.phase || (String(tarinai.state || "") === "idle" ? "idle" : "acting"),
label,
text: fields.text || reason,
reason,
target,
startedAt,
elapsed: Math.max(0, now - startedAt),
minDuration,
lockSeconds,
deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt,
source,
sourceReasonText: fields.sourceReasonText || previous.sourceReasonText || "",
causeText: fields.causeText || previous.causeText || "",
forcedId: fields.forcedId || previous.forcedId || "",
forcedRequest,
priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0,
interruptible: fields.interruptible !== false,
data: { ...(previous.data || {}), ...(fields.data || {}) },
tiedNeeds: fields.tiedNeeds || previous.tiedNeeds || [need],
specId: fields.specId || action?.id || previous.specId || actionId,
specKind: fields.specKind || action?.kind || previous.specKind || "need_action",
};
if (forcedRequest?.source) next.data.forcedSource = forcedRequest.source;
tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1;
next.serial = tarinai.behaviorSerial;
if (typeof setTarinaiBehavior === "function") tarinai.behavior = setTarinaiBehavior(tarinai, next);
else tarinai.behavior = next;
if (target !== undefined) tarinai.target = target;
if (reason && (!tarinai.thought || tarinai.thought === previous.reason || tarinai.thought === previous.text || tarinai.thought === previous.label)) tarinai.thought = reason;
return tarinai.behavior || next;
}
function setBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", options = {}) {
if (!action) return null;
return setBehavior(tarinai, {
actionId: action.id,
action: action,
need: choice.need || action.need,
subNeed: options.subNeed || action.subNeed || "",
label: action.label || options.actionLabel || "行動している",
text: reasonText || action.label,
reason: reasonText || action.label,
target: options.target !== undefined ? options.target : tarinai?.target,
tiedNeeds: choice.tiedNeeds || [choice.need || action.need],
source: options.forced ? "forced" : (options.source || "need"),
forced: Boolean(options.forced),
forcedId: options.forcedId || "",
sourceReasonText: options.sourceReasonText || "",
causeText: options.causeText || "",
forcedRequest: options.forcedRequest || null,
priority: options.priority ?? actionPriority(action),
lockSeconds: options.lockSeconds,
minDuration: options.minDuration,
phase: options.phase || "starting",
specId: action.id,
specKind: action.kind || "need_action",
});
}
function clearBehavior(tarinai, reason = "") {
if (!tarinai) return;
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(tarinai, { reason });
else tarinai.behavior = null;
if (reason) tarinai.thought = reason;
}
function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) {
if (!tarinai || tarinai.dead || !actionId) return false;
const entry = normalizeForcedBehaviorEntry(tarinai, actionId, opts);
let queue = forcedBehaviorQueueOf(tarinai);
if (entry.replaceSameSource) queue = queue.filter(e => !(e && e.id === entry.id && e.source === entry.source));
queue.push(entry);
queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0)));
setForcedBehaviorQueue(tarinai, queue.slice(0, 12));
if (opts.target) tarinai.target = opts.target;
if (opts.applyShock && typeof applyNeedShock === "function") applyNeedShock(tarinai, opts.applyShock, opts.target || null);
return true;
}
function completeForcedBehavior(tarinai, behavior = null, status = "completed") {
if (!tarinai) return false;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const forcedId = b.forcedId || "";
if (!forcedId) return false;
const queue = forcedBehaviorQueueOf(tarinai);
const before = queue.length;
const next = queue.filter(entry => entry && entry.uid !== forcedId);
setForcedBehaviorQueue(tarinai, next);
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return before !== next.length;
}
function retryForcedBehavior(tarinai, behavior = null, reason = "retry") {
if (!tarinai) return false;
const b = behavior || (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const forcedId = b.forcedId || "";
if (!forcedId) return false;
const now = tarinai.world?.time || 0;
const entry = forcedBehaviorQueueOf(tarinai).find(e => e && e.uid === forcedId);
if (!entry) return false;
entry.status = "queued";
entry.lastFailedAt = now;
entry.failReason = reason;
entry.activeSince = 0;
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return true;
}
function processForcedBehaviorQueue(tarinai, world, needs) {
if (!tarinai || tarinai.dead) return false;
const now = world?.time || 0;
let queue = forcedBehaviorQueueOf(tarinai).filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now));
if (!queue.length) {
setForcedBehaviorQueue(tarinai, []);
if (typeof clearTarinaiForcedBehavior === "function") clearTarinaiForcedBehavior(tarinai);
return false;
}
// \u5b9f\u884c\u4e2d\u306e\u5f37\u5236\u884c\u52d5\u306f\u30ad\u30e5\u30fc\u306b\u6b8b\u3057\u3001\u5b8c\u4e86/\u5931\u6557/\u671f\u9650\u5207\u308c\u307e\u3067\u52b9\u679c\u3092\u4fdd\u6301\u3059\u308b\u3002
const activeQueueBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior;
const activeForcedId = activeQueueBehavior?.forcedId || "";
for (const entry of queue) {
if (!entry.uid) entry.uid = `${entry.id}:${entry.source || "external"}:${entry.createdAt || now}:${Math.random().toString(36).slice(2, 8)}`;
if (entry.uid === activeForcedId && behaviorIsForced(activeQueueBehavior) && activeQueueBehavior?.actionId === entry.id) {
markForcedBehaviorActive(tarinai, entry, actionById(entry.id));
} else if (entry.status === "active") {
// behavior \u304c\u5916\u90e8\u8981\u56e0\u3067\u6d88\u3048\u305f\u5834\u5408\u306f\u3001\u671f\u9650\u5185\u306a\u3089\u518d\u8a66\u884c\u3078\u623b\u3059\u3002
entry.status = "queued";
entry.lastFailedAt = now;
}
}
queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0)));
setForcedBehaviorQueue(tarinai, queue);
const activePriority = Number(activeQueueBehavior?.priority ?? actionPriority(activeQueueBehavior?.actionId)) || 0;
const activeForced = behaviorIsForced(activeQueueBehavior);
for (let i = 0; i < queue.length; i++) {
const entry = queue[i];
const action = actionById(entry.id);
if (!action) { queue.splice(i, 1); i--; continue; }
if (entry.status === "active" && activeQueueBehavior?.forcedId === entry.uid) continue;
const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0;
if (activeQueueBehavior && (tarinai.behaviorLockTimer || 0) > 0.08) {
if (activeForced && entryPriority <= activePriority + 8) continue;
if (!entry.interrupt && entryPriority <= activePriority) continue;
if (entryPriority <= activePriority && !entry.force) continue;
}
if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue;
entry.lastTriedAt = now;
entry.attempts = (Number(entry.attempts || 0) || 0) + 1;
const target = resolveForcedBehaviorTarget(tarinai, world, entry, action);
if (target) {
tarinai.target = target;
entry.targetId = behaviorTargetId(target);
}
if ((action.id === "fight_rival" || action.id === "intimidate_enemy" || action.id === "approach_mate" || action.id === "birth_ritual") && !target) {
continue;
}
const need = action.need || entry.need || "safety";
const choice = { need, tiedNeeds: [need], max: Math.max(needThreshold(need, "start"), Number(needs?.[need] || 0) || 0) };
const causeText = entry.causeText || forcedCausePhrase(entry.source || "", entry.reasonText || "");
const reasonText = buildReasonText(need, choice.tiedNeeds, action, tarinai, world, { causeText });
if (activeQueueBehavior && entryPriority > activePriority) {
retryForcedBehavior(tarinai, activeQueueBehavior, "interrupted_by_higher_forced_behavior");
clearBehavior(tarinai);
tarinai.behaviorLockTimer = 0;
if (tarinai.state === "sleep" || tarinai.sleeping) {
tarinai.sleeping = false;
tarinai.sleepSession = null;
}
}
const started = startNeedAction(tarinai, world, choice, action, reasonText, {
forced: true,
forcedId: entry.uid,
forcedReasonText: entry.reasonText || reasonText,
sourceReasonText: entry.reasonText || reasonText,
causeText,
priority: entryPriority,
source: entry.source,
target,
forcedRequest: { ...entry, status: "active", reason: entry.reasonText || reasonText, causeText },
lockSeconds: entry.duration || undefined,
minDuration: entry.minDuration || undefined,
});
if (started) {
markForcedBehaviorActive(tarinai, entry, action);
setForcedBehaviorQueue(tarinai, queue);
return true;
}
}
setForcedBehaviorQueue(tarinai, queue);
return false;
}
function setBehaviorText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, reason = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null, forcedId = "", sourceReasonText = "", forcedReasonText = "", causeText = "" } = {}) {
if (!tarinai) return;
const now = tarinai.world?.time || 0;
const previousBehavior = (typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai.behavior) || {};
const previousActionId = previousBehavior.actionId || "";
const nextNeed = need || previousBehavior.need || "social";
const nextActionId = actionId || previousActionId || tarinai.state || "idle";
const displayReason = reasonText ?? reason;
const nextActionLabel = actionLabel || displayReason || previousBehavior.label || tarinai.state || "行動している";
const action = actionById(nextActionId);
const previousForced = behaviorIsForced(previousBehavior);
const preserveForced = Boolean(previousForced && source === "state" && (!actionId || previousActionId === nextActionId));
let nextReasonText = displayReason || nextActionLabel;
const rawNeedsForText = tarinai.needRaw || tarinai.needs || {};
const existingTiedNeeds = (Array.isArray(previousBehavior.tiedNeeds) && previousBehavior.tiedNeeds.length ? [...previousBehavior.tiedNeeds] : [nextNeed])
.filter((key, idx) => idx === 0 || Number(rawNeedsForText?.[key] || 0) > 0.5);
if (existingTiedNeeds.some(key => key && key !== nextNeed) && !String(nextReasonText).includes("けど、")) {
nextReasonText = buildReasonText(nextNeed, existingTiedNeeds, { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null);
}
const nextForced = forced ?? preserveForced;
const nextSource = nextForced ? "forced" : (source || previousBehavior.source || "state");
const nextForcedId = forcedId || previousBehavior.forcedId || "";
const nextSourceReason = sourceReasonText || forcedReasonText || previousBehavior.sourceReasonText || "";
const nextCauseText = causeText || previousBehavior.causeText || "";
if (nextCauseText) {
nextReasonText = buildReasonText(nextNeed, existingTiedNeeds.length ? existingTiedNeeds : [nextNeed], { ...(action || {}), id: nextActionId, label: nextActionLabel, subNeed: subNeed || action?.subNeed || "" }, tarinai, tarinai.world || null, { causeText: nextCauseText });
}
if (target !== undefined) tarinai.target = target;
setBehavior(tarinai, {
actionId: nextActionId,
action,
need: nextNeed,
subNeed: subNeed || action?.subNeed || previousBehavior.subNeed || "",
label: nextActionLabel,
text: nextReasonText,
reason: nextReasonText,
target: target !== undefined ? target : tarinai.target,
tiedNeeds: existingTiedNeeds,
source: nextSource,
sourceReasonText: nextSourceReason,
causeText: nextCauseText,
forced: nextForced,
forcedId: nextForcedId,
forcedRequest: previousBehavior.forcedRequest || null,
priority: previousBehavior.priority ?? actionPriority(action),
phase,
startedAt: previousBehavior.startedAt || now,
minDuration: previousBehavior.minDuration || 0.8,
lockSeconds: previousBehavior.lockSeconds || 1.0,
});
}
function synchronizeActionTextFromState(tarinai) {
if (!tarinai || tarinai.dead) return;
const state = String(tarinai.state || "");
const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : "";
if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null;
const label = partner?.name ? `${partner.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
setBehaviorText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b", reason: label, target: partner });
return;
}
if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reason: catalogText || "\u98df\u3079\u3066\u3044\u308b" });
return;
}
if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reason: catalogText || "\u55a7\u5629\u3057\u3066\u3044\u308b" });
return;
}
if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "\u9003\u3052\u3066\u3044\u308b", reason: catalogText || "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b" });
return;
}
if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) {
setBehaviorText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: catalogText || "\u5a01\u5687\u3057\u3066\u3044\u308b" });
return;
}
if (state === "sleep" || tarinai.sleeping) {
setBehaviorText(tarinai, { need: "sleep", actionId: (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(tarinai) : tarinai.behavior?.actionId) || "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reason: catalogText || "\u7720\u3063\u3066\u3044\u308b" });
}
}

View file

@ -118,26 +118,11 @@
play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10),
};
},
personalityLabel() {
const tags = getPersonalityTraitTags(this);
return tags.length ? tags.join(" / ") : "\u4e2d\u7acb";
},
personalityTags() { return getPersonalityTraitTags(this); },
adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); },
shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); },
fightWinCount() {
return this.totalFightWins || 0;
},
fightLossCount() {
return this.totalFightLosses || 0;
},
maybeBecomeTimidAfterDamage(cause = "") {
if (this.dead) return false;
const losses = this.totalFightLosses || 0;
@ -272,16 +257,13 @@
const before = this.stress || 0;
if (this.needs && typeof calculateStressFromNeeds === "function") {
const nextSafety = typeof quantizeNeed === "function" ? quantizeNeed((this.needs.safety || 0) + shock) : Math.min(100, (this.needs.safety || 0) + shock);
this.stress = calculateStressFromNeeds({ ...this.needs, safety: nextSafety });
this.stress = typeof applyGroundStressModifier === "function" ? applyGroundStressModifier(this, calculateStressFromNeeds({ ...this.needs, safety: nextSafety })) : calculateStressFromNeeds({ ...this.needs, safety: nextSafety });
}
const gained = Math.max(delta, Math.max(0, (this.stress || 0) - before));
const threshold = opts.threshold ?? 8;
if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2);
return gained;
},
damageCauseLabel(reason = "") { return HEALTH.causeLabel(reason); },
recentDamageCause(maxAge = 10) { return HEALTH.recentDamageCause(this, maxAge); },
rememberDamage(amount, reason = "") { return HEALTH.rememberDamage(this, amount, reason); },
@ -318,22 +300,6 @@
familyJoined() {
return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length);
},
nearestCoParent(maxRange = 220) {
let best = null;
let bestD = maxRange;
for (const o of this.world.tarinai || []) {
if (!o || o === this || o.dead) continue;
if (!(this.world.areCoParents?.(this, o))) continue;
const d = dist(this, o);
if (d < bestD) {
best = o;
bestD = d;
}
}
return best;
},
displayGeneration() {
return this.familyJoined() ? this.generation : null;
},

View file

@ -4,7 +4,7 @@
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_item_effects.js");
const EFFECT_REGISTRY = global.TarinaiEffectRegistry || null;
const EFFECT_REGISTRY = global.TarinaiItemRegistry?.effect || null;
const ITEM_EFFECT_LABELS = Object.freeze(EFFECT_REGISTRY?.labels?.() || {});
const ITEM_EFFECT_COLORS = Object.freeze(EFFECT_REGISTRY?.colors?.() || {});
const RANDOM_EFFECT_POOL = Object.freeze(EFFECT_REGISTRY?.randomPool?.() || [
@ -231,8 +231,8 @@
this.actionCooldowns.food = Math.max(this.actionCooldowns.food || 0, 5.2);
}
this.hunger -= actual;
if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior)?.forced)) {
this.intentLockTimer = Math.min(this.intentLockTimer || 0, 0.25);
if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !(typeof isTarinaiBehaviorForced === "function" ? isTarinaiBehaviorForced(this) : Boolean(this.behavior?.source === "forced" || this.behavior?.forcedId || this.behavior?.forcedRequest))) {
this.behaviorLockTimer = Math.min(this.behaviorLockTimer || 0, 0.25);
}
return actual;
},

View file

@ -0,0 +1,344 @@
"use strict";
// Food/item/structure targeting helpers for Tarinai need actions.
function effectiveFoodTypeForItem(item) {
if (!item) return "";
return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || "");
}
function isDuplicatorFoodSource(item) {
return !!item && item.type === "duplicator";
}
const NORMAL_POOP_MEAL_THRESHOLD = 3;
function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) {
const type = String(foodType || "");
if (type === "laxative" || options.laxative === true) return 0;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 1;
const value = Math.max(0, Number(amount) || 0);
return value / 18;
}
function effectiveFoodRoleMatches(item, role) {
const type = effectiveFoodTypeForItem(item);
if (!type) return false;
if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type));
if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice";
if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type));
return false;
}
function roleMatches(item, role) {
if (!item || item.dead) return false;
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false;
if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role);
if (item.roles?.[role]) return true;
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box";
if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(item.type);
if (role === "grassMaterial") return item.type === "grass";
return false;
}
function foodInteractionPoint(item) {
if (!item) return { x: 0, y: 0 };
if (item.type === "duplicator") return { x: item.x + (item.r || 34) * 0.62, y: item.y + (item.r || 34) * 0.24 };
return { x: item.x, y: item.y };
}
function foodInteractionDistance(tarinai, item) {
const p = foodInteractionPoint(item);
const mouth = tarinai?.mouthPosition?.(item) || { x: tarinai?.x || 0, y: tarinai?.y || 0 };
return distXY(mouth.x, mouth.y, p.x, p.y);
}
function foodInteractionReach(tarinai, item, role = "food") {
if (item?.type === "duplicator") return Math.max(14, (tarinai?.radius || 20) * 0.44 + 5);
if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40);
return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28);
}
function foodPriorityRank(tarinai, item) {
const type = effectiveFoodTypeForItem(item);
if (!type) return -1;
const need = typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need;
const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0;
const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0;
if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6;
if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7;
if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6;
if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7;
if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4;
if (type === "sleep_drug") return need === "sleep" ? 3600 : 2450;
if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000 + Math.max(0, healthNeed - 45) * 5;
if (type === "grass") {
const hunger = Number(tarinai?.hunger || 0) || 0;
const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0;
return 2000 + Math.max(0, hunger - 64) * 18 + Math.max(0, foodNeed - 58) * 12;
}
if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0);
if (type === "food" || type === "ant_corpse") return 3000;
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000;
return -1;
}
function foodPriorityScore(tarinai, item, distance = 0) {
let rank = foodPriorityRank(tarinai, item);
if (rank < 0) return -Infinity;
const hunger = Number(tarinai?.hunger || 0) || 0;
if (item?.type === "zunchi") {
if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity;
if (!tarinai?.isZunchiSlave) rank -= 1400;
if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200;
}
if (item?.type === "duplicator") rank += 85;
return rank - Math.max(0, Number(distance) || 0) * 0.65;
}
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
if (!tarinai || !world) return false;
for (const it of world.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world.items || []) {
if (!it || it.dead || it.type === "zunchi") continue;
if (tarinai.shouldAvoidTarget?.(it)) continue;
const type = effectiveFoodTypeForItem(it);
if (!type) continue;
if (["sweet", "food", "grass", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) {
if (it.type === "grass" && (it.amount || 0) <= 0) continue;
if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue;
return true;
}
}
return false;
}
function zunchiEatingBlocked(tarinai, item, world) {
if (!tarinai || !item || item.type !== "zunchi") return false;
const itemAge = Number(item.age || 0) || 0;
const worldAge = Number.isFinite(item.producedAt) ? Math.max(0, (world?.time || 0) - item.producedAt) : itemAge;
const protectedAge = Math.max(itemAge, worldAge);
const protectedUntil = Number(item.eatProtectedUntil || 0) || 0;
if (protectedUntil > 0 && (world?.time || 0) < protectedUntil) return true;
const foodNeed = Number(tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const hunger = Number(tarinai.hunger || 0) || 0;
// 排泄直後のずんちは観察・接触対象であり、通常食として即消費させない。
// ずんちどれいも数秒は待つ。通常個体は極限飢餓でも最低90秒は食べない。
const emergency = hunger >= 104 || foodNeed >= 94;
const grace = tarinai.isZunchiSlave ? (emergency ? 4 : 18) : (emergency ? 20 : 90);
if (protectedAge < grace) return true;
if (!emergency && !tarinai.isZunchiSlave && item.stage === "fresh" && protectedAge < 150 && foodNeed < 94 && hunger < 104) return true;
return false;
}
function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
let best = null, bestScore = Infinity;
const hunger = Number(tarinai?.hunger || 0) || 0;
const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0;
const foodEmergency = role === "food" && (hunger >= 96 || foodNeed >= 84);
const searchDist = foodEmergency ? Math.max(maxDist, 1180) : maxDist;
const primary = ((role === "food" || role === "drink" || role === "medicine") && world?.nearbyFood)
? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true)
: (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []);
const candidates = Array.isArray(primary) ? primary.slice() : [];
if (foodEmergency && world?.itemsOfType) {
for (const type of ["food", "sweet", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) {
for (const it of world.itemsOfType(type) || []) {
if (it && !it.dead && !candidates.includes(it)) candidates.push(it);
}
}
}
for (const item of candidates) {
if (!roleMatches(item, role)) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item);
if (d > searchDist) continue;
let score = d;
if (role === "food" || role === "medicine") {
const priority = foodPriorityScore(tarinai, item, d);
if (!Number.isFinite(priority)) continue;
score = -priority;
}
if (score < bestScore) { best = item; bestScore = score; }
}
return best;
}
function sleepPlaceAvailableFor(world, tarinai, item) {
if (!world || !tarinai || !item || item.dead) return false;
if (!(item.type === "nest_box" || item.type === "bed")) return false;
if (tarinai.shouldAvoidTarget?.(item)) return false;
const capacity = item.type === "nest_box"
? (world.nestBoxCapacity ? world.nestBoxCapacity(item) : 5)
: 1;
const occupancy = item.type === "nest_box"
? (world.nestBoxOccupants ? world.nestBoxOccupants(item, Infinity).length : world.bedOccupancy?.(item) || 0)
: (world.bedOccupancy?.(item) || 0);
return occupancy < capacity;
}
function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) {
let best = null;
let bestScore = Infinity;
const list = world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || [];
for (const item of list) {
if (!item || item.dead || item.type !== type) continue;
if (!sleepPlaceAvailableFor(world, tarinai, item)) continue;
const d = dist(tarinai, item);
if (d > maxDist) continue;
const comfort = world?.bedComfort ? world.bedComfort(item) : 1;
const score = d - comfort * 18;
if (score < bestScore) { best = item; bestScore = score; }
}
return best;
}
function findNearestSleepPlace(world, tarinai, maxDist = 520) {
return nearestSleepPlaceOfType(world, tarinai, "nest_box", maxDist)
|| nearestSleepPlaceOfType(world, tarinai, "bed", maxDist)
|| findOwnedStructure(world, tarinai, "grass_bed", maxDist)
|| findUnownedStructure(world, tarinai, "grass_bed", maxDist)
|| null;
}
function findNearbyDanger(world, tarinai, maxDist = 150) {
const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null;
const item = findNearestItemWithRole(world, tarinai, "danger", maxDist);
if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item;
return ant || item;
}
function activePanicBreaker(tarinai, world, maxDist = 260) {
const breaker = tarinai?.lastNeedShockBreaker || null;
if (!breaker || !tarinai) return null;
const cause = String(tarinai.lastPanicCause || "");
// Relation/death shocks are intentionally not active dangers. Keeping them as
// breakers made old panic spread and persist after the original event ended.
if (cause === "relation_death" || cause === "pending_relation_death") {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (breaker.dead) {
tarinai.lastNeedShockBreaker = null;
return null;
}
const now = Number(world?.time || 0) || 0;
if (Number.isFinite(tarinai.lastPanicAt) && now - tarinai.lastPanicAt > 8.0) {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (!Number.isFinite(breaker.x) || !Number.isFinite(breaker.y)) return null;
const d = distXY(tarinai.x, tarinai.y, breaker.x, breaker.y);
const isDangerItem = roleMatches(breaker, "danger") || breaker.kind === "ant" || breaker.type === "ant";
const isThreatTarinai = breaker !== tarinai && !breaker.dead && breaker.id && (breaker.fightTimer > 0.04 || breaker.intimidateTimer > 0.04 || breaker.fightMochiTimer > 0.04);
const allowedRange = isDangerItem ? maxDist : (isThreatTarinai ? maxDist * 0.75 : maxDist * 0.45);
if (d <= allowedRange) return breaker;
tarinai.lastNeedShockBreaker = null;
return null;
}
function survivalNeedShouldCancelPanic(tarinai, needs) {
if (!tarinai) return false;
const food = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const sleep = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
const health = Number(needs?.health ?? tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0;
return food >= 72 || (tarinai.hunger || 0) >= 82 || sleep >= 82 || (tarinai.energy || 100) <= 14 || health >= 88;
}
function evaluateConflictUrge(tarinai, world, maxDist = 96) {
if (!tarinai || tarinai.dead || !world) return null;
if ((tarinai.fightCooldown || 0) > 0.04 || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.postBirthPeaceTimer || 0) > 0.04) return null;
const p = tarinai.personalityProfile?.() || tarinai.currentPersonality || {};
const aggression = Number(tarinai.currentPersonality?.aggression ?? p.fight ?? 0) || 0;
const battleDrug = (tarinai.fightMochiTimer || 0) > 0.04 || tarinai.hasFightMochiEffect?.();
const near = typeof world.nearbyTarinai === "function" ? world.nearbyTarinai(tarinai.x, tarinai.y, maxDist) : (world.tarinai || []);
let best = null;
for (const other of near || []) {
if (!other || other === tarinai || other.dead) continue;
if (!world.canFightPair?.(tarinai, other)) continue;
if ((other.fightCooldown || 0) > 0.04 || (other.postBirthPeaceTimer || 0) > 0.04) continue;
if (world.areParentChild?.(tarinai, other) || world.areCoParents?.(tarinai, other)) continue;
const d = Math.max(1, dist(tarinai, other));
if (d > maxDist) continue;
const rel = tarinai.relationTo?.(other.id) || {};
const relOther = other.relationTo?.(tarinai.id) || {};
const mixedZunchi = !!tarinai.isZunchiSlave !== !!other.isZunchiSlave;
const otherBattleDrug = (other.fightMochiTimer || 0) > 0.04 || other.hasFightMochiEffect?.();
const closePressure = clamp(1 - d / maxDist, 0, 1) * 18;
const stressPressure = clamp(((tarinai.stress || 0) - 52) / 42, 0, 1) * 18;
const angerPressure = (tarinai.type === "angry" ? 12 : 0) + ((other.type === "angry" || otherBattleDrug) ? 8 : 0);
const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 22) / 90, 0, 1) * 14;
const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 26;
const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 22 : 0;
const drugBonus = battleDrug ? 56 : 0;
const zunchiBonus = mixedZunchi ? 14 : 0;
const personalityBonus = clamp(aggression, -1, 1) * 10;
const score = closePressure + stressPressure + angerPressure + fearPressure + drugBonus + zunchiBonus + personalityBonus - affinityBrake - calmBrake;
if (!best || score > best.score) {
best = {
target: other,
score,
forced: !!battleDrug || !!otherBattleDrug,
defensive: mixedZunchi || (rel.fear || 0) > 35,
reason: mixedZunchi ? "\u8fd1\u304f\u306e\u76f8\u624b\u304c\u843d\u3061\u7740\u304b\u306a\u3044" : battleDrug ? "\u304d\u305a\u3064\u304d\u9905\u3067\u6c17\u304c\u7acb\u3063\u3066\u3044\u308b" : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u8fd1\u3044",
};
}
}
return best && best.score > (best.forced ? 4 : 12) ? best : null;
}
function conflictTargetFor(tarinai, world, minScore = 34) {
const evaluated = evaluateConflictUrge(tarinai, world, 118);
if (evaluated && evaluated.score >= minScore) return evaluated;
const id = tarinai?.conflictTargetId || "";
const target = id ? world?.liveTarinaiById?.(id) : null;
if (!target || !world?.canFightPair?.(tarinai, target)) return null;
const d = dist(tarinai, target);
const carried = Math.max(0, Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, d - 70) * 0.28;
return carried >= minScore ? { target, score: carried, forced: (tarinai.fightMochiTimer || 0) > 0.04, defensive: false, reason: "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b" } : null;
}
function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) {
return findNearestItemWithRole(world, tarinai, materialRole, maxDist);
}
function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) {
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue;
if (type && item.type !== type) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) {
let best = null, bestD = maxDist;
for (const item of world?.items || []) {
if (!item?.isStructure || item.dead || item.ownerId) continue;
if (type && item.type !== type) continue;
if (tarinai?.shouldAvoidTarget?.(item)) continue;
const d = dist(tarinai, item);
if (d < bestD) { best = item; bestD = d; }
}
return best;
}
function consumeGrassMaterial(material, stages = 1) {
if (!material) return false;
const steps = Math.max(1, Math.floor(Number(stages) || 1));
const consumed = window.TarinaiGrass?.regress?.(material, steps) ?? 0;
if (!(consumed > 0)) return false;
material.eatenAmount = (material.eatenAmount || 0) + consumed;
material.world?.markTerrainDirty?.("grass-material-consumed");
return true;
}
function moveToOrUse(t, target, state = "seek_food", reason = "") {
if (!target) return false;
t.setActionState?.(state, { target, reason, sleeping: false });
return true;
}

298
js/tarinai_needs_core.js Normal file
View file

@ -0,0 +1,298 @@
"use strict";
const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"];
const TARINAI_NEED_LABELS = {
food: "\u6442\u990c",
sleep: "\u7761\u7720",
health: "\u5065\u5eb7",
safety: "\u5b89\u5168",
social: "\u95a2\u4fc2",
fulfill: "\u5145\u8db3",
};
const TARINAI_NEED_PHRASES = {
food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b",
sleep: "\u306d\u3080\u3044",
health: "\u8abf\u5b50\u304c\u60aa\u3044",
safety: "\u3042\u3076\u306a\u3044",
social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b",
fulfill: "\u306a\u306b\u304b\u6e80\u305f\u3055\u308c\u306a\u3044",
};
const TARINAI_NEED_THRESHOLDS = {
food: { start: 64, continue: 20 },
sleep: { start: 62, continue: 30 },
health: { start: 54, continue: 32 },
safety: { start: 42, continue: 20 },
social: { start: 52, continue: 30 },
fulfill: { start: 48, continue: 26 },
};
const TARINAI_NEED_SATISFACTION_DECAY = {
food: 0.85,
sleep: 0.75,
health: 0.65,
safety: 1.10,
social: 1.05,
fulfill: 1.15,
};
const TARINAI_ACTION_LOCK_SECONDS = {
eat_food: 0.85,
drink_water: 1.8,
sleep_in_bed: 18.0,
sleep_build_grass_bed: 10.0,
sleep_anywhere: 12.0,
use_medicine: 2.4,
rest_to_recover: 6.0,
sunbath: 10.5,
panic_escape: 2.1,
flee: 1.7,
hide_at_owned_structure: 6.4,
approach_friend: 6.4,
approach_parent_or_child: 7.0,
approach_mate: 5.6,
fight_rival: 3.6,
intimidate_enemy: 1.15,
play: 8.2,
build_grass_bed: 10.0,
build_plushie: 10.0,
return_owned_structure: 6.4,
use_plushie: 7.4,
wander_lightly: 4.2,
};
const TARINAI_ACTION_PRIORITY = {
fight_rival: 56,
intimidate_enemy: 42,
panic_escape: 94,
flee: 92,
hide_at_owned_structure: 90,
use_medicine: 86,
rest_to_recover: 84,
sunbath: 91,
sleep_in_bed: 86,
sleep_build_grass_bed: 85,
sleep_anywhere: 84,
birth_ritual: 72,
approach_mate: 68,
approach_parent_or_child: 66,
approach_friend: 66,
eat_food: 50,
drink_water: 48,
build_grass_bed: 30,
build_plushie: 28,
use_plushie: 24,
return_owned_structure: 22,
play: 20,
wander_lightly: 10,
};
function actionPriority(actionOrId) {
const id = typeof actionOrId === "string" ? actionOrId : actionOrId?.id;
if (!id) return 0;
return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0;
}
const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"];
const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = {
fight_mochi: 180,
love_mochi: 150,
sleep_drug: 145,
drug: 140,
user: 200,
external: 130,
};
function createDefaultNeeds() {
return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
}
function quantizeNeed(value) {
return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10));
}
function getNeedDisplayValue(value) {
return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10)));
}
function needPressure(value) {
const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100;
return x * x * 100;
}
function groundStressMultiplierFor(tarinai) {
const mult = Number(tarinai?.world?.groundStressMultiplier?.());
return Number.isFinite(mult) && mult > 0 ? mult : 1;
}
function applyGroundStressModifier(tarinai, nextStress) {
const next = clamp(Number(nextStress) || 0, 0, 130);
const before = Number(tarinai?.stress);
if (!Number.isFinite(before) || next <= before) return next;
return clamp(before + (next - before) * groundStressMultiplierFor(tarinai), 0, 130);
}
function calculateStressFromNeeds(needs) {
const weightedLinear =
(Number(needs.food) || 0) * 0.09 +
(Number(needs.sleep) || 0) * 0.11 +
(Number(needs.health) || 0) * 0.22 +
(Number(needs.safety) || 0) * 0.26 +
(Number(needs.social) || 0) * 0.07 +
(Number(needs.fulfill) || 0) * 0.13;
const peakNeed = Math.max(0, ...TARINAI_NEED_KEYS.map(key => Number(needs?.[key] || 0) || 0));
const peakPressure = Math.max(0, peakNeed - 62) * 0.10;
const quadraticPressure =
needPressure(needs.food) * 0.10 +
needPressure(needs.sleep) * 0.12 +
needPressure(needs.health) * 0.25 +
needPressure(needs.safety) * 0.30 +
needPressure(needs.social) * 0.08 +
needPressure(needs.fulfill) * 0.15;
return clamp(
weightedLinear +
peakPressure +
quadraticPressure * 0.35,
0,
130
);
}
function applyNeedShock(tarinai, deltas = {}, breaker = null) {
if (!tarinai || tarinai.dead) return false;
tarinai.needShock = tarinai.needShock || {};
for (const key of TARINAI_NEED_KEYS) {
const delta = Number(deltas[key] || 0) || 0;
if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta);
else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta);
}
if (breaker) tarinai.lastNeedShockBreaker = breaker;
if (tarinai.needs && typeof calculateStressFromNeeds === "function") {
const projected = { ...tarinai.needs };
for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0));
tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(projected));
}
return true;
}
function applyNeedSatisfaction(tarinai, deltas = {}, source = "") {
if (!tarinai || tarinai.dead) return false;
tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds();
tarinai.lastSatisfiedAt = tarinai.lastSatisfiedAt || {};
const now = tarinai.world?.time || 0;
let changed = false;
for (const key of TARINAI_NEED_KEYS) {
const amount = Math.max(0, Number(deltas[key] || 0) || 0);
if (amount <= 0) continue;
tarinai.needSatisfaction[key] = clamp((tarinai.needSatisfaction[key] || 0) + amount, 0, 88);
tarinai.lastSatisfiedAt[key] = now;
changed = true;
}
if (source) tarinai.lastSatisfactionSource = source;
return changed;
}
function applyNeedRelief(tarinai, deltas = {}) {
if (!tarinai || tarinai.dead) return false;
const shockDeltas = {};
const satisfactionDeltas = {};
let hasShock = false;
let hasSatisfaction = false;
for (const key of TARINAI_NEED_KEYS) {
const delta = Number(deltas[key] || 0) || 0;
if (delta < 0) {
satisfactionDeltas[key] = -delta;
hasSatisfaction = true;
} else if (delta > 0) {
shockDeltas[key] = delta;
hasShock = true;
}
}
if (hasSatisfaction) applyNeedSatisfaction(tarinai, satisfactionDeltas, "relief");
if (hasShock) applyNeedShock(tarinai, shockDeltas, null);
return hasSatisfaction || hasShock;
}
function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") {
let actions = null;
try { actions = TARINAI_ACTIONS; } catch (err) { actions = null; }
if (!tarinai || tarinai.dead || !Array.isArray(actions)) return false;
const world = tarinai.world || globalThis.world || null;
const aggression = Number(tarinai.currentPersonality?.aggression || tarinai.personalityProfile?.().fight || 0);
const fightLikeTarget = breaker && breaker.type !== "firecracker" && breaker.kind !== "ant" && breaker.id && world?.canFightPair?.(tarinai, breaker);
const fightAction = actions.find(a => a.id === "fight_rival");
if (fightLikeTarget && aggression > 0.62 && fightAction) {
tarinai.conflictTargetId = breaker.id;
tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72);
const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 };
const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
}
const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee");
if (!panicAction) return false;
const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 };
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
return false;
}
function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") {
if (!tarinai || tarinai.dead) return false;
const before = tarinai.previousNeeds || createDefaultNeeds();
const projected = { ...(tarinai.needs || createDefaultNeeds()) };
for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0));
const safetyDelta = (projected.safety || 0) - (before.safety || 0);
const healthDelta = (projected.health || 0) - (before.health || 0);
const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0);
const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50;
if (!shockTriggered) return false;
if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false;
return startNeedDrivenEmergencyReaction(tarinai, breaker, reason);
}
function needThreshold(need, kind = "start") {
return TARINAI_NEED_THRESHOLDS[need]?.[kind] ?? (kind === "continue" ? 30 : 50);
}
function actionLockSeconds(action, tarinai = null) {
const base = Number(action?.lockSeconds ?? TARINAI_ACTION_LOCK_SECONDS[action?.id] ?? 2.0) || 2.0;
const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${action?.id || "idle"}`;
const jitter = typeof stableUnit === "function" ? (stableUnit(key, "action-lock") - 0.5) * 0.18 : (Math.random() - 0.5) * 0.12;
return Math.max(0.5, base * (1 + jitter));
}
function scoreNeedForAction(needs, tarinai, world, need) {
const value = Number(needs?.[need]) || 0;
let score = value;
if (need === "safety") score += 14;
else if (need === "health") score += 8;
else if (need === "food") {
const hunger = Number(tarinai?.hunger || 0) || 0;
score += 3 + Math.max(0, hunger - 70) * 0.55 + Math.max(0, hunger - 92) * 0.95;
}
if ((typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(tarinai) : tarinai?.behavior?.need) === need) score += 10;
const cooldown = Number(tarinai?.actionCooldowns?.[need] || 0) || 0;
if (cooldown > 0) score -= Math.min(22, cooldown * 2.8);
const nowBucket = Math.floor((world?.time || 0) / 8);
const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${need}:${nowBucket}`;
const noise = typeof stableUnit === "function" ? (stableUnit(key, "need-score") - 0.5) * 3.2 : (Math.random() - 0.5) * 2.0;
return score + noise;
}
function chooseTopNeedRandom(needs, tarinai = null, world = null) {
const scores = {};
for (const key of TARINAI_NEED_KEYS) scores[key] = scoreNeedForAction(needs, tarinai, world, key);
const max = Math.max(...TARINAI_NEED_KEYS.map(key => scores[key]));
const tiedNeeds = TARINAI_NEED_KEYS.filter(key => max - scores[key] <= 1.5);
const need = TARINAI_NEED_PRIORITY.find(key => tiedNeeds.includes(key)) || tiedNeeds[0] || "fulfill";
return { need, tiedNeeds, max: Number(needs?.[need]) || 0, score: max, scores };
}

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,21 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_render.js");
function shouldShowTarinaiDecor(tarinai) {
const worldRef = tarinai?.world;
if (!tarinai || !worldRef) return true;
if (worldRef.selected === tarinai || tarinai.favorite) return true;
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.02 || (tarinai.pokeFlashTimer || 0) > 0.03) return true;
if ((tarinai.focusPulseTimer || 0) > 0.04 || tarinai.state === "cursor_friend" || tarinai.state === "cursor_enemy") return true;
const tier = window.TarinaiPerf?.renderQualityTier?.() || "high";
if (tier === "low") return false;
if (tier === "mid") {
const p = worldRef.pointer;
return Boolean(p?.inside && typeof distXY === "function" && distXY(tarinai.x, tarinai.y, p.x, p.y) < tarinai.radius * 2.0);
}
return true;
}
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
drawSunbathPose(ctx, t, lighting = null) {
if (this.dead) return true;
@ -85,7 +100,7 @@
if (nestHideAlpha <= 0.01) return true;
const lightState = lighting || getLightingState(this.world);
const showDecor = true || this.world.selected === this;
const showDecor = shouldShowTarinaiDecor(this);
if (showDecor) {
const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8);
drawProjectedShadow(ctx, drawX, drawY + drawH * 0.30, drawW * 0.58, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.90 * nestHideAlpha });
@ -134,7 +149,7 @@
this.visibleSpriteId = "";
this.spriteLockUntil = 0;
}
const showDecor = true || this.world.selected === this;
const showDecor = shouldShowTarinaiDecor(this);
if (sunbathActive) this.preloadSunbathSprites?.();
let sprite = this.spriteId();
if (!sunbathActive && String(sprite || "").startsWith("sunbath")) {

View file

@ -0,0 +1,121 @@
"use strict";
// Social, panic, fight, mate behavior runtime ticks.
function mateTargetFor(t, world, maxDist = 520) {
if (!t || !world) return null;
const current = currentBehaviorTarget(t) || t.target;
const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04;
if (ok(current) && dist(t, current) <= maxDist + 120) return current;
return world.nearestOther?.(t, maxDist, ok) || null;
}
function triggerFriendContact(t, other, world, dt = 0) {
if (!t || !other || !world) return false;
const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85;
t.adjustRelation?.(other, amount, -0.05, "friend_contact");
other.adjustRelation?.(t, amount * 0.78, -0.04, "friend_contact");
t.loneliness = clamp((t.loneliness || 0) - 8, 0, 110);
if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -16, fulfill: -3 });
if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("\u306f\u3046", 2.2, "rgba(92,132,78,0.78)");
t.setActionState?.("idle", { target: other, reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, sleeping: false });
if (typeof setBehaviorText === "function") setBehaviorText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "\u4ef2\u9593\u3068\u4e00\u7dd2\u306b\u3044\u308b", reason: `${other.name || "\u4ef2\u9593"}\u3068\u4e00\u7dd2\u306b\u3044\u308b`, target: other, phase: "perform", source: "behavior" });
return "finished";
}
function updateSocialContactBehavior(t, world, dt, mode = "bond") {
if (!t || !world) return false;
let other = currentBehaviorTarget(t) || t.target || null;
if (!other || other.dead || other === t) {
other = mode === "family" ? t.parentToFollow?.() : (t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 560) || world.nearestOther?.(t, 520));
}
if (!other || other.dead || other === t) return false;
const near = dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12);
if (!near) return moveToOrUse(t, other, mode === "family" ? "follow_parent" : "seek_friend", mode === "family" ? "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b" : "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
const conflict = conflictTargetFor(t, world, 30);
const forcedConflict = (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04;
const socialPartsForContact = t.socialReasonParts || {};
const conflictDominant = Number(socialPartsForContact.conflict || 0) >= Math.max(Number(socialPartsForContact.bond || 0), Number(socialPartsForContact.mate || 0), Number(socialPartsForContact.family || 0)) + 8;
if (forcedConflict || (conflict?.target === other && conflict.score >= 34 && conflictDominant)) {
return updateFightBehavior(t, world, dt) || true;
}
const mate = mateTargetFor(t, world, 150);
const parts = t.socialReasonParts || {};
const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.actionId;
const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId;
const mateIntent = mode === "mate" || selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || (t.loveMochiTimer || 0) > 0.04 || (other.loveMochiTimer || 0) > 0.04;
const mateDominant = Number(parts.mate || 0) >= Math.max(Number(parts.bond || 0), Number(parts.conflict || 0)) + 10;
if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10 && (mateIntent || mateDominant)) {
if (world.startBirthRitual?.(t, other)) return "finished";
}
return triggerFriendContact(t, other, world, dt);
}
function updateMateBehavior(t, world, dt) {
const other = mateTargetFor(t, world, 560);
if (!other) return false;
const d = dist(t, other);
if (d <= Math.max(50, (t.radius || 20) + (other.radius || 20) + 14)) {
const otherWantsMate = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(other) : other.behavior?.actionId) === "approach_mate" || (other.loveMochiTimer || 0) > 0.04 || (other.reproductionTimer || 0) <= 4;
const selfForced = isCurrentBehaviorForced(t) || (t.loveMochiTimer || 0) > 0.04;
if ((otherWantsMate || selfForced || (t.reproductionTimer || 0) <= 4) && world.startBirthRitual?.(t, other)) return "finished";
moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t);
return true;
}
return moveToOrUse(t, other, "seek_friend", "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b");
}
function updateFightBehavior(t, world, dt) {
if (!t || !world) return false;
if ((t.fightTimer || 0) > 0.04) {
const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.liveTarinaiById?.(t.fightTargetId) : null);
t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b", sleeping: false });
return true;
}
const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || isCurrentBehaviorForced(t) ? 24 : 42);
const current = currentBehaviorTarget(t);
const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, isCurrentBehaviorForced(t) ? 760 : 360));
if (!isLiveTarinaiEntity(rival)) return false;
t.conflictTargetId = rival.id;
if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u306b\u5411\u304b\u3063\u3066\u3044\u308b");
if (candidate?.forced || isCurrentBehaviorForced(t) || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival);
world.startConflict?.(t, rival);
return true;
}
function updatePanicBehavior(t, world, dt, needs) {
const breakerDanger = activePanicBreaker(t, world, 260);
const nearbyDanger = findNearbyDanger(world, t, 260) || null;
const realDanger = breakerDanger || nearbyDanger || null;
const currentTarget = currentBehaviorTarget(t);
const safety = Number(needs?.safety || 0) || 0;
const now = Number(world?.time || 0) || 0;
if (!Number.isFinite(t.panicStartedAt) || t.state !== "panic") t.panicStartedAt = now;
// Survival needs must be able to break panic. Otherwise a stale fear state
// can suppress eating/sleeping long enough to kill the colony.
if (!realDanger && survivalNeedShouldCancelPanic(t, needs)) {
t.fearTimer = 0;
t.lastNeedShockBreaker = null;
return "finished";
}
const panicAge = Math.max(0, now - (Number(t.panicStartedAt) || now));
const hardStop = Number(t.panicHardStopAt || 0) || 0;
if (!realDanger && (panicAge >= 7.5 || (hardStop > 0 && now >= hardStop))) {
t.fearTimer = 0;
t.lastNeedShockBreaker = null;
return "finished";
}
if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.12) return "finished";
const danger = realDanger || currentTarget;
if (t.state !== "panic" || !t.target || (t.target.dead && !Number.isFinite(t.target.x))) {
t.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "\u6016\u304f\u3066\u9003\u3052\u3066\u3044\u308b", wake: true, sleeping: false });
}
if (realDanger || (t.defeatedTimer || 0) > 0.04) t.fearTimer = Math.max(t.fearTimer || 0, 0.26);
if (!realDanger && safety < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.18) return "finished";
return true;
}

View file

@ -163,7 +163,9 @@
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
const mateIntent = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.id) === "approach_mate";
const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId;
const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId;
const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug;
if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) {
this.world.startBirthRitual(this, o);
}
@ -294,7 +296,7 @@
if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) {
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander");
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
this.intentLockTimer = 0;
this.behaviorLockTimer = 0;
this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f");
return;
}
@ -306,9 +308,11 @@
? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62))
: (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20));
if (reachedSleepFurniture) {
this.target.use?.(this, this.world);
this.startSleeping?.(this.target, this.target.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u308b\u5834\u6240\u306b\u7740\u3044\u305f\u306e\u3067\u4f11\u3093\u3067\u3044\u308b");
if (this.target.type === "nest_box") this.enterNestBox(this.target, dt);
const sleepTarget = this.target;
const isEasyBed = sleepTarget?.type === "grass_bed";
sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined);
this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt);
return;
}
}

View file

@ -58,6 +58,10 @@ TEXT_CATALOG.stateLabels = Object.freeze({
seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059",
seek_material: "\u6750\u6599\u3092\u63a2\u3059",
seek_enemy: "\u76f8\u624b\u3078\u5411\u304b\u3046",
wander: "\u79fb\u52d5\u4e2d",
build: "\u4f5c\u6210\u4e2d",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
system: "\u305a\u3093\u3061\u75c5",
@ -77,12 +81,40 @@ TEXT_CATALOG.stateLabels = Object.freeze({
frozen: "\u51b7\u51cd\u4e2d",
});
TEXT_CATALOG.behaviorLabels = Object.freeze({
seek_material: "材料を探している",
build_structure: "作成している",
build: "作成している",
seek_enemy: "気になる相手へ向かっている",
wander_lightly: "少し移動している",
eat_food: "食事をしている",
drink_water: "水を飲んでいる",
sleep_in_bed: "寝床で休んでいる",
sleep_anywhere: "休んでいる",
intimidate_enemy: "威嚇している",
panic_escape: "逃げている",
});
TEXT_CATALOG.behaviorLabel = function behaviorLabelFromCatalog(value = "") {
const key = String(value || "");
return TEXT_CATALOG.behaviorLabels?.[key] || TEXT_CATALOG.stateLabels?.[key] || key;
};
TEXT_CATALOG.cleanBehaviorText = function cleanBehaviorText(text = "", fallbackKey = "") {
const raw = String(text || "").trim();
const key = String(fallbackKey || raw || "").trim();
const mapped = TEXT_CATALOG.behaviorLabel(key);
if (!raw) return mapped || "";
if (/^[a-z][a-z0-9_:-]*$/i.test(raw)) return TEXT_CATALOG.behaviorLabel(raw);
return raw;
};
TEXT_CATALOG.targetLabelOverrides = Object.freeze({
system: "\u305a\u3093\u3061\u75c5",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
});
// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel().
TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides;
TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) {
@ -107,8 +139,12 @@ TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (!t) return "\u306a\u3057";
if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim();
if (behaviorText) return behaviorText;
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
const behaviorLabel = typeof globalThis.behaviorText === "function"
? globalThis.behaviorText(t)
: String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (behaviorState?.text || behaviorState?.reason || "")).trim();
const cleanedBehaviorLabel = TEXT_CATALOG.cleanBehaviorText(behaviorLabel, behaviorState?.actionId || t.state || "");
if (cleanedBehaviorLabel) return cleanedBehaviorLabel;
const target = TEXT_CATALOG.targetLabel(t.target);
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";

View file

@ -15,6 +15,8 @@ const ui = {
resetBtn: document.getElementById("resetBtn"),
creditsDialog: document.getElementById("creditsDialog"),
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
panel: document.querySelector(".panel"),
panelQuickTabs: document.getElementById("panelQuickTabs"),
toolPalette: document.getElementById("toolPalette"),
toolCard: document.getElementById("toolCard"),
selectedCard: document.getElementById("selectedCard"),
@ -31,6 +33,8 @@ const ui = {
statPop: document.getElementById("statPop"),
statDead: document.getElementById("statDead"),
statColonyMood: document.getElementById("statColonyMood"),
statGroundType: document.getElementById("statGroundType"),
groundTypeBtn: document.getElementById("groundTypeBtn"),
colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"),
@ -50,6 +54,7 @@ const uiCache = {
stats: {},
selectedSnapshot: "",
selectedEmpty: false,
showSelectedEmpty: false,
selectedCollapsedCategories: new Set(),
logPushEnabled: false,
logPushKinds: new Set(["death", "accident", "birth"]),

View file

@ -6,6 +6,7 @@ function bindUI() {
syncLogPushControls();
syncAudioControls();
applyToolTips();
window.bindGroundUI?.();
renderEcologyCards();
window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
@ -15,9 +16,71 @@ function bindUI() {
return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(world.tool));
}
function clickedInsideGameOrToolUi(target) {
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target));
return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target) || ui.panelQuickTabs?.contains?.(target));
}
function panelSectionForTab(tab = "") {
if (tab === "selected") return ui.selectedCard;
if (tab === "tools") return ui.toolCard;
if (tab === "colony") return document.querySelector(".colony-card");
if (tab === "events") return document.querySelector(".log-card");
return null;
}
function setActivePanelTab(tab = "") {
for (const btn of ui.panelQuickTabs?.querySelectorAll("[data-panel-tab]") || []) {
btn.classList.toggle("active", btn.dataset.panelTab === tab);
}
}
function scrollPanelToTab(tab = "") {
const panel = ui.panel;
if (!panel) return false;
if (tab === "selected" && (!world.selected || world.selected.dead || !world.tarinai.includes(world.selected))) {
selectTool("observe");
uiCache.showSelectedEmpty = true;
uiCache.selectedEmpty = false;
renderSelected();
showToast("たりないが未選択です。観察モードにしました。");
}
const target = panelSectionForTab(tab);
if (!target) return false;
target.classList?.remove?.("hidden");
const tabsH = ui.panelQuickTabs?.offsetHeight || 0;
const top = Math.max(0, target.offsetTop - tabsH - 8);
panel.scrollTo({ top, behavior: "smooth" });
setActivePanelTab(tab);
return true;
}
ui.panelQuickTabs?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-panel-tab]");
if (!btn) return;
e.preventDefault();
audio.uiClick?.();
scrollPanelToTab(btn.dataset.panelTab || "");
});
ui.panel?.addEventListener("scroll", () => {
const panel = ui.panel;
if (!panel || !ui.panelQuickTabs) return;
const tabsH = ui.panelQuickTabs.offsetHeight || 0;
const entries = [
["selected", ui.selectedCard],
["tools", ui.toolCard],
["colony", document.querySelector(".colony-card")],
["events", document.querySelector(".log-card")],
].filter(([, el]) => el && !el.classList.contains("hidden"));
let active = entries[0]?.[0] || "";
const y = panel.scrollTop + tabsH + 18;
for (const [key, el] of entries) {
if (el.offsetTop <= y) active = key;
}
setActivePanelTab(active);
}, { passive: true });
syncToolSizeBadges();
setActivePanelTab(ui.selectedCard?.classList.contains("hidden") ? "tools" : "selected");
for (const toggle of document.querySelectorAll(".panel-card-toggle")) {
toggle.addEventListener("click", () => {
const card = toggle.closest(".collapsible-card");
@ -38,7 +101,6 @@ function bindUI() {
uiCache.lastChartDrawAt = 0;
renderStats();
});
syncToolSizeBadges();
ui.logPushEnabled?.addEventListener("change", () => {
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
audio.notify?.();
@ -147,6 +209,7 @@ function bindUI() {
});
ui.selectedCloseBtn?.addEventListener("click", () => {
world.selected = null;
uiCache.showSelectedEmpty = false;
uiCache.selectedSnapshot = "";
uiCache.selectedEmpty = false;
renderSelected();
@ -279,556 +342,10 @@ function bindUI() {
chainScroll(ui.archiveContent);
transferEdgeScroll(ui.log, rightPanel);
const inputModeManager = window.TarinaiInputMode;
const setMobileInputMode = (mode = "auto") => {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode;
ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode));
document.body.classList.toggle("mobile-mode-camera", mode === "camera");
document.body.classList.toggle("mobile-mode-tool", mode === "tool");
document.body.classList.toggle("mobile-mode-family", mode === "family");
};
const updateTouchMobileUiClass = () => {
const enabled = Boolean(inputModeManager?.refresh?.().touchFirst);
document.body.classList.toggle("touch-mobile-ui", enabled);
if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled;
};
const mobileInputMode = () => inputModeManager?.currentMode || uiCache.mobileInputMode || "auto";
updateTouchMobileUiClass();
window.addEventListener("resize", updateTouchMobileUiClass, { passive: true });
window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass);
setMobileInputMode(uiCache.mobileInputMode || "auto");
ui.mobileModeControls?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mobile-mode]");
if (!btn) return;
audio.uiClick?.();
setMobileInputMode(btn.dataset.mobileMode || "auto");
showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`);
});
const touchPoint = (touches, index = 0) => {
const t = touches?.[index];
if (!t) return null;
return { clientX: t.clientX, clientY: t.clientY };
};
const touchCenter = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a) return null;
if (!b) return a;
return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 };
};
const touchDistance = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a || !b) return 0;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
};
const screenToWorldClient = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 };
};
const clientToWorldRaw = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const scale = world.viewScale ? world.viewScale() : 1;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const rawX = (sx - off.x) / scale + (world.cameraX || 0);
const rawY = (sy - off.y) / scale + (world.cameraY || 0);
return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h };
};
const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => {
if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
if (!world.setFieldZoom(nextZoom)) return false;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
world.cameraX = focus.x - (sx - off.x) / scale;
world.cameraY = focus.y - (sy - off.y) / scale;
world.clampCamera?.();
return true;
};
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
}
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) {
window.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002");
uiCache.grabOrigin = null;
return true;
}
uiCache.grabOrigin = null;
return false;
}
const beginTouchGrab = (p, e) => {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) return false;
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.touches?.[0]?.clientX || 0;
uiCache.grabStartY = e.touches?.[0]?.clientY || 0;
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
uiCache.touchMode = "grab";
renderStats();
return true;
};
canvas?.addEventListener("touchstart", (e) => {
if (!e.touches?.length) return;
e.preventDefault();
uiCache.touchMoved = false;
const mode = mobileInputMode();
if ((mode === "camera" || mode === "family") && e.touches.length === 1) {
const t = e.touches[0];
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchMode = "cameraPan";
return;
}
if (e.touches.length >= 2) {
const c = touchCenter(e.touches);
uiCache.touchMode = "pinchZoom";
uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches));
uiCache.pinchStartZoom = world.fieldZoom || 1;
uiCache.pinchLastCenterX = c?.clientX || 0;
uiCache.pinchLastCenterY = c?.clientY || 0;
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchLastWorldX = p.x;
uiCache.touchLastWorldY = p.y;
if (mode === "tool" && world.tool === "observe") {
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "water_hose" && p.inside) {
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = t.clientX;
uiCache.hoseStartY = t.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
uiCache.touchMode = "hose";
render();
return;
}
if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return;
uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap";
}, { passive: false });
canvas?.addEventListener("touchmove", (e) => {
if (!e.touches?.length || !uiCache.touchMode) return;
e.preventDefault();
if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) {
const c = touchCenter(e.touches);
const distNow = Math.max(1, touchDistance(e.touches));
const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow);
setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio);
const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX);
const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY);
uiCache.pinchLastCenterX = c.clientX;
uiCache.pinchLastCenterY = c.clientY;
if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy);
uiCache.touchMoved = true;
render();
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
const screenDx = t.clientX - (uiCache.touchLastX || t.clientX);
const screenDy = t.clientY - (uiCache.touchLastY || t.clientY);
const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY));
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
if (uiCache.touchMode === "cameraPan") {
if (totalMove > 2) uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
return;
}
if (uiCache.touchMode === "toolTapOnly") {
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; }
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
render();
return;
}
if (uiCache.touchMode === "grab" && uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.touchMode = "";
return;
}
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
render();
return;
}
if (uiCache.touchMode === "panOrTap") {
if (totalMove > 7) {
uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
}
}
}, { passive: false });
canvas?.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) {
uiCache.hosing = false;
world.rebuildSpatial?.();
renderStats();
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
renderStats();
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
}
if (!e.touches?.length) {
uiCache.touchMode = "";
uiCache.touchMoved = false;
}
}, { passive: false });
canvas?.addEventListener("touchcancel", () => {
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
}, { passive: true });
canvas?.addEventListener("contextmenu", (e) => e.preventDefault());
canvas?.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
audio.waterHose?.();
const p = screenToWorld(e);
if (!p.inside) return;
e.preventDefault();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = e.clientX;
uiCache.hoseStartY = e.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
render();
return;
}
if (e.button === 0 && world.tool === "pinch") {
const p = screenToWorld(e);
if (!p.inside) return;
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return;
}
e.preventDefault();
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
canvas.style.cursor = "grabbing";
renderStats();
return;
}
if (e.button !== 2) return;
e.preventDefault();
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = e.button;
uiCache.panStartX = e.clientX;
uiCache.panStartY = e.clientY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
});
window.addEventListener("mouseup", (e) => {
if (uiCache.hosing) {
uiCache.hosing = false;
canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")));
world.rebuildSpatial?.();
renderStats();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.clientX, e.clientY);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
renderStats();
return;
}
if (!uiCache.panning) return;
if (uiCache.panButton !== null && e.button !== uiCache.panButton) return;
uiCache.panning = false;
uiCache.panButton = null;
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
});
window.addEventListener("mousemove", (e) => {
if (uiCache.hosing) {
const p = screenToWorld(e);
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY));
if (totalMove > 3) uiCache.hoseMoved = true;
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
canvas.style.cursor = "crosshair";
render();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return;
}
const p = screenToWorld(e);
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.clientX, e.clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
canvas.style.cursor = "grabbing";
return;
}
if (!uiCache.panning) return;
const dx = e.clientX - uiCache.panLastX;
const dy = e.clientY - uiCache.panLastY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
});
canvas?.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = screenToWorld(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
canvas?.addEventListener("mousemove", (e) => {
const p = screenToWorld(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
});
canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas?.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
const inputContext = window.TarinaiUIInputShared?.createInputContext?.({ canvas, world, ui, uiCache });
if (!inputContext) throw new Error("Tarinai UI input context is not available");
window.TarinaiTouchInput?.bindTouchInput?.(inputContext);
window.TarinaiMouseInput?.bindMouseInput?.(inputContext);
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;

View file

@ -18,7 +18,8 @@ function renderStats() {
};
setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statDead, "dead", values.dead);
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "\u306e\u3093\u3073\u308a");
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "安定");
window.TarinaiGroundUI?.update?.(world);
updateColonyHistory(values);
drawColonyChart(values);
renderSelected();
@ -111,9 +112,6 @@ function drawColonyChart(values) {
const chart = ui.colonyChart;
if (!chart) return;
const now = performance.now();
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const minInterval = perf >= 3 ? 2600 : perf === 2 ? 1800 : perf === 1 ? 950 : 0;
if (minInterval && uiCache.lastChartDrawAt && now - uiCache.lastChartDrawAt < minInterval) return;
const mode = uiCache.chartMode || "population";
if (mode === "environment" && !uiCache.chartHistory.length && !uiCache.environmentInitialRow) {
uiCache.environmentInitialRow = { t: world.time || 0, ...values };
@ -178,17 +176,17 @@ function chartSeries(mode = "population") {
{ key: "hunger", label: "\u7a7a\u8179", color: "#e4a33f", percent: true },
{ key: "lonely", label: "\u5bc2\u3057\u3055", color: "#9b78d4", percent: true },
{ key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true },
{ key: "mood", label: "\u7dcf\u5408\u7684\u6c17\u5206", color: "#0072b2", percent: true },
{ key: "mood", label: "気分", color: "#0072b2", percent: true },
];
if (mode === "objects") return [
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
{ key: "obj_trace", label: "足跡", color: "#8a7054" },
];
if (mode === "environment") return [
{ key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true },
{ key: "hygiene", label: "\u885b\u751f", color: "#6f8a3f", percent: true },
{ key: "happiness", label: "\u5e78\u798f", color: "#d98bc0", percent: true },
{ key: "security", label: "安全", color: "#d65e46", percent: true },
{ key: "hygiene", label: "衛生", color: "#6f8a3f", percent: true },
{ key: "happiness", label: "満足", color: "#d98bc0", percent: true },
];
return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }];
}

View file

@ -1,9 +1,4 @@
"use strict";
function buildArchiveRows(family) {
return liveFamilyComponents(family);
}
function lineageEdgeFamily(family = world.family || {}) {
return family || {};
}
@ -85,47 +80,3 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) {
children.sort((a, b) => lineageNodeSort(family[a], family[b]));
return children;
}
function lineageHasActualRelation(n, family = world.family || {}) {
return lineageMutualParentIds(n, family).length > 0 || lineageMutualChildIds(n, family).length > 0;
}
function lineageRelatedIdSet(family) {
family = lineageEdgeFamily(family);
const relationIndex = lineageRelationIndex(family);
const ids = new Set();
for (const n of Object.values(family || {}).filter(Boolean)) {
const parents = relationIndex.parentsByChild.get(n.id) || [];
const children = relationIndex.childrenByParent.get(n.id) || [];
if (parents.size || children.size) ids.add(n.id);
for (const id of parents) ids.add(id);
for (const id of children) ids.add(id);
}
return ids;
}
function liveFamilyComponents(family) {
// Childless pairs, including pairs currently performing the birth ritual, are
// intentionally excluded. A \u5bb6\u7cfb\u56f3 node is created only after an actual parent/child
// relation exists; otherwise ritual participants become isolated pseudo-families.
family = lineageEdgeFamily(family);
const relatedIds = lineageRelatedIdSet(family);
const nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean);
const ids = new Set(nodes.map(n => n.id));
const comps = window.TarinaiFamilyGraph.connectedComponents(nodes, {
sort: lineageNodeSort,
edgesOf: (n) => [
...lineageMutualParentIds(n, family, ids),
...lineageMutualChildIds(n, family, ids),
],
filter: comp => comp.some(x => x.alive),
});
comps.sort((a, b) => {
const ag = Math.min(...a.map(n => n.generation || 1));
const bg = Math.min(...b.map(n => n.generation || 1));
const at = Math.min(...a.map(n => n.birthTime || 0));
const bt = Math.min(...b.map(n => n.birthTime || 0));
return ag - bg || at - bt || b.length - a.length;
});
return comps;
}

View file

@ -201,37 +201,6 @@ function lineageExpectedChildEdgeCount(component, family) {
for (const n of component || []) count += lineageParentIds(n, idSet, family).length;
return count;
}
function lineageVisibleRelationSignature(family) {
family = lineageEdgeFamily(family);
const relationIndex = lineageRelationIndex(family);
const relatedIds = new Set();
for (const [childId, parents] of relationIndex.parentsByChild.entries()) {
if (!parents.size) continue;
relatedIds.add(childId);
for (const parentId of parents) relatedIds.add(parentId);
}
return Array.from(relatedIds)
.sort()
.map(id => {
const n = family[id] || {};
const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(",");
const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(",");
return [
id,
n.name || "",
n.type || "",
n.generation || 1,
n.birthTime || 0,
n.alive === false ? 0 : 1,
n.deathReason || "",
parents,
children,
].join(":");
})
.join("|");
}
function lineageArchiveNearViewport() {
if (!ui.archiveContent?.getBoundingClientRect) return true;
const rect = ui.archiveContent.getBoundingClientRect();

View file

@ -360,11 +360,6 @@ function validateFamilyTree() {
});
return issues;
}
function renderArchiveWindow() {
renderArchive();
}
function scheduleArchiveWindowRender() {
if (typeof archiveAutoUpdateEnabled === "function" && !archiveAutoUpdateEnabled()) {
uiCache.archiveScheduled = false;

57
js/ui_ground.js Normal file
View file

@ -0,0 +1,57 @@
"use strict";
(function (global) {
function groundDef(worldRef = global.world) {
const id = worldRef?.groundType || "soil";
return global.TarinaiGround?.definition?.(id) || (typeof GROUND_TYPES !== "undefined" ? GROUND_TYPES[id] || GROUND_TYPES.soil : { id: "soil", label: "土", description: "いつものじめん" });
}
function groundTip(worldRef = global.world) {
const def = groundDef(worldRef);
if (global.TarinaiGround?.tooltipText) return global.TarinaiGround.tooltipText(def.id);
return `${def.label || "じめん"}\n${def.description || "クリックで切り替え"}`;
}
function updateGroundUI(worldRef = global.world) {
const uiRef = global.ui || {};
const def = groundDef(worldRef);
const label = def.label || "土";
if (uiRef.statGroundType) setTextIfChanged(uiRef.statGroundType, "groundType", label);
if (uiRef.groundTypeBtn) {
const desc = def.description || "クリックで切り替え";
uiRef.groundTypeBtn.dataset.tip = desc;
uiRef.groundTypeBtn.removeAttribute("title");
uiRef.groundTypeBtn.setAttribute("aria-label", desc ? `地面を切り替える。現在は${label}${desc}` : `地面を切り替える。現在は${label}`);
}
}
function bindGroundUI(worldRef = global.world) {
const btn = global.ui?.groundTypeBtn;
if (!btn) return;
if (btn.dataset.groundBound !== "1") {
btn.dataset.groundBound = "1";
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const currentWorld = global.world || worldRef;
audio.uiClick?.();
currentWorld?.cycleGroundType?.();
if (global.uiCache) {
global.uiCache.lastStatsGroundType = "";
if (global.uiCache.stats) global.uiCache.stats.groundType = "";
}
updateGroundUI(currentWorld);
global.renderStats?.();
});
}
global.TarinaiTooltips?.bind?.(btn, () => groundTip(worldRef));
updateGroundUI(worldRef);
}
global.TarinaiGroundUI = Object.freeze({
bind: bindGroundUI,
update: updateGroundUI,
tip: groundTip,
});
global.bindGroundUI = bindGroundUI;
})(window);

81
js/ui_input_mouse.js Normal file
View file

@ -0,0 +1,81 @@
"use strict";
(function (global) {
function bindMouseInput(ctx) {
if (!ctx?.canvas) return false;
const { canvas, world, uiCache } = ctx;
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
e.preventDefault();
ctx.beginHose(p, e.clientX, e.clientY);
return;
}
if (e.button === 0 && world.tool === "pinch") {
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
if (!ctx.beginGrab(p, e.clientX, e.clientY, { notifyNoTarget: true })) return;
e.preventDefault();
canvas.style.cursor = "grabbing";
return;
}
if (e.button !== 2) return;
e.preventDefault();
ctx.beginPan(e.clientX, e.clientY, e.button);
});
window.addEventListener("mouseup", (e) => {
if (ctx.endHose()) return;
if (ctx.endGrab(e.clientX, e.clientY)) return;
ctx.endPan(e.button);
});
window.addEventListener("mousemove", (e) => {
if (uiCache.hosing) {
const p = ctx.screenToWorldEvent(e);
ctx.moveHose(p, e.clientX, e.clientY);
return;
}
if (uiCache.grabbing) {
const p = ctx.screenToWorldEvent(e);
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
ctx.moveGrab(p, e.clientX, e.clientY);
return;
}
ctx.movePan(e.clientX, e.clientY);
});
canvas.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = ctx.screenToWorldEvent(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
canvas.addEventListener("mousemove", (e) => ctx.updatePointerFromEvent(e));
canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
return true;
}
global.TarinaiMouseInput = Object.freeze({ bindMouseInput });
})(window);

378
js/ui_input_shared.js Normal file
View file

@ -0,0 +1,378 @@
"use strict";
(function (global) {
function cursorForTool(tool) {
if (tool === "water_hose") return "crosshair";
if (tool === "pinch") return "grab";
if (tool === "observe") return "default";
if (tool === "poke" || tool === "delete") return "pointer";
return "crosshair";
}
function createInputContext({ canvas, world, ui, uiCache }) {
if (!canvas || !world || !uiCache) return null;
const inputModeManager = global.TarinaiInputMode;
function setMobileInputMode(mode = "auto") {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode;
ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode));
document.body.classList.toggle("mobile-mode-camera", mode === "camera");
document.body.classList.toggle("mobile-mode-tool", mode === "tool");
document.body.classList.toggle("mobile-mode-family", mode === "family");
return mode;
}
function updateTouchMobileUiClass() {
const enabled = Boolean(inputModeManager?.refresh?.().touchFirst);
document.body.classList.toggle("touch-mobile-ui", enabled);
if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled;
return enabled;
}
function mobileInputMode() {
return inputModeManager?.currentMode || uiCache.mobileInputMode || "auto";
}
function bindMobileModeControls() {
updateTouchMobileUiClass();
window.addEventListener("resize", updateTouchMobileUiClass, { passive: true });
window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass);
setMobileInputMode(uiCache.mobileInputMode || "auto");
ui.mobileModeControls?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mobile-mode]");
if (!btn) return;
audio.uiClick?.();
setMobileInputMode(btn.dataset.mobileMode || "auto");
showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`);
});
}
function touchPoint(touches, index = 0) {
const t = touches?.[index];
if (!t) return null;
return { clientX: t.clientX, clientY: t.clientY };
}
function touchCenter(touches) {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a) return null;
if (!b) return a;
return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 };
}
function touchDistance(touches) {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a || !b) return 0;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
function screenToWorldClient(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 };
}
function screenToWorldEvent(e) {
if (typeof screenToWorld === "function") return screenToWorld(e);
return screenToWorldClient(e.clientX, e.clientY);
}
function clientToWorldRaw(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const scale = world.viewScale ? world.viewScale() : 1;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const rawX = (sx - off.x) / scale + (world.cameraX || 0);
const rawY = (sy - off.y) / scale + (world.cameraY || 0);
return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h };
}
function setZoomAroundClientPoint(clientX, clientY, nextZoom) {
if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
if (!world.setFieldZoom(nextZoom)) return false;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
world.cameraX = focus.x - (sx - off.x) / scale;
world.cameraY = focus.y - (sy - off.y) / scale;
world.clampCamera?.();
return true;
}
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
}
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && global.TarinaiFreezeSystem?.freezeTarinai) {
global.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002");
uiCache.grabOrigin = null;
return true;
}
uiCache.grabOrigin = null;
return false;
}
function prepareGrabTarget(target) {
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
}
function beginGrab(p, clientX, clientY, options = {}) {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
if (options.notifyNoTarget) showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return false;
}
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = clientX || 0;
uiCache.grabStartY = clientY || 0;
prepareGrabTarget(target);
if (options.touchMode) uiCache.touchMode = options.touchMode;
renderStats();
return true;
}
function moveGrab(p, clientX, clientY, options = {}) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
if (options.touch) uiCache.touchMode = "";
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return false;
}
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(clientX, clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
canvas.style.cursor = "grabbing";
if (options.renderFrame) render();
return true;
}
function endGrab(clientX, clientY) {
if (!uiCache.grabbing) return false;
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, clientX, clientY);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
canvas.style.cursor = cursorForTool(world.tool);
renderStats();
return true;
}
function beginHose(p, clientX, clientY, options = {}) {
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = clientX;
uiCache.hoseStartY = clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
if (options.touchMode) uiCache.touchMode = options.touchMode;
render();
return true;
}
function moveHose(p, clientX, clientY, options = {}) {
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
const totalMove = Math.hypot(clientX - (uiCache.hoseStartX || clientX), clientY - (uiCache.hoseStartY || clientY));
if (totalMove > 3) {
uiCache.hoseMoved = true;
if (options.touch) uiCache.touchMoved = true;
}
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
canvas.style.cursor = "crosshair";
render();
}
function endHose() {
if (!uiCache.hosing) return false;
uiCache.hosing = false;
canvas.style.cursor = cursorForTool(world.tool);
world.rebuildSpatial?.();
renderStats();
return true;
}
function beginPan(clientX, clientY, button = null) {
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = button;
uiCache.panStartX = clientX;
uiCache.panStartY = clientY;
uiCache.panLastX = clientX;
uiCache.panLastY = clientY;
}
function movePan(clientX, clientY) {
if (!uiCache.panning) return false;
const dx = clientX - uiCache.panLastX;
const dy = clientY - uiCache.panLastY;
uiCache.panLastX = clientX;
uiCache.panLastY = clientY;
const totalMove = Math.hypot(clientX - uiCache.panStartX, clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
return true;
}
function endPan(button = null) {
if (!uiCache.panning) return false;
if (uiCache.panButton !== null && button !== null && button !== uiCache.panButton) return false;
uiCache.panning = false;
uiCache.panButton = null;
canvas.style.cursor = cursorForTool(world.tool);
return true;
}
function updatePointerFromEvent(e) {
const p = screenToWorldEvent(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
}
function cancelPointerActions() {
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
}
return {
canvas,
world,
ui,
uiCache,
cursorForTool,
setMobileInputMode,
updateTouchMobileUiClass,
mobileInputMode,
bindMobileModeControls,
touchPoint,
touchCenter,
touchDistance,
screenToWorldClient,
screenToWorldEvent,
clientToWorldRaw,
setZoomAroundClientPoint,
beginGrab,
moveGrab,
endGrab,
beginHose,
moveHose,
endHose,
beginPan,
movePan,
endPan,
updatePointerFromEvent,
cancelPointerActions,
};
}
global.TarinaiUIInputShared = Object.freeze({ createInputContext, cursorForTool });
})(window);

133
js/ui_input_touch.js Normal file
View file

@ -0,0 +1,133 @@
"use strict";
(function (global) {
function bindTouchInput(ctx) {
if (!ctx?.canvas) return false;
const { canvas, world, uiCache } = ctx;
ctx.bindMobileModeControls();
canvas.addEventListener("touchstart", (e) => {
if (!e.touches?.length) return;
e.preventDefault();
uiCache.touchMoved = false;
const mode = ctx.mobileInputMode();
if ((mode === "camera" || mode === "family") && e.touches.length === 1) {
const t = e.touches[0];
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchMode = "cameraPan";
return;
}
if (e.touches.length >= 2) {
const c = ctx.touchCenter(e.touches);
uiCache.touchMode = "pinchZoom";
uiCache.pinchStartDistance = Math.max(1, ctx.touchDistance(e.touches));
uiCache.pinchStartZoom = world.fieldZoom || 1;
uiCache.pinchLastCenterX = c?.clientX || 0;
uiCache.pinchLastCenterY = c?.clientY || 0;
return;
}
const t = e.touches[0];
const p = ctx.screenToWorldClient(t.clientX, t.clientY);
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchLastWorldX = p.x;
uiCache.touchLastWorldY = p.y;
if (mode === "tool" && world.tool === "observe") {
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "water_hose" && p.inside) {
ctx.beginHose(p, t.clientX, t.clientY, { touchMode: "hose" });
return;
}
if (world.tool === "pinch" && p.inside && ctx.beginGrab(p, t.clientX, t.clientY, { touchMode: "grab" })) return;
uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap";
}, { passive: false });
canvas.addEventListener("touchmove", (e) => {
if (!e.touches?.length || !uiCache.touchMode) return;
e.preventDefault();
if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) {
const c = ctx.touchCenter(e.touches);
const distNow = Math.max(1, ctx.touchDistance(e.touches));
const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow);
ctx.setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio);
const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX);
const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY);
uiCache.pinchLastCenterX = c.clientX;
uiCache.pinchLastCenterY = c.clientY;
if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy);
uiCache.touchMoved = true;
render();
return;
}
const t = e.touches[0];
const p = ctx.screenToWorldClient(t.clientX, t.clientY);
const screenDx = t.clientX - (uiCache.touchLastX || t.clientX);
const screenDy = t.clientY - (uiCache.touchLastY || t.clientY);
const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY));
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
if (uiCache.touchMode === "cameraPan") {
if (totalMove > 2) uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
return;
}
if (uiCache.touchMode === "toolTapOnly") {
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
ctx.moveHose(p, t.clientX, t.clientY, { touch: true });
return;
}
if (uiCache.touchMode === "grab" && uiCache.grabbing) {
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
ctx.moveGrab(p, t.clientX, t.clientY, { touch: true, renderFrame: true });
return;
}
if (uiCache.touchMode === "panOrTap") {
if (totalMove > 7) {
uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
}
}
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) ctx.endHose();
if (uiCache.grabbing) {
ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
const p = ctx.screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
}
if (!e.touches?.length) {
uiCache.touchMode = "";
uiCache.touchMoved = false;
}
}, { passive: false });
canvas.addEventListener("touchcancel", () => ctx.cancelPointerActions(), { passive: true });
return true;
}
global.TarinaiTouchInput = Object.freeze({ bindTouchInput });
})(window);

View file

@ -2,7 +2,8 @@
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(0.6, Math.min(window.devicePixelRatio || 1, 2));
const perfScale = window.TarinaiPerf?.dprScale?.() || 1;
const dpr = Math.max(0.5, Math.min((window.devicePixelRatio || 1) * perfScale, 2));
const nextW = Math.max(1, Math.floor(rect.width * dpr));
const nextH = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
@ -46,40 +47,7 @@ function hydrateLazyImages(root) {
}
function applyToolTips() {
const tips = window.TEXT_CATALOG?.toolTips || {};
if (!ui.toolPalette) return;
let pop = document.getElementById("toolTipPopover");
if (!pop) {
pop = document.createElement("div");
pop.id = "toolTipPopover";
pop.className = "tool-tip-popover hidden";
document.body.appendChild(pop);
}
const showTip = (btn) => {
const baseTip = btn?.dataset?.tip || "";
if (!baseTip) return;
const tip = baseTip;
pop.textContent = tip;
pop.classList.remove("hidden");
const rect = btn.getBoundingClientRect();
const popW = pop.offsetWidth || 220;
const popH = pop.offsetHeight || 36;
const left = rect.left - popW - 12;
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`;
};
const hideTip = () => pop.classList.add("hidden");
for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) {
const tip = tips[btn.dataset.tool];
if (!tip) continue;
btn.dataset.tip = tip;
if (btn.dataset.tipBound === "1") continue;
btn.dataset.tipBound = "1";
btn.addEventListener("mouseenter", () => showTip(btn));
btn.addEventListener("focus", () => showTip(btn));
btn.addEventListener("mouseleave", hideTip);
btn.addEventListener("blur", hideTip);
}
window.TarinaiTooltips?.applyToolPalette?.();
}
function renderEcologyCards() {

View file

@ -1,5 +1,7 @@
"use strict";
const escapeHtml = window.TarinaiUIHelpers.htmlEscape;
function quoteNameInRecordText(text, name) {
const raw = String(text || "");
const n = String(name || "").trim();
@ -139,19 +141,6 @@ function personalityDailySnapshot(t) {
}).join("|");
return `${keyPart}#${causePart}`;
}
function recentChangeCausesHtml(t) {
const rows = Array.isArray(t?.recentChangeCauses) ? t.recentChangeCauses.slice(0, 8) : [];
if (!rows.length) return `<li><span>-</span><strong>\u6700\u8fd1\u306e\u5909\u5316\u306a\u3057</strong></li>`;
return rows.map(entry => {
const timeText = world.clockStringFromTime ? world.clockStringFromTime(entry.time || 0) : formatRecordTime(entry.time || 0);
const reason = String(entry.reason || "\u5909\u5316");
const target = String(entry.target || "\u72b6\u614b");
const value = entry.value === "" || entry.value == null ? "" : ` ${entry.value}`;
return `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(reason)} / ${escapeHtml(target)}${escapeHtml(value)}</strong></li>`;
}).join("");
}
function selectedDataCategoryHtml(id, label, bodyHtml) {
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
const collapsed = uiCache.selectedCollapsedCategories.has(id);
@ -192,17 +181,21 @@ function selectedStateText(t) {
function renderSelected() {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) {
ui.selectedCard?.classList.add("hidden");
if (uiCache.selectedEmpty) return;
const showEmpty = Boolean(uiCache.showSelectedEmpty);
ui.selectedCard?.classList.toggle("hidden", !showEmpty);
if (uiCache.selectedEmpty && uiCache.selectedEmptyVisible === showEmpty) return;
uiCache.selectedSnapshot = "";
uiCache.selectedEmpty = true;
uiCache.selectedEmptyVisible = showEmpty;
ui.selectedInfo.className = "selected-info empty";
ui.selectedInfo.textContent = "\u672a\u9078\u629e";
return;
}
uiCache.showSelectedEmpty = false;
ui.selectedCard?.classList.remove("hidden");
uiCache.selectedEmpty = false;
uiCache.selectedEmptyVisible = false;
const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id;
if (activeNameInput) return;
const rel = t.relationSummary ? t.relationSummary() : {};
@ -219,7 +212,7 @@ function renderSelected() {
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const snapshot = [
t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target),
behaviorState?.need || "", behaviorState?.id || "", behaviorState?.label || "", behaviorState?.reasonText || "", behaviorState?.id || "", behaviorState?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
behaviorState?.need || "", behaviorState?.actionId || "", behaviorState?.label || "", behaviorState?.reason || "", behaviorState?.source || "", behaviorState?.text || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood),
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
@ -324,14 +317,16 @@ function stateLabel(s) {
function reasonLabel(t) {
if (!t) return "\u306a\u3057";
const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim();
const behavior = typeof globalThis.behaviorText === "function"
? globalThis.behaviorText(t)
: String(typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.text || t.behavior?.reason || "")).trim();
if (behavior) return behavior;
const state = String(t.state || "");
const live = window.TEXT_CATALOG?.reasonLabel?.(t) || "";
const liveStates = new Set(["birth_ritual", "eat", "sleep", "panic", "fight", "intimidate", "ant_attack", "ant_intimidate", "frozen"]);
if (live && (liveStates.has(state) || (t.birthRitualTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04 || (t.fightTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04)) return live;
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
if (behaviorState?.reasonText) return behaviorState.reasonText;
if (behaviorState?.reason || behaviorState?.text) return behaviorState.reason || behaviorState.text;
return live || "\u306a\u3057";
}
@ -354,26 +349,6 @@ function setTextIfChanged(el, key, value) {
uiCache.stats[key] = next;
el.textContent = next;
}
function setValueIfChanged(el, key, value) {
if (!el) return;
if (uiCache.stats[key] === value) return;
uiCache.stats[key] = value;
el.value = value;
}
function escapeHtml(value) {
const helper = window.TarinaiUIHelpers?.htmlEscape;
if (typeof helper === "function" && helper !== escapeHtml) return helper(value);
return String(value ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[c] || c));
}
function escapeRegExp(value) {
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -24,11 +24,12 @@ function renderToolPalette() {
body.className = "tool-grid tool-category-body";
for (const toolId of category.toolIds || []) {
const def = toolDefinition(toolId);
if (!def || def.simulationOnly) continue;
if (!def || def.simulationOnly || toolId === "observe") continue;
const btn = document.createElement("button");
btn.className = "tool";
if (EMOJI_ICON_TOOLS.has(toolId)) btn.classList.add("tool-emoji-icon");
btn.dataset.tool = toolId;
btn.dataset.toolCategory = category.id || "";
const label = document.createElement("span");
label.className = "tool-label";
label.textContent = def.label || toolId;

70
js/ui_tooltips.js Normal file
View file

@ -0,0 +1,70 @@
"use strict";
(function (global) {
function ensureTooltipPopover() {
let pop = document.getElementById("toolTipPopover");
if (!pop) {
pop = document.createElement("div");
pop.id = "toolTipPopover";
pop.className = "tool-tip-popover hidden";
document.body.appendChild(pop);
}
return pop;
}
function resolveTip(el) {
if (!el) return "";
if (typeof el.__tarinaiTipProvider === "function") return String(el.__tarinaiTipProvider() || "");
return String(el.dataset?.tip || "");
}
function showTooltip(el) {
const tip = resolveTip(el);
if (!tip) return;
const pop = ensureTooltipPopover();
pop.textContent = tip;
pop.classList.remove("hidden");
const rect = el.getBoundingClientRect();
const popW = pop.offsetWidth || 220;
const popH = pop.offsetHeight || 36;
const preferLeft = rect.left - popW - 12;
const rightSide = rect.right + 12;
const left = preferLeft >= 8 ? preferLeft : rightSide;
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`;
}
function hideTooltip() {
ensureTooltipPopover().classList.add("hidden");
}
function bindTooltip(el, tip = "") {
if (!el) return;
if (typeof tip === "function") el.__tarinaiTipProvider = tip;
else if (tip) el.dataset.tip = String(tip);
if (el.dataset.tipBound === "1") return;
el.dataset.tipBound = "1";
el.addEventListener("mouseenter", () => showTooltip(el));
el.addEventListener("focus", () => showTooltip(el));
el.addEventListener("mouseleave", hideTooltip);
el.addEventListener("blur", hideTooltip);
}
function applyToolPaletteTooltips() {
const tips = global.TEXT_CATALOG?.toolTips || {};
const root = global.ui?.toolPalette || document;
for (const btn of root.querySelectorAll?.(".tool[data-tool]") || []) {
const tip = tips[btn.dataset.tool];
if (tip) bindTooltip(btn, tip);
}
}
global.TarinaiTooltips = Object.freeze({
ensure: ensureTooltipPopover,
bind: bindTooltip,
show: showTooltip,
hide: hideTooltip,
applyToolPalette: applyToolPaletteTooltips,
});
global.bindTooltip = bindTooltip;
})(window);

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "16.00.01";
const APP_BUILD = "lag-pass-static-schedule";
const APP_VERSION = "16.07.09";
const APP_BUILD = "noise-cleanup";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -43,6 +43,7 @@ class World { constructor() {
this.lastPhase = "";
this.weather = "sunny";
this.fieldType = "garden";
this.groundType = "soil";
this.fieldZoom = 1;
this.cameraX = 0;
this.cameraY = 0;
@ -67,7 +68,7 @@ class World { constructor() {
this.drawList = [];
this.itemCounts = {};
this.effectCounts = {};
this.colonyMood = { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } };
this.colonyMood = { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } };
this.lastColonyMoodDay = 0;
this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0;

View file

@ -115,7 +115,8 @@
const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y };
if (!spot) continue;
let nearest = Infinity;
for (const it of this.items || []) {
const nests = this.itemsOfType ? this.itemsOfType("ant_nest") : (this.items || []);
for (const it of nests) {
if (!it || it.dead || it.type !== "ant_nest") continue;
nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y));
}

View file

@ -111,7 +111,7 @@
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
});
let blastedBalls = 0;
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
@ -200,7 +200,7 @@
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
});
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
@ -386,33 +386,6 @@
const occ = this.bedOccupancy(bed);
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
},
bestBedFor(t, maxDist = 360) {
const choose = (kind) => {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue;
if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) {
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : [];
if (occupants.length >= this.nestBoxCapacity(bed)) continue;
}
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34;
const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
};
const owned = (this.nearbyItems(t.x, t.y, maxDist) || [])
.filter(bed => bed && !bed.dead && bed.ownerId === t.id && bed.roles?.sleepPlace)
.sort((a, b) => dist(t, a) - dist(t, b))[0] || null;
return owned || choose("nest_box") || choose("bed") || choose("grass_bed");
},
relationNotice(aId, bId, kind, cooldown = 30) {
const key = [aId, bId].sort().join(":") + `:${kind}`;
const last = this.relationNotices?.[key] || -Infinity;
@ -438,27 +411,6 @@
}
return true;
},
applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) {
if (!entity || !Number.isFinite(strength)) return false;
let dx = (entity.x || 0) - sourceX;
let dy = (entity.y || 0) - sourceY;
let d = Math.hypot(dx, dy);
if (!Number.isFinite(d) || d < 0.001) {
const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
const random = options.random || 0;
return this.applyImpulse(
entity,
dx / d * strength + rand(-random, random),
dy / d * strength + rand(-random, random),
options,
);
},
applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) {
if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false;
const normalized = options.cause || cause || "\u885d\u7a81";
@ -607,17 +559,6 @@
this.log(`${label}\u304c\u843d\u3061\u3001${victims.join("\u3068")}\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
},
countBallChasers(ball, except = null) {
if (!ball) return 0;
let count = 0;
for (const t of this.tarinai || []) {
if (!t || t === except || t.dead) continue;
if (t.state === "play_ball" && t.target === ball) count += 1;
}
return count;
},
limitBallChasers() {
if (!this.itemCounts?.ball) return;
const groups = new Map();
@ -764,7 +705,7 @@
resolveBallInteractions(dt) {
if (!this.itemCounts?.ball) return;
for (const ball of this.items) {
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84;

View file

@ -82,11 +82,6 @@
mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"),
];
},
nestBoxTopRect(it) {
return this.nestBoxSolidRects(it)[0] || null;
},
nestBoxEntryPoint(box) {
const base = this.nestBoxBaseRect(box);
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
@ -299,19 +294,5 @@
}
return null;
},
findGrassSproutSpot(source, fertile = false) {
const attempts = fertile ? 24 : 10;
const minR = fertile ? Math.max(30, source.r * 1.9) : 18;
const maxR = fertile ? 125 : 72;
for (let i = 0; i < attempts; i++) {
const angle = rand(0, Math.PI * 2);
const radius = rand(minR, maxR);
const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44);
const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
if (this.grassSpotOpen(x, y, { minGrassGap: fertile ? 24 : 22, avoidTarinai: true })) return { x, y };
}
return null;
}
}));
})(typeof window !== "undefined" ? window : globalThis);

75
js/world_event_effects.js Normal file
View file

@ -0,0 +1,75 @@
"use strict";
(function (global) {
const events = global.TarinaiEvents;
if (!events || events.__worldEventEffectsBound) return;
events.__worldEventEffectsBound = true;
events.on("world:phase", () => {
global.audio?.phase?.();
});
function playLogSoundForEntry(entry = {}) {
if (!entry || entry.hiddenFromObservation || entry.sound === false || entry.silentAudio) return;
const audio = global.audio;
if (!audio) return;
const kind = entry.kind || "note";
const text = String(entry.text || "");
if (kind === "death") return audio.death?.();
if (kind === "birth") return audio.birth?.();
if (kind === "fight") return /決着|勝|負|覚え/.test(text) ? audio.fightFinish?.() : audio.fight?.();
if (kind === "food") return audio.eat?.();
if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 });
if (kind === "weather") return audio.phase?.();
if (kind === "relation") return audio.notify?.();
if (kind === "accident") {
if (/配置|置いた|設置/.test(text)) return audio.place?.(/爆竹/.test(text) ? "firecracker" : "");
if (/げんこつ/.test(text)) return audio.genkotsuImpact?.();
if (/爆竹|爆発/.test(text)) return audio.explode?.();
if (/石/.test(text)) return audio.stoneImpact?.();
if (/ボール/.test(text)) return audio.ballHit?.();
return audio.damage?.(18);
}
if (kind === "event") {
if (/女王アリ/.test(text)) return audio.queenAnt?.();
if (/アリ|巣/.test(text)) return audio.antNest?.();
if (/病|発病/.test(text)) return audio.disease?.();
if (/回復|治/.test(text)) return audio.heal?.();
}
}
function renderFreezePanel(worldRef) {
if (global.TarinaiFreezePanel?.render && global.TarinaiFreezeStore?.frozenList) {
global.TarinaiFreezePanel.render(global.TarinaiFreezeStore.frozenList(worldRef));
return;
}
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}
events.on("log:entry", (event) => {
const detail = event.detail || {};
const worldRef = detail.world || global.world;
const entry = detail.entry;
if (!entry) return;
playLogSoundForEntry(entry);
if (typeof renderLog === "function") renderLog(worldRef?.logs || []);
if (typeof pushLogNotification === "function") pushLogNotification(entry);
});
events.on("tool:placed", (event) => {
const detail = event.detail || {};
if (detail.dropped) global.audio?.place?.(detail.type || detail.item?.type || "");
});
events.on("freeze:changed", (event) => {
const detail = event.detail || {};
const worldRef = detail.world || global.world;
renderFreezePanel(worldRef);
if (detail.renderWorld !== false) {
if (typeof renderSelected === "function") renderSelected();
if (typeof renderStats === "function") renderStats();
if (typeof render === "function") render();
}
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -19,6 +19,11 @@
this.items = [];
this.ants = [];
this.effects = [];
this.itemCounts = {};
this.effectCounts = {};
this.itemTypeBuckets = new Map();
this.itemIdMap = new Map();
this.itemBucketsDirty = false;
this.logs = [];
this.events = window.TarinaiEvents || null;
this.eventCounters = {};
@ -27,6 +32,8 @@
this.drawSortTimer = 0;
this.drawListDirty = true;
this.antUpdatePhase = 0;
this.grassGrowthTimer = 0;
this.nextGrassLimitCheckAt = 0;
this.selected = null;
this.deadCount = 0;
this.liveIdNext = 1;
@ -51,6 +58,7 @@
this.clampCamera();
this.lastPhase = this.phaseName();
this.weather = "sunny";
this.groundType = window.TarinaiGround?.exists?.(this.groundType || "soil") ? (this.groundType || "soil") : "soil";
this.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
seedPopulation = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
const grassCount = field.grass ?? 18;
@ -65,11 +73,14 @@
}
for (let i = 0; i < grassCount; i++) {
const grass = new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70));
globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true });
if (this.addItem) this.addItem(grass, "reset-grass");
else this.items.push(grass);
}
this.addItem?.(new Item("stone", this.w * 0.68, this.h * 0.32), "reset-stone") || this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
this.updateItemCounts();
this.enforceGrassLimit?.("reset-grass-limit");
this.updateItemCounts();
this.updateEffectCounts();
this.markTerrainDirty?.("reset");
this.rebuildSpatial(true);
@ -78,7 +89,7 @@
},
tarinaiFamilyKey(t) {
return t?.familyKey || t?.archiveKey || t?.id || "";
return t?.familyKey || t?.id || "";
},
assignTarinaiLiveId(t) {
@ -377,7 +388,6 @@
this.markFamilyTreeDirty("death");
}
}
this.notifyDeathToRelations?.(t, finalReason);
this.familyPrunePending = true;
},
@ -410,8 +420,66 @@
return t;
},
nestBoxOccupants(box, limit = 5) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = (this.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
},
pointerNestBoxInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
if (!best) return null;
const occupants = this.nestBoxOccupants(best, Infinity);
const capacity = this.nestBoxCapacity(best);
return { box: best, occupants, capacity };
},
nestBoxTooltipLines(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.nestBoxOccupants(box, Infinity);
const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5);
const more = occupants.length > names.length ? `、ほか${occupants.length - names.length}` : "";
return ["巣箱", names.length ? `入ってる子: ${names.join("、")}${more}` : "入ってる子: なし"];
},
pointerOwnedBedInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "grass_bed") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
}
if (!best) return null;
const owner = this.liveTarinaiById?.(best.ownerId) || null;
return { bed: best, owner };
},
ownedBedTooltipLines(bed) {
if (!bed || bed.dead || bed.type !== "grass_bed") return [];
const owner = this.liveTarinaiById?.(bed.ownerId) || null;
return ["かんたんベッド", `持ち主: ${owner?.name || "不明"}`];
},
pointerItemTooltipInfo() {
const nest = this.pointerNestBoxInfo?.();
if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" };
const bedInfo = this.pointerOwnedBedInfo?.();
if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" };
return null;
},
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false;
if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false;
if (this.areParentChild(a, b)) return false;
if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false;
@ -433,17 +501,35 @@
b.birthRitualRole = 1;
a.birthRitualLeader = true;
b.birthRitualLeader = false;
a.setActionState("birth_ritual", { target: b, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" });
b.setActionState("birth_ritual", { target: a, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" });
if (typeof setLiveActionText === "function") {
const ritualReason = "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
const applyRitualAction = (actor, partner) => {
if (!actor) return false;
if (typeof actor.setActionState === "function") {
actor.setActionState("birth_ritual", { target: partner || null, reason: ritualReason, need: "social", subNeed: "mate", actionId: "birth_ritual", phase: "acting" });
return true;
}
actor.state = "birth_ritual";
actor.target = partner || null;
actor.thought = ritualReason;
actor.sleeping = false;
if (typeof setBehaviorText === "function") {
setBehaviorText(actor, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: ritualReason, reasonText: ritualReason, target: partner || null, phase: "acting", source: "state" });
} else if (typeof patchTarinaiBehavior === "function") {
patchTarinaiBehavior(actor, { actionId: "birth_ritual", phase: "acting", target: partner || null, source: "state", need: "social", subNeed: "mate", reason: ritualReason, label: ritualReason, text: ritualReason });
}
return true;
};
applyRitualAction(a, b);
applyRitualAction(b, a);
if (typeof setBehaviorText === "function") {
const aBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(a) : a.behavior;
const bBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(b) : b.behavior;
const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const aText = typeof buildReasonText === "function" ? buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause }) : `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
const bText = typeof buildReasonText === "function" ? buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }) : `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
setLiveActionText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setLiveActionText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
}
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
@ -584,7 +670,6 @@
if (this.family[aKey] && !this.family[aKey].children.includes(childKey)) this.family[aKey].children.push(childKey);
if (this.family[bKey] && !this.family[bKey].children.includes(childKey)) this.family[bKey].children.push(childKey);
this.familyCleanVersion = null;
audio.birth();
this.lastBirthAt = this.time;
if (total <= 1) this.log(`${child.name}\u304c${a.name}\u3068${b.name}\u306e\u5b50\u3068\u3057\u3066\u751f\u307e\u308c\u305f\u3002`, "birth", { participants: [child, a, b] });
return child;
@ -715,7 +800,7 @@
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65));
actor.intimidateTargetId = target.id;
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42,
size: actor.radius * 0.55,
@ -782,9 +867,9 @@
b.counterAttackFromId = b === receiver ? initiator.id : null;
a.setActionState("fight", { target: b, reason: reasonA });
b.setActionState("fight", { target: a, reason: reasonB });
if (typeof setLiveActionText === "function") {
setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
if (typeof setBehaviorText === "function") {
setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
}
a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8));
b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8));
@ -820,7 +905,7 @@
}));
audio.fight();
if (this.time - Math.max(a.lastLog, b.lastLog) > 6) {
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] });
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b], sound: false });
a.lastLog = b.lastLog = this.time;
}
return true;

View file

@ -365,10 +365,9 @@
placeItem(item, dropped = true) {
if (!item) return null;
if (item.type === "grass") {
item.grassLimitExempt = true;
item.manualGrass = true;
item.placedByPlayer = true;
if (item.type === "grass" && !this.canAddGrass?.(1)) {
showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
return null;
}
if (this.placementBlocked(item)) {
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
@ -379,10 +378,13 @@
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
}
this.addItem?.(item, `place:${item.type}`, { ignoreGrassLimit: item.type === "grass", manualGrass: item.type === "grass" }) || this.items.push(item);
const placed = this.addItem?.(item, `place:${item.type}`);
if (!placed) {
if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
return null;
}
this.emit("tool:place", { item, type: item.type, dropped });
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
if (dropped) audio.place?.(item.type);
this.ensureSpatial?.(`place:${item.type}`);
return item;
},
@ -436,32 +438,11 @@
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 });
t.wanderAngle = spot.angle;
audio.birth();
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
return;
}
if (tool === "delete") {
let best = null, bestD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (it.dead) continue;
const visualHit = Math.max(22, (it.r || 12) * 2.0);
const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
const d = distXY(x, y, it.x, it.y);
if (d < hit && d < bestD) { best = it; bestD = d; }
}
if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
best.amount = 0;
audio.delete?.();
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
const label = toolLabel(best.type);
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
if (this.useToolAt?.(x, y)) return;
const itemType = toolItemType(tool);
if (itemType) {
@ -495,33 +476,6 @@
return type === "ball_poke" || type === "avoid_relationship";
},
playLogSoundForEntry(entry = {}) {
if (!entry || entry.hiddenFromObservation || typeof audio === "undefined") return;
const kind = entry.kind || "note";
const text = String(entry.text || "");
if (kind === "death") return audio.death?.();
if (kind === "birth") return audio.birth?.();
if (kind === "fight") return /\u6c7a\u7740|\u52dd|\u8ca0|\u899a\u3048/.test(text) ? audio.fightFinish?.() : audio.fight?.();
if (kind === "food") return audio.eat?.();
if (kind === "grass") return audio.play?.("sfx_grass", { category: "ops", minGap: 0.25 });
if (kind === "weather") return audio.phase?.();
if (kind === "relation") return audio.notify?.();
if (kind === "accident") {
if (/\u914d\u7f6e|\u7f6e\u3044\u305f|\u8a2d\u7f6e/.test(text)) return audio.place?.(/\u7206\u7af9/.test(text) ? "firecracker" : "");
if (/\u3052\u3093\u3053\u3064/.test(text)) return audio.genkotsuImpact?.();
if (/\u7206\u7af9|\u7206\u767a/.test(text)) return audio.explode?.();
if (/\u77f3/.test(text)) return audio.stoneImpact?.();
if (/\u30dc\u30fc\u30eb/.test(text)) return audio.ballHit?.();
return audio.damage?.(18);
}
if (kind === "event") {
if (/\u5973\u738b\u30a2\u30ea/.test(text)) return audio.queenAnt?.();
if (/\u30a2\u30ea|\u5de3/.test(text)) return audio.antNest?.();
if (/\u75c5|\u767a\u75c5/.test(text)) return audio.disease?.();
if (/\u56de\u5fa9|\u6cbb/.test(text)) return audio.heal?.();
}
},
log(text, kind = null, options = {}) {
const entry = {
time: this.time,
@ -529,6 +483,7 @@
kind: kind || this.inferLogKind(text),
eventType: options.eventType || "",
hiddenFromObservation: Boolean(options.hiddenFromObservation),
sound: options.sound === false ? false : true,
participants: (options.participants || []).filter(Boolean).map(t => ({
id: t.id || t.releasedLiveId || "",
liveId: t.id || t.releasedLiveId || "",
@ -558,11 +513,8 @@
if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind);
}
this.logs.unshift(entry);
this.emit("log:entry", { entry });
this.playLogSoundForEntry?.(entry);
this.logs = this.logs.slice(0, 100);
renderLog(this.logs);
if (typeof pushLogNotification === "function") pushLogNotification(entry);
this.emit("log:entry", { world: this, entry });
}
}));
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -4,21 +4,8 @@
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_spatial_budget.js");
function liveCount(list) {
let count = 0;
for (const item of list || []) if (item && !item.dead) count += 1;
return count;
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
performanceLevel() {
return 0;
},
performanceQuality() {
return { level: 0, pressure: 0, effectDensity: 1, antStride: 2, effectLimit: CONFIG.effectLimit ?? 96, itemScanLimit: 16, socialScanLimit: 8, collisionInterval: 1.8, bedConflictInterval: 3.2, statsInterval: 1.2, maxDpr: 2 };
},
beginFramePerformanceBudgets() {
this.workBudget = {
ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)),
@ -54,6 +41,16 @@
return false;
},
spatialDirtySummary() {
return {
staticItems: Boolean(this.spatialStaticItemsDirty),
dynamicItems: Boolean(this.spatialDynamicItemsDirty),
tarinai: Boolean(this.spatialTarinaiDirty),
ants: Boolean(this.spatialAntDirty),
};
},
runtimeDiagnostics() {
const behaviorCounts = {};
let forcedBehaviors = 0;
@ -63,20 +60,22 @@
if (!t || t.dead) continue;
liveTarinai += 1;
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const id = behavior?.id || t.state || "none";
const id = behavior?.actionId || t.state || "none";
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
if (behavior?.forced) forcedBehaviors += 1;
if ((t.intentLockTimer || 0) > 0.01) lockedBehaviors += 1;
if (typeof isTarinaiBehaviorValueForced === "function" ? isTarinaiBehaviorValueForced(behavior) : Boolean(behavior?.source === "forced" || behavior?.forcedId || behavior?.forcedRequest)) forcedBehaviors += 1;
if ((t.behaviorLockTimer || 0) > 0.01) lockedBehaviors += 1;
}
return {
spatial: {
version: this.spatialVersion || 0,
dirty: Boolean(this.spatialDirty),
dirtyParts: this.spatialDirtySummary?.() || null,
dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "",
dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0,
dirtyMarksTotal: this.spatialDirtyMarksTotal || 0,
rebuildsThisFrame: this.spatialRebuildsThisFrame || 0,
rebuildsTotal: this.spatialRebuildsTotal || 0,
partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0,
lastRebuildReason: this.lastSpatialRebuildReason || "",
},
render: {
@ -96,68 +95,52 @@
locked: lockedBehaviors,
counts: behaviorCounts,
},
performance: {
runtime: {
liveTarinai,
fps: window.__tarinaiFps || window.TarinaiPerformance?.fps || 0,
pressure: 0,
level: 0,
fps: window.__tarinaiFps || 0,
dpr: (typeof uiCache !== "undefined" ? uiCache?.canvasDpr : window.uiCache?.canvasDpr) || 0,
quality: null,
},
work: {
budget: this.workBudget || null,
stats: this.workStats || null,
},
scheduler: this._itemUpdateSchedulerStats || null,
perf: window.TarinaiPerf?.snapshot?.() || null,
};
},
grassLoadLevel() {
const grass = this.itemCounts?.grass || 0;
if (grass >= 180) return 3;
if (grass >= 120) return 2;
if (grass >= 72) return 1;
return 0;
},
grassUpdateFactor() {
return [1.0, 1.35, 1.85, 2.45][this.grassLoadLevel()] || 1.0;
},
grassLimit() {
const limit = Number(CONFIG.grassLimit ?? 360);
return Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : Infinity;
return window.TarinaiGround?.grassLimit?.(this.groundType || "soil", this.fieldType || "garden") ?? (CONFIG.grassLimit ?? 99);
},
isGrassLimitExempt(item) {
return Boolean(item && item.type === "grass" && (item.grassLimitExempt || item.manualGrass || item.placedByPlayer));
},
liveGrassCount() {
let total = 0;
for (const it of this.items || []) {
if (it && !it.dead && it.type === "grass" && !this.isGrassLimitExempt?.(it)) total += 1;
}
return total;
},
canAddGrass(extra = 1) {
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 360);
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
if (!Number.isFinite(limit)) return true;
return (this.liveGrassCount ? this.liveGrassCount() : 0) + Math.max(0, Number(extra) || 0) <= limit;
const current = Math.max(0, Math.floor(Number(this.itemCounts?.grass || 0)));
return current + Math.max(0, Number(extra) || 0) <= limit;
},
sortedDrawList() {
if (!this.drawList) this.drawList = [];
const antCount = liveCount(this.ants);
const tarinaiCount = liveCount(this.tarinai);
if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) {
this.drawList.length = 0;
for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t);
for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a);
this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0));
this.drawListDirty = false;
enforceGrassLimit(reason = "grass-limit") {
const limit = this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 99);
if (!Number.isFinite(limit)) return 0;
this.updateItemCounts?.(`${reason}:count`);
let kept = 0;
let removed = 0;
for (const it of this.items || []) {
if (!it || it.dead || it.type !== "grass") continue;
if (kept < limit) {
kept += 1;
continue;
}
it.amount = 0;
it.growth = 0;
it.grassStage = 0;
removed += 1;
}
return this.drawList;
if (removed > 0) {
this.markItemBucketsDirty?.(reason);
this.markSpatialDirty?.(reason);
this.markTerrainDirty?.(reason);
}
return removed;
},
ensureSpatial(reason = "read") {
@ -172,7 +155,14 @@
nearbyItems(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-items");
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch, precise);
const out = this.spatialItemScratch || [];
out.length = 0;
if (this.spatial?.nearbyInto && this.spatial.staticItemCells && this.spatial.dynamicItemCells) {
this.spatial.nearbyInto(this.spatial.staticItemCells, x, y, radius, out, precise);
this.spatial.nearbyInto(this.spatial.dynamicItemCells, x, y, radius, out, precise);
return out;
}
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, out, precise);
},
nearbyTarinai(x, y, radius, precise = false) {
@ -184,28 +174,58 @@
this.ensureSpatial?.("nearby-ants");
return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise);
},
nearbyFood(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-food");
if (this.spatial?.nearbySplitInto && this.spatial.staticFoodCells && this.spatial.dynamicFoodCells) {
return this.spatial.nearbySplitInto(this.spatial.staticFoodCells, this.spatial.dynamicFoodCells, x, y, radius, this.spatialFoodScratch, precise);
}
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
},
nearbyObstacles(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-obstacles");
if (this.spatial?.nearbySplitInto && this.spatial.staticObstacleCells && this.spatial.dynamicObstacleCells) {
return this.spatial.nearbySplitInto(this.spatial.staticObstacleCells, this.spatial.dynamicObstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
}
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
},
nearbyFood(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-food");
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
},
nearbyHazards(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-hazards");
if (this.spatial?.nearbySplitInto && this.spatial.staticHazardCells && this.spatial.dynamicHazardCells) {
return this.spatial.nearbySplitInto(this.spatial.staticHazardCells, this.spatial.dynamicHazardCells, x, y, radius, this.spatialHazardScratch, precise);
}
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
},
rebuildSpatial(force = false, reason = "manual") {
// Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial
// so repeated rebuild requests in the same phase are collapsed to one rebuild.
if (!force && this.spatialDirty === false && reason !== "manual") return false;
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
const profiler = window.TarinaiPerf;
const end = profiler?.begin?.("update.spatial") || null;
try {
const full = reason === "manual" || reason === "reset" || !this.spatialVersion || !this.spatial?.rebuildStaticItems;
if (full) {
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
} else {
const staticDirty = Boolean(this.spatialStaticItemsDirty);
const dynamicDirty = Boolean(this.spatialDynamicItemsDirty || staticDirty);
const tarinaiDirty = Boolean(this.spatialTarinaiDirty);
const antDirty = Boolean(this.spatialAntDirty);
if (staticDirty) this.spatial.rebuildStaticItems(this.items, { rebuildCombined: false });
if (dynamicDirty) this.spatial.rebuildDynamicItems(this.items, { rebuildCombined: false });
if (tarinaiDirty) this.spatial.rebuildTarinai(this.tarinai);
if (antDirty) this.spatial.rebuildAnts(this.ants || []);
this.spatialPartialRebuildsTotal = (this.spatialPartialRebuildsTotal || 0) + 1;
}
} finally {
if (end) end();
}
this.spatialDirty = false;
this.spatialStaticItemsDirty = false;
this.spatialDynamicItemsDirty = false;
this.spatialTarinaiDirty = false;
this.spatialAntDirty = false;
this.spatialDirtyReason = "";
this.spatialVersion = (this.spatialVersion || 0) + 1;
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
@ -231,13 +251,15 @@
addItem(item, reason = "add-item", opts = {}) {
if (!item) return null;
const ignoreGrassLimit = Boolean(opts.ignoreGrassLimit || opts.manualGrass || item.grassLimitExempt || item.manualGrass || item.placedByPlayer);
if (item.type === "grass" && !ignoreGrassLimit && !this.canAddGrass?.(1)) return null;
if (item.type === "grass" && ignoreGrassLimit) item.grassLimitExempt = true;
if (item.type === "grass" && !this.canAddGrass?.(1)) return null;
item.world = this;
this.items.push(item);
this.markItemBucketsDirty?.(reason);
this.markSpatialDirty?.(reason);
if (opts.terrain !== false) this.markTerrainDirty?.(reason);
if (opts.terrain !== false) {
if (item.type === "grass" || item.type === "trace" || item.type === "splat") this.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || item.radius || 24, 36), reason);
else this.markTerrainDirty?.(reason);
}
if (opts.countNow !== false && item.type) this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
return item;
},
@ -254,15 +276,6 @@
const bucket = this.itemTypeBuckets?.get?.(type);
return bucket || [];
},
liveItemsOfType(type, predicate = null) {
const bucket = this.itemsOfType(type);
if (typeof predicate !== "function") return bucket;
const out = [];
for (const item of bucket) if (item && !item.dead && predicate(item)) out.push(item);
return out;
},
updateItemCounts(reason = "manual") {
const counts = this.itemCounts;
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
@ -287,6 +300,11 @@
this.lastItemBucketRebuildReason = reason;
},
liveItemsOfType(type, predicate = null) {
const list = this.itemsOfType ? this.itemsOfType(type) : (this.items || []).filter(it => it && !it.dead && it.type === type);
return predicate ? list.filter(predicate) : list;
},
updateEffectCounts() {
const counts = this.effectCounts;
for (const key of Object.keys(counts)) counts[key] = 0;

46
js/world_tool_actions.js Normal file
View file

@ -0,0 +1,46 @@
"use strict";
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_tool_actions.js");
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
findDeleteToolTargetAt(x, y) {
let best = null;
let bestD = Infinity;
for (let i = (this.items || []).length - 1; i >= 0; i -= 1) {
const it = this.items[i];
if (!it || it.dead) continue;
const visualHit = Math.max(22, (it.r || 12) * 2.0);
const hit = this.screenSizeToWorld ? this.screenSizeToWorld(visualHit) : visualHit;
const d = distXY(x, y, it.x, it.y);
if (d < hit && d < bestD) { best = it; bestD = d; }
}
return best;
},
deleteItemAt(x, y) {
const target = this.findDeleteToolTargetAt?.(x, y);
if (!target) { showToast("消せるものがありません。"); return true; }
if (isPinType(target.type) && target.pinState === "lodged") { showToast("刺さった鋲は、つまんで抜いてください。"); return true; }
const label = toolLabel(target.type);
const removed = global.TarinaiStructureLifecycle?.deleteItem?.(this, target, {
reason: "delete-tool",
userReason: target.type === "grass_bed" ? "ベッドがなくなった" : "置きものがなくなった",
wake: true,
});
if (!removed) return true;
audio.delete?.();
this.compactItems?.();
this.updateItemCounts?.();
this.rebuildSpatial?.();
this.log?.(`${label}を消した。`, "observe");
return true;
},
useToolAt(x, y) {
if (this.tool === "delete") return this.deleteItemAt(x, y);
return false;
},
}));
})(window);

View file

@ -4,71 +4,6 @@
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_update.js");
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "grass") return 3.0 + stableUnit(item.id || item.seed || "grass", "grass-update") * 2.2;
if (item.type === "duplicator") return 2.0;
if (item.type === "water") return 1.8;
if (item.type === "trace" || item.type === "splat") return 1.2;
if (item.type === "ant_corpse") return 3.5;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
return Infinity;
}
function updateItemsScheduled(worldRef, dt) {
let ran = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead) continue;
const interval = itemUpdateInterval(it);
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
it.update(dt, worldRef);
ran += 1;
continue;
}
it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt);
if (it._scheduledUpdateDt < interval) continue;
const scheduledDt = it._scheduledUpdateDt;
it._scheduledUpdateDt = 0;
it.update(scheduledDt, worldRef);
ran += 1;
}
return ran;
}
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
if (!worldRef || !Array.isArray(list) || !list.length) return false;
const thresholdSq = threshold * threshold;
let moved = false;
for (const entity of list) {
if (!entity || entity.dead) continue;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
continue;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) continue;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
environmentScores(force = false) {
const nowScore = this.time || 0;
@ -113,43 +48,18 @@
colonyMoodDefinition(id = "relaxed") {
const defs = {
calm: { id: "calm", label: "\u304a\u3060\u3084\u304b", description: "\u793e\u4ea4\u6027\u30d7\u30e9\u30b9", effects: { personality: { sociability: 0.42 } } },
prickly: { id: "prickly", label: "\u30d4\u30ea\u30d4\u30ea", description: "\u653b\u6483\u6027\u30d7\u30e9\u30b9", effects: { personality: { aggression: 0.46 } } },
stinky: { id: "stinky", label: "\u304f\u3055\u3044", description: "\u958b\u653e\u6027\u30de\u30a4\u30ca\u30b9", effects: { personality: { openness: -0.44 } } },
weaklings: { id: "weaklings", label: "\u3056\u3053\u3060\u3089\u3051", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u4ee5\u5916\u306e\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: {} } },
zunda_party: { id: "zunda_party", label: "\u305a\u3093\u3060\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
ball_party: { id: "ball_party", label: "\u307c\u30fc\u308b\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
sun_party: { id: "sun_party", label: "\u304a\u3072\u3055\u307e\u3071\u30fc\u3066\u3043\u30fc", description: "\u65e5\u5149\u6d74\u983b\u5ea6\u5897\u52a0", effects: { personality: {} } },
ant_overrun: { id: "ant_overrun", label: "\u3042\u308a\u3060\u3089\u3051", description: "\u30a2\u30ea\u3092\u898b\u308b\u3068\u30d1\u30cb\u30c3\u30af", effects: { personality: {} } },
relaxed: { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } },
calm: { id: "calm", label: "穏やか", description: "社交性が上がる", effects: { personality: { sociability: 0.42 } } },
prickly: { id: "prickly", label: "緊張", description: "攻撃性が上がる", effects: { personality: { aggression: 0.46 } } },
stinky: { id: "stinky", label: "不衛生", description: "開放性が下がる", effects: { personality: { openness: -0.44 } } },
weaklings: { id: "weaklings", label: "弱体化", description: "ずんちどれい以外のストレスが下がる", effects: { personality: {} } },
zunda_party: { id: "zunda_party", label: "ずんだ集中", description: "社交性と開放性が上がり、ストレスが下がる", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
ball_party: { id: "ball_party", label: "ボール集中", description: "社交性と開放性が上がり、ストレスが下がる", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
sun_party: { id: "sun_party", label: "日光浴傾向", description: "日光浴の頻度が上がる", effects: { personality: {} } },
ant_overrun: { id: "ant_overrun", label: "アリ増加", description: "アリを見るとパニックになりやすい", effects: { personality: {} } },
relaxed: { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } },
};
return defs[id] || defs.relaxed;
},
evaluateColonyMood() {
const alive = (this.tarinai || []).filter(t => t && !t.dead);
const avgStress = alive.length ? alive.reduce((s, t) => s + (t.stress || 0), 0) / alive.length : 0;
const avgHunger = alive.length ? alive.reduce((s, t) => s + (t.hunger || 0), 0) / alive.length : 0;
const environment = this.environmentScores ? this.environmentScores() : { hygiene: 100 };
const fights = alive.filter(t => t.state === "fight" || (t.fightTimer || 0) > 0.04 || t.state === "intimidate" || (t.intimidateTimer || 0) > 0.04).length;
const corpseCount = (this.itemCounts?.ant_corpse || 0) + Math.max(0, Math.round((this.deadCount || 0) * 0.5));
const slaveRatio = alive.length ? alive.filter(t => t.isZunchiSlave).length / alive.length : 0;
const candidates = [];
if (alive.length && avgStress <= 28 && avgHunger <= 34) candidates.push("calm");
if (fights >= 2 || corpseCount >= 4) candidates.push("prickly");
if ((environment.hygiene || 0) <= 42) candidates.push("stinky");
if (alive.length && slaveRatio >= 0.45) candidates.push("weaklings");
if ((this.itemCounts?.sweet || 0) >= 30) candidates.push("zunda_party");
if ((this.itemCounts?.ball || 0) >= 10) candidates.push("ball_party");
if (alive.some(t => t.state === "sunbath" || (t.sunbathTimer || 0) > 0.1)) candidates.push("sun_party");
if ((this.itemCounts?.ant_nest || 0) >= 1) candidates.push("ant_overrun");
const nextId = candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : "relaxed";
this.colonyMood = this.colonyMoodDefinition(nextId);
this.lastColonyMoodDay = this.day || 1;
this.log?.(`\u4eca\u65e5\u306e\u30b3\u30ed\u30cb\u30fc\u306e\u96f0\u56f2\u6c17\u306f\u300c${this.colonyMood.label}\u300d\u3002`, "event");
return this.colonyMood;
},
observeHighlightKind(t) {
const s = this.selected;
if (!s || !t || t === s || t.dead || this.tool !== "observe") return "";
@ -197,56 +107,6 @@
return conflicts;
},
nestBoxOccupants(box, limit = 5) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
},
pointerNestBoxInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
if (!best) return null;
const occupants = this.nestBoxOccupants(best, Infinity);
const capacity = this.nestBoxCapacity(best);
return { box: best, occupants, capacity };
},
pointerOwnedBedInfo() {
const p = this.pointer;
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "grass_bed") continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; }
}
if (!best) return null;
const owner = this.liveTarinaiById?.(best.ownerId) || null;
return { bed: best, owner };
},
nestBoxToolTipCount() {
const pointed = this.pointerNestBoxInfo?.();
if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) };
const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box");
if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() };
let current = 0, max = 0;
for (const box of boxes) {
current += this.nestBoxOccupants(box, Infinity).length;
max += this.nestBoxCapacity(box);
}
return { current, max };
},
blastZunchiFrom(x, y, radius, strength = 1) {
let moved = 0;
for (const it of this.nearbyItems(x, y, radius + 60)) {
@ -272,146 +132,9 @@
},
update(dt) {
if (this.paused) return;
dt *= this.speed;
dt = Math.min(dt, 0.12 * this.speed);
const prevTime = this.time;
this.time += dt;
const previousDay = this.day || 1;
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
const prevHour = this.hourOfDay ? this.hourOfDay(prevTime) : 0;
const nowHour = this.hourOfDay ? this.hourOfDay(this.time) : 0;
const crossedColonyEvalHour = (this.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6);
const pendingColonyMoodEvaluation = crossedColonyEvalHour && this.lastColonyMoodDay !== this.day;
if (this.day !== previousDay) {
for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {}, byCause: {} };
}
if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
this.shakeTimer = Math.max(0, (this.shakeTimer || 0) - dt);
if ((this.shakeTimer || 0) <= 0) { this.shakeDuration = 0; this.shakeStrength = 0; }
this.updateWeather(dt);
this.foodSpoilagePenalty = Math.max(0, (this.foodSpoilagePenalty || 0) - dt * 0.010);
this.updateTerrainDirtyState?.(dt);
this.spatialRebuildsThisFrame = 0;
this.spatialDirtyMarksThisFrame = 0;
this.ensureSpatial?.("update-start");
this.beginFramePerformanceBudgets?.();
const itemCountBefore = this.items.length;
updateItemsScheduled(this, dt);
const itemsMoved = markSpatialDirtyIfMoved(this, this.items, "items-moved", 0.25);
if (this.itemCounts?.ball) {
this.ensureSpatial?.("ball-ball-collisions");
this.resolveBallBallCollisions(dt);
this.markSpatialDirty?.("ball-ball-collisions");
}
this.updateAnts?.(dt);
const antsMoved = markSpatialDirtyIfMoved(this, this.ants || [], "ants-moved", 0.25);
if (itemsMoved || antsMoved || this.spatialDirty) this.ensureSpatial?.("post-mobile-item-update");
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
const now = this.time || 0;
const bedConflictInterval = 3.2;
if (now >= (this.nextBedConflictCheckAt || 0)) {
this.nextBedConflictCheckAt = now + bedConflictInterval;
this.workStats && (this.workStats.bedConflictRuns = (this.workStats.bedConflictRuns || 0) + 1);
this.resolveOwnedBedSleepConflicts?.();
} else {
this.workStats && (this.workStats.bedConflictSkips = (this.workStats.bedConflictSkips || 0) + 1);
}
markSpatialDirtyIfMoved(this, this.tarinai, "tarinai-moved", 0.25);
this.ensureSpatial?.("post-tarinai-update");
this.limitBallChasers();
this.resolveBallInteractions(dt);
const collisionInterval = 1.8;
if (now >= (this.nextTarinaiCollisionCheckAt || 0)) {
this.nextTarinaiCollisionCheckAt = now + collisionInterval;
this.workStats && (this.workStats.collisionRuns = (this.workStats.collisionRuns || 0) + 1);
this.resolveTarinaiHighSpeedCollisions(dt);
} else {
this.workStats && (this.workStats.collisionSkips = (this.workStats.collisionSkips || 0) + 1);
}
this.compactTimer += dt;
const compactInterval = 2.5;
let itemsCompacted = false;
if (this.compactTimer >= compactInterval) {
itemsCompacted = this.compactItems();
this.compactEffects();
this.compactTarinai();
this.compactAnts?.();
this.compactTimer = 0;
}
this.countsTimer += dt;
const countsInterval = 2.0;
if (this.countsTimer >= countsInterval || itemsCompacted || this.items.length !== itemCountBefore) {
this.updateItemCounts();
this.updateEffectCounts();
this.countsTimer = 0;
}
if ((this.outOfBoundsCheckAt || -999) + 5.0 <= this.time) {
this.outOfBoundsCheckAt = this.time;
this.sanitizeOutOfBounds();
}
this.drawSortTimer += dt;
const sortInterval = 0.25;
if (this.drawSortTimer >= sortInterval) {
this.drawListDirty = true;
this.drawSortTimer = 0;
}
const phase = this.phaseName();
if (phase !== this.lastPhase) {
this.lastPhase = phase;
this.emit("world:phase", { phase });
audio.phase();
}
if (this.tarinai.length > 0 && this.tarinai.length <= 10 && this.time - (this.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = this.edgeSpawnPoint(null, null, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 });
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
audio.birth?.();
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
}
if (Math.random() < dt * 0.109) {
const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
allowOriginal: true,
minRadius: 28,
maxRadius: 140,
attempts: 36,
});
if (spot) {
const sprout = new Item("grass", spot.x, spot.y);
sprout.amount = rand(30, 62);
sprout.growth = rand(0.18, 0.38);
if (this.addItem) this.addItem(sprout, "grass-spawn");
else this.items.push(sprout);
}
}
// Rain visuals are diagonal line streaks in render.js. This branch creates
// horizontal oval water items spawned by rain; it intentionally does not
// share the rain overlay renderer.
if (window.TarinaiWeatherSystem?.shouldDropRainWater?.(this, dt)) {
const drop = window.TarinaiWeatherSystem.createRainWaterItem(this);
this.addItem?.(drop, "rain-water") || this.items.push(drop);
this.emit?.("rain:itemDropped", { item: drop, type: "water" });
}
this.lastRuntimeDiagnostics = this.runtimeDiagnostics?.() || null;
const phases = window.TarinaiWorldUpdatePhases;
if (!phases?.update) throw new Error("TarinaiWorldUpdatePhases is not available");
phases.update(this, dt);
},
sanitizeOutOfBounds() {

429
js/world_update_phases.js Normal file
View file

@ -0,0 +1,429 @@
"use strict";
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = [
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "water_bowl",
];
function forScheduledItems(worldRef, visitor) {
if (!worldRef || typeof visitor !== "function") return 0;
let count = 0;
if (!worldRef.itemsOfType) {
for (const it of worldRef.items || []) {
if (!it || it.dead || it.type === "grass") continue;
visitor(it);
count += 1;
}
return count;
}
for (const type of SCHEDULED_ITEM_TYPES) {
const bucket = worldRef.itemsOfType(type);
for (const it of bucket || []) {
if (!it || it.dead) continue;
visitor(it);
count += 1;
}
}
return count;
}
function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0;
if (item.isStructure && item.type === "grass_bed") return 3.0;
if ((item.dropTimer || 0) > 0 || Math.hypot(item.vx || 0, item.vy || 0) > 0.08) return 0;
if (item.type === "zunchi") return 2.4;
if (item.type === "grass") return Infinity;
if (item.type === "duplicator") return 2.0;
if (item.type === "water") return 1.8;
if (item.type === "trace" || item.type === "splat") return 1.2;
if (item.type === "ant_corpse") return 3.5;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) return 5.0;
return Infinity;
}
function updateItemsScheduled(worldRef, dt) {
if (global.TarinaiItemUpdateScheduler?.run) return global.TarinaiItemUpdateScheduler.run(worldRef, dt);
let ran = 0;
forScheduledItems(worldRef, (it) => {
const interval = itemUpdateInterval(it);
if (!Number.isFinite(interval)) return;
if (interval <= 0) {
it.update(dt, worldRef);
ran += 1;
return;
}
it._scheduledUpdateDt = Math.min(10, (it._scheduledUpdateDt || 0) + dt);
if (it._scheduledUpdateDt < interval) return;
const scheduledDt = it._scheduledUpdateDt;
it._scheduledUpdateDt = 0;
it.update(scheduledDt, worldRef);
ran += 1;
});
return ran;
}
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
if (!worldRef || !Array.isArray(list) || !list.length) return false;
const thresholdSq = threshold * threshold;
let moved = false;
for (const entity of list) {
if (!entity || entity.dead) continue;
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
continue;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) continue;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
function markScheduledItemsMoved(worldRef, reason = "items-moved", threshold = 0.35) {
if (!worldRef) return false;
const thresholdSq = threshold * threshold;
let moved = false;
const tracked = global.TarinaiItemUpdateScheduler?.trackedDynamicItems?.(worldRef);
const source = tracked && tracked.length ? tracked : null;
const visit = (entity) => {
const x = Number.isFinite(entity.x) ? entity.x : 0;
const y = Number.isFinite(entity.y) ? entity.y : 0;
const oldX = entity._spatialTrackX;
const oldY = entity._spatialTrackY;
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
return;
}
const dx = x - oldX;
const dy = y - oldY;
if (dx * dx + dy * dy <= thresholdSq) return;
entity._spatialTrackX = x;
entity._spatialTrackY = y;
moved = true;
};
if (source) {
for (const entity of source) if (entity && !entity.dead) visit(entity);
} else {
forScheduledItems(worldRef, visit);
}
if (moved) worldRef.markSpatialDirty?.(reason);
return moved;
}
function updateRandomGrassGrowth(worldRef, dt) {
if (!worldRef || !global.TarinaiGrass) return false;
if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return false;
worldRef.grassGrowthTimer = (worldRef.grassGrowthTimer || 0) + dt;
const field = worldRef.fieldType || "garden";
const growthSpeedMul = 2.0;
const interval = (field === "park" ? 1.8 : (field === "cage" ? 3.2 : 2.4)) / growthSpeedMul;
if (worldRef.grassGrowthTimer < interval) return false;
worldRef.grassGrowthTimer = 0;
const grasses = worldRef.itemsOfType ? worldRef.itemsOfType("grass") : (worldRef.items || []).filter(it => it && it.type === "grass");
if (!grasses || !grasses.length) return false;
const tries = Math.min(12, grasses.length);
for (let i = 0; i < tries; i++) {
const it = grasses[Math.floor(Math.random() * grasses.length)];
if (!it || it.dead || (it.amount || 0) <= 0) continue;
if (worldRef.grassBlockedAt?.(it.x, it.y, it)) {
if (global.TarinaiGrass.regress(it, 1) > 0) {
worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-blocked") || worldRef.markTerrainDirty?.("random-grass-blocked");
worldRef.markItemBucketsDirty?.("random-grass-blocked");
return true;
}
continue;
}
if (global.TarinaiGrass.advance(it, 1)) {
worldRef.markTerrainDirtyAt?.(it.x, it.y, Math.max(it.r || 24, 36), "random-grass-grow") || worldRef.markTerrainDirty?.("random-grass-grow");
return true;
}
}
return false;
}
function normalizeDelta(worldRef, dt) {
if (!worldRef || worldRef.paused) return null;
let frameDt = dt * worldRef.speed;
frameDt = Math.min(frameDt, 0.12 * worldRef.speed);
return frameDt;
}
function updateClock(worldRef, dt) {
const prevTime = worldRef.time;
worldRef.time += dt;
const previousDay = worldRef.day || 1;
worldRef.day = Math.floor(worldRef.time / CONFIG.dayLength) + 1;
const prevHour = worldRef.hourOfDay ? worldRef.hourOfDay(prevTime) : 0;
const nowHour = worldRef.hourOfDay ? worldRef.hourOfDay(worldRef.time) : 0;
const crossedColonyEvalHour = (worldRef.day !== previousDay) ? (nowHour >= 6) : (prevHour < 6 && nowHour >= 6);
const pendingColonyMoodEvaluation = crossedColonyEvalHour && worldRef.lastColonyMoodDay !== worldRef.day;
if (worldRef.day !== previousDay) {
for (const t of worldRef.tarinai || []) if (t) t.personalityDaily = { day: worldRef.day, total: 0, byKey: {}, byCause: {} };
}
return { prevTime, previousDay, prevHour, nowHour, crossedColonyEvalHour, pendingColonyMoodEvaluation };
}
function updateFrameSignals(worldRef, dt) {
if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4);
worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt);
if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; }
}
function updateEnvironment(worldRef, dt) {
worldRef.updateWeather(dt);
worldRef.foodSpoilagePenalty = Math.max(0, (worldRef.foodSpoilagePenalty || 0) - dt * 0.010);
worldRef.updateTerrainDirtyState?.(dt);
}
function prepareSpatialFrame(worldRef) {
worldRef.spatialRebuildsThisFrame = 0;
worldRef.spatialDirtyMarksThisFrame = 0;
worldRef.ensureSpatial?.("update-start");
worldRef.beginFramePerformanceBudgets?.();
}
function updateItemsAndAnts(worldRef, dt) {
const itemCountBefore = worldRef.items.length;
updateItemsScheduled(worldRef, dt);
updateRandomGrassGrowth(worldRef, dt);
const itemsMoved = markScheduledItemsMoved(worldRef, "items-moved", 0.25);
if (worldRef.itemCounts?.ball) {
worldRef.ensureSpatial?.("ball-ball-collisions");
worldRef.resolveBallBallCollisions(dt);
worldRef.markSpatialDirty?.("ball-ball-collisions");
}
worldRef.updateAnts?.(dt);
const antsMoved = markSpatialDirtyIfMoved(worldRef, worldRef.ants || [], "ants-moved", 0.25);
if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update");
return { itemCountBefore, itemsMoved, antsMoved };
}
function updateEffects(worldRef, dt) {
for (const ef of worldRef.effects) ef.update(dt);
}
function updateVisibleWorldRect(worldRef, margin = 120) {
const scale = worldRef.viewScale ? worldRef.viewScale() : 1;
const vw = (worldRef.viewportW || worldRef.w || 1000) / Math.max(0.01, scale);
const vh = (worldRef.viewportH || worldRef.h || 720) / Math.max(0.01, scale);
const cameraX = worldRef.cameraX || 0;
const cameraY = worldRef.cameraY || 0;
return { left: cameraX - margin, top: cameraY - margin, right: cameraX + vw + margin, bottom: cameraY + vh + margin };
}
function pointInRect(x, y, rect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
function tarinaiUpdateInterval(worldRef, tarinai, visibleRect) {
if (!tarinai || tarinai.dead) return Infinity;
if (worldRef.selected === tarinai) return 0;
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0;
if (pointInRect(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0;
const count = (worldRef.tarinai || []).length;
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
if (tier === "high" && count <= 36) return 0;
const farRect = updateVisibleWorldRect(worldRef, 520);
return pointInRect(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70;
}
function updateCreatures(worldRef, dt) {
const end = global.TarinaiPerf?.begin?.("update.tarinai") || null;
const visibleRect = updateVisibleWorldRect(worldRef, 160);
try {
for (const t of worldRef.tarinai) {
if (!t || t.dead) continue;
const interval = tarinaiUpdateInterval(worldRef, t, visibleRect);
if (!Number.isFinite(interval)) continue;
if (interval <= 0) {
const runDt = Math.min(1.4, (t._updateAccum || 0) + dt);
t._updateAccum = 0;
t.update(runDt);
continue;
}
t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt);
t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7);
if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) {
const runDt = Math.max(dt, t._updateAccum || dt);
t._updateAccum = 0;
t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval;
t.update(runDt);
}
}
} finally {
if (end) end();
}
const now = worldRef.time || 0;
const bedConflictInterval = 3.2;
if (now >= (worldRef.nextBedConflictCheckAt || 0)) {
worldRef.nextBedConflictCheckAt = now + bedConflictInterval;
worldRef.workStats && (worldRef.workStats.bedConflictRuns = (worldRef.workStats.bedConflictRuns || 0) + 1);
worldRef.resolveOwnedBedSleepConflicts?.();
} else {
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
}
markSpatialDirtyIfMoved(worldRef, worldRef.tarinai, "tarinai-moved", 0.25);
worldRef.ensureSpatial?.("post-tarinai-update");
worldRef.limitBallChasers();
worldRef.resolveBallInteractions(dt);
const collisionInterval = 1.8;
if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval;
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
worldRef.resolveTarinaiHighSpeedCollisions(dt);
} else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
}
}
function runMaintenance(worldRef, dt, itemCountBefore) {
worldRef.compactTimer += dt;
const compactInterval = 2.5;
let itemsCompacted = false;
if (worldRef.compactTimer >= compactInterval) {
itemsCompacted = worldRef.compactItems();
worldRef.compactEffects();
worldRef.compactTarinai();
worldRef.compactAnts?.();
worldRef.compactTimer = 0;
}
worldRef.countsTimer += dt;
const countsInterval = 2.0;
if (worldRef.countsTimer >= countsInterval || itemsCompacted || worldRef.items.length !== itemCountBefore) {
worldRef.updateItemCounts();
worldRef.updateEffectCounts();
worldRef.countsTimer = 0;
}
if ((worldRef.time || 0) >= (worldRef.nextGrassLimitCheckAt || 0)) {
worldRef.nextGrassLimitCheckAt = (worldRef.time || 0) + 3.0;
const removedGrass = worldRef.enforceGrassLimit?.("periodic-grass-limit") || 0;
if (removedGrass > 0) {
worldRef.compactItems();
worldRef.updateItemCounts();
}
}
if ((worldRef.outOfBoundsCheckAt || -999) + 5.0 <= worldRef.time) {
worldRef.outOfBoundsCheckAt = worldRef.time;
worldRef.sanitizeOutOfBounds();
}
worldRef.drawSortTimer += dt;
const sortInterval = 0.25;
if (worldRef.drawSortTimer >= sortInterval) {
worldRef.drawListDirty = true;
worldRef.drawSortTimer = 0;
}
}
function emitPhaseChange(worldRef) {
const phase = worldRef.phaseName();
if (phase !== worldRef.lastPhase) {
worldRef.lastPhase = phase;
worldRef.emit("world:phase", { world: worldRef, phase });
}
}
function spawnAmbientTarinai(worldRef, dt) {
if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = worldRef.edgeSpawnPoint(null, null, 42, true);
const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 });
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
}
}
function spawnAmbientGrass(worldRef, dt) {
if ((worldRef.groundGrassMultiplier?.() ?? 1) <= 0) return;
if ((worldRef.canAddGrass?.(1) ?? true) && Math.random() < dt * 0.045) {
const spot = worldRef.findGrassPlantingSpot(rand(60, worldRef.w - 60), rand(60, worldRef.h - 60), {
allowOriginal: true,
minRadius: 28,
maxRadius: 140,
attempts: 18,
});
if (spot) {
const sprout = new Item("grass", spot.x, spot.y);
global.TarinaiGrass?.setStage?.(sprout, 0, { force: true });
if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn");
else worldRef.items.push(sprout);
}
}
}
function spawnRainWater(worldRef, dt) {
// Rain visuals are diagonal line streaks in render.js. This branch creates
// horizontal oval water items spawned by rain; it intentionally does not
// share the rain overlay renderer.
if (global.TarinaiWeatherSystem?.shouldDropRainWater?.(worldRef, dt)) {
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef);
worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop);
worldRef.emit?.("rain:itemDropped", { item: drop, type: "water" });
}
}
function finalizeDiagnostics(worldRef) {
worldRef.lastRuntimeDiagnostics = worldRef.runtimeDiagnostics?.() || null;
}
function update(worldRef, dt) {
const frameDt = normalizeDelta(worldRef, dt);
if (frameDt === null) return;
updateClock(worldRef, frameDt);
updateFrameSignals(worldRef, frameDt);
updateEnvironment(worldRef, frameDt);
prepareSpatialFrame(worldRef);
const mobile = updateItemsAndAnts(worldRef, frameDt);
updateEffects(worldRef, frameDt);
updateCreatures(worldRef, frameDt);
runMaintenance(worldRef, frameDt, mobile.itemCountBefore);
emitPhaseChange(worldRef);
spawnAmbientTarinai(worldRef, frameDt);
spawnAmbientGrass(worldRef, frameDt);
spawnRainWater(worldRef, frameDt);
finalizeDiagnostics(worldRef);
}
global.TarinaiWorldUpdatePhases = Object.freeze({
update,
phases: Object.freeze({
normalizeDelta,
updateClock,
updateFrameSignals,
updateEnvironment,
prepareSpatialFrame,
updateItemsAndAnts,
updateEffects,
updateCreatures,
runMaintenance,
emitPhaseChange,
spawnAmbientTarinai,
spawnAmbientGrass,
spawnRainWater,
finalizeDiagnostics,
}),
});
})(window);

View file

@ -45,19 +45,49 @@
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
this.lastSpatialDirtyReason = reason;
const r = String(reason || "manual");
if (/tarinai-moved|post-tarinai|creature/i.test(r)) {
this.spatialTarinaiDirty = true;
} else if (/ants?-moved|post-ant|ant/i.test(r) && !/ant_nest|ant-nest/i.test(r)) {
this.spatialAntDirty = true;
} else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi/i.test(r)) {
this.spatialDynamicItemsDirty = true;
} else {
this.spatialStaticItemsDirty = true;
this.spatialDynamicItemsDirty = true;
this.spatialTarinaiDirty = true;
this.spatialAntDirty = true;
}
},
markTerrainDirty(reason = "manual") {
this.terrainDirty = true;
this.terrainDirtyGlobal = true;
this.terrainDirtyReason = reason;
this.terrainVersion = (this.terrainVersion || 0) + 1;
this.terrainLastDirtyAt = this.time || 0;
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion });
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion, global: true });
},
markTerrainDirtyAt(x, y, radius = 48, reason = "manual") {
const cx = Math.floor((Number(x) || 0) / 256);
const cy = Math.floor((Number(y) || 0) / 256);
const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256));
if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set();
for (let yy = cy - cr; yy <= cy + cr; yy++) {
for (let xx = cx - cr; xx <= cx + cr; xx++) this.terrainDirtyChunks.add(`${xx}:${yy}`);
}
this.terrainDirty = true;
this.terrainDirtyReason = reason;
this.terrainVersion = (this.terrainVersion || 0) + 1;
this.terrainLastDirtyAt = this.time || 0;
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion, global: false, x, y, radius });
return true;
},
terrainSignature() {
const counts = this.itemCounts || {};
return `${this.fieldType || "garden"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`;
return `${this.fieldType || "garden"}:${this.groundType || "soil"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`;
},
updateTerrainDirtyState(dt = 0) {
@ -90,6 +120,45 @@
return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden;
},
groundDefinition() {
return window.TarinaiGround?.definition?.(this.groundType || "soil") || GROUND_TYPES?.[this.groundType || "soil"] || GROUND_TYPES?.soil || { id: "soil", label: "土", stressMultiplier: 1, grassMultiplier: 1 };
},
groundLabel() {
return window.TarinaiGround?.label?.(this.groundType || "soil") || this.groundDefinition().label || "土";
},
groundStressMultiplier() {
return window.TarinaiGround?.stressMultiplier?.(this.groundType || "soil") || 1;
},
groundGrassMultiplier() {
return window.TarinaiGround?.grassMultiplier?.(this.groundType || "soil") ?? 1;
},
setGroundType(id = "soil", opts = {}) {
const next = window.TarinaiGround?.exists?.(id) ? id : "soil";
const previous = this.groundType || "soil";
this.groundType = next;
if (next !== previous || opts.force) {
this.markTerrainDirty?.("ground-type");
this.enforceGrassLimit?.("ground-type");
this.updateItemCounts?.("ground-type");
this.drawListDirty = true;
if (!opts.silent) {
const label = this.groundLabel?.() || "土";
this.log?.(`じめん: ${label}`);
if (typeof showToast === "function") showToast(`じめんを ${label} にしました。`);
}
this.emit?.("ground:changed", { previous, next, definition: this.groundDefinition?.() });
}
return this.groundDefinition?.();
},
cycleGroundType() {
return this.setGroundType(window.TarinaiGround?.nextId?.(this.groundType || "soil") || "soil");
},
fieldWorldScale() {
const field = this.fieldDefinition();
// Field type defines the logical simulation area. Wheel zoom must not
@ -100,13 +169,6 @@
isFenceType(type = "") {
return type === "fence_v" || type === "fence_h";
},
liveFenceCount() {
let count = 0;
for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1;
return count;
},
baseZoomMin() {
return CONFIG.fieldZoomMin || 0.42;
},
@ -151,8 +213,8 @@
},
screenSizeToWorld(size) {
// Kept as a compatibility shim for older hit-test call sites. The values
// currently passed here are already logical world units, not CSS pixels.
// Hit tests use logical world units here, so this adapter intentionally
// returns the supplied size unchanged.
return size;
},

Some files were not shown because too many files have changed in this diff Show more