Compare commits
5 commits
8bd4405fe8
...
434f07cc4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 434f07cc4e | |||
| 2741b93dea | |||
| d875df8600 | |||
| 9ef1b8dbb1 | |||
| d5c661bc22 |
109 changed files with 11034 additions and 6918 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.tmp-edge-profile/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local browser smoke/probe artifacts.
|
||||
*probe*.html
|
||||
*probe*.png
|
||||
*smoke*.html
|
||||
*smoke*.png
|
||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
level=none expiry=0
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Patch Notes 15.24.12
|
||||
|
||||
- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers.
|
||||
- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs.
|
||||
- Re-routed love mochi, fight mochi, and sleep drug effects through forced need actions.
|
||||
- Converted eating, panic, fighting, intimidation, and birth ritual display/scheduling to behavior-backed actions.
|
||||
- Split social need pressure internally into bond/family/mate/conflict/fear parts and used those parts in social action selection.
|
||||
- Weighted fulfill action selection by its internal reason parts.
|
||||
- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced.
|
||||
- UI action text now prefers `activeBehavior` over stale thoughts or intent strings.
|
||||
|
||||
## 15.24.14
|
||||
- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。
|
||||
- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。
|
||||
- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。
|
||||
- 睡眠欲求を概日圧 `circadianSleepPressure` 中心に変更。夜は大きく上昇、昼下がりは微増、それ以外は減衰。
|
||||
- 初期生成時、夜なら睡眠欲求が高めになるよう初期エネルギーと概日圧を調整。
|
||||
51
README.md
51
README.md
|
|
@ -1,51 +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.20.16`.
|
||||
|
||||
## 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/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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "15.24.14",
|
||||
"version": "16.07.09",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -10,29 +10,43 @@
|
|||
"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",
|
||||
"js/tarinai_action_spec.js",
|
||||
"js/tarinai_behavior_state.js",
|
||||
"js/tarinai_identity_social.js",
|
||||
"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",
|
||||
|
|
@ -45,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",
|
||||
|
|
@ -54,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",
|
||||
|
|
@ -77,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 |
BIN
assets/sounds/voice_007_sunbath_pokapoka.wav
Normal file
BIN
assets/sounds/voice_007_sunbath_pokapoka.wav
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 278 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
134
css/base.css
134
css/base.css
|
|
@ -216,7 +216,7 @@ h1 {
|
|||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 252, 245, 0.96);
|
||||
border: 1px solid rgba(84,68,48,0.16);
|
||||
|
|
@ -309,7 +309,7 @@ h1 {
|
|||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
|
|
@ -346,59 +346,84 @@ h1 {
|
|||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0,1fr));
|
||||
grid-template-columns: repeat(3, minmax(0,1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool {
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 8px 8px 42px;
|
||||
min-height: 46px;
|
||||
--tool-main-icon-size: 30px;
|
||||
--tool-watermark-size: 0px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-rows: 27px minmax(1.05em, auto);
|
||||
align-items: start;
|
||||
justify-items: center;
|
||||
gap: 0;
|
||||
padding: 3px 5px 4px;
|
||||
min-height: 36px;
|
||||
font-size: 12px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.015em;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 7px center, center;
|
||||
background-size: 30px 30px, cover;
|
||||
background-position: center 4px, center;
|
||||
background-size: var(--tool-main-icon-size) var(--tool-main-icon-size), cover;
|
||||
}
|
||||
.tool::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
left: 50%;
|
||||
top: 3px;
|
||||
width: var(--tool-main-icon-size);
|
||||
height: var(--tool-main-icon-size);
|
||||
transform: translateX(-50%);
|
||||
background: var(--tool-icon) center / contain no-repeat;
|
||||
opacity: 0.92;
|
||||
opacity: 0.98;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
|
||||
opacity: 0.10;
|
||||
background: none;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.tool-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
grid-row: 2;
|
||||
align-self: start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 1.12;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.tool.tool-has-canvas-icon { padding-left: 42px; --tool-icon: none; }
|
||||
.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: 3px;
|
||||
top: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transform: translateY(-50%);
|
||||
left: 50%;
|
||||
top: -5px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool > span:not(.tool-size-badge) { display: none; }
|
||||
.tool-sprite-watermark { display: none !important; }
|
||||
.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); }
|
||||
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); }
|
||||
.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.webp"); }
|
||||
|
|
@ -442,8 +467,8 @@ h1 {
|
|||
}
|
||||
.signboard-editor.hidden { display: none; }
|
||||
.signboard-editor-panel {
|
||||
width: min(430px, calc(100vw - 28px));
|
||||
border-radius: 18px;
|
||||
width: min(220px, calc(100vw - 28px));
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96));
|
||||
border: 1px solid rgba(94, 70, 42, 0.20);
|
||||
|
|
@ -476,16 +501,23 @@ h1 {
|
|||
}
|
||||
.signboard-editor-close:hover { background: rgba(255,248,232,0.96); }
|
||||
#signboardEditorText {
|
||||
width: 100%;
|
||||
width: 7.4em;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
resize: none;
|
||||
min-height: 88px;
|
||||
max-height: 88px;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(84,68,48,0.18);
|
||||
background: rgba(255,255,255,0.84);
|
||||
padding: 10px 11px;
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
|
||||
font: 15px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
|
||||
letter-spacing: 0.03em;
|
||||
outline: none;
|
||||
}
|
||||
#signboardEditorText:focus {
|
||||
|
|
@ -494,6 +526,8 @@ h1 {
|
|||
}
|
||||
.signboard-editor-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 8px;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
|
|
@ -501,8 +535,42 @@ h1 {
|
|||
}
|
||||
.signboard-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
120
css/layout.css
120
css/layout.css
|
|
@ -3,78 +3,24 @@
|
|||
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
|
||||
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); }
|
||||
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); }
|
||||
.tool[data-tool="poke"] { --tool-icon: none; }
|
||||
.tool[data-tool="poke"]::before {
|
||||
content: "\1F448";
|
||||
background: none;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="poke"]::after {
|
||||
content: "\1F448";
|
||||
background: none;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
opacity: 0.11;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.tool[data-tool="pinch"] { --tool-icon: none; }
|
||||
.tool[data-tool="pinch"]::before {
|
||||
content: "\1F90F";
|
||||
background: none;
|
||||
font-size: 25px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="pinch"]::after {
|
||||
content: "\1F90F";
|
||||
background: none;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.tool[data-tool="water_hose"] { --tool-icon: none; }
|
||||
.tool[data-tool="water_hose"]::before {
|
||||
content: "\1F6BF";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="water_hose"]::after {
|
||||
content: "\1F6BF";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 46px;
|
||||
line-height: 1;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
|
||||
.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); }
|
||||
|
||||
.tool[data-tool="poke"] { --tool-emoji: "\1F448"; }
|
||||
.tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; }
|
||||
.tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; }
|
||||
.tool.tool-emoji-icon { --tool-icon: none; }
|
||||
.tool.tool-emoji-icon::before {
|
||||
content: var(--tool-emoji);
|
||||
background: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 29px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool.tool-emoji-icon::after { content: none; display: none; }
|
||||
.tool.selected {
|
||||
background: linear-gradient(180deg, #ffffff, #efffde);
|
||||
border-color: rgba(103, 178, 44, 0.52);
|
||||
|
|
@ -91,8 +37,8 @@
|
|||
.tool-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.tool-head h2 { margin-bottom: 0; }
|
||||
.btn.mini { padding: 6px 10px; font-size: 12px; }
|
||||
.tool-body { margin-top: 12px; }
|
||||
.tool-palette { display: flex; flex-direction: column; gap: 9px; }
|
||||
.tool-body { margin-top: 10px; }
|
||||
.tool-palette { display: flex; flex-direction: column; gap: 8px; }
|
||||
.tool-category {
|
||||
border: 1px solid rgba(84,68,48,0.10);
|
||||
border-radius: 14px;
|
||||
|
|
@ -104,7 +50,7 @@
|
|||
border: 0;
|
||||
border-bottom: 1px solid rgba(84,68,48,0.08);
|
||||
background: rgba(255,252,244,0.66);
|
||||
padding: 8px 10px;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
|
|
@ -112,7 +58,7 @@
|
|||
}
|
||||
.tool-category-toggle::before { content: "\25be "; display: inline-block; width: 16px; color: var(--muted); }
|
||||
.tool-category.collapsed .tool-category-toggle::before { content: "\25b8 "; }
|
||||
.tool-category-body { padding: 8px; }
|
||||
.tool-category-body { padding: 7px; }
|
||||
.tool-category.collapsed .tool-category-body { display: none; }
|
||||
|
||||
.selected-data-groups {
|
||||
|
|
@ -146,14 +92,14 @@
|
|||
.selected-category-body .info-row:last-child { border-bottom: 0; }
|
||||
.tool-size-badge {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
bottom: 6px;
|
||||
min-width: 20px;
|
||||
padding: 2px 5px;
|
||||
right: 4px;
|
||||
bottom: 3px;
|
||||
min-width: 16px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(42,36,29,0.68);
|
||||
background: rgba(42,36,29,0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-size: 8.5px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px rgba(42,36,29,0.16);
|
||||
|
|
@ -321,7 +267,7 @@ meter {
|
|||
margin-right: 6px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
background: rgba(84,68,48,0.08);
|
||||
|
|
@ -373,18 +319,6 @@ meter {
|
|||
.tool-category[data-tool-category="hazard"] { --tool-cat-a: rgba(255,230,224,0.84); --tool-cat-b: rgba(250,207,197,0.78); --tool-cat-line: rgba(189,88,66,0.30); }
|
||||
.tool-category { border-color: var(--tool-cat-line, rgba(84,68,48,0.10)); background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28)); }
|
||||
.tool-category-toggle { background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54))); border-bottom-color: var(--tool-cat-line, rgba(84,68,48,0.08)); }
|
||||
.tool[data-tool="ball"] { --tool-icon: none; }
|
||||
.tool[data-tool="ball"]::before {
|
||||
content: "⚽";
|
||||
background: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
}
|
||||
.tool[data-tool="ball"]::after { background: none; }
|
||||
@media (min-width: 921px) {
|
||||
.mobile-mode-controls { display: none !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +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); }
|
||||
|
||||
.collapsible-card { padding-top: 10px; }
|
||||
.panel-card-toggle { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 0; border-radius: 14px; padding: 10px 12px; cursor: pointer; font: inherit; color: var(--ink); background: rgba(255,255,255,.48); box-shadow: inset 0 1px 0 rgba(255,255,255,.54); }
|
||||
.panel-card-title { font-size: 17px; font-weight: 700; letter-spacing: .03em; }
|
||||
.panel-card-state { font-size: 12px; color: rgba(98,82,64,.78); padding-left: 16px; position: relative; }
|
||||
.panel-card-state::before { content: "▾"; position: absolute; left: 0; top: 50%; transform: translateY(-52%); color: rgba(98,82,64,.72); }
|
||||
.collapsible-card.collapsed .panel-card-state::before { content: "▸"; }
|
||||
.collapsible-card.collapsed .panel-card-body { display: none; }
|
||||
.panel-card-body { padding-top: 12px; }
|
||||
.tool-card { background: linear-gradient(180deg, rgba(255,247,252,.92), rgba(249,240,249,.90)); border-color: rgba(200,153,186,.34); }
|
||||
.colony-card { background: linear-gradient(180deg, rgba(244,250,238,.92), rgba(235,246,230,.90)); border-color: rgba(132,178,128,.34); }
|
||||
.log-card { background: linear-gradient(180deg, rgba(255,246,238,.92), rgba(252,239,226,.90)); border-color: rgba(207,157,121,.34); }
|
||||
.tool-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,239,248,.82),rgba(253,234,246,.84)); }
|
||||
.colony-card .panel-card-toggle { background: linear-gradient(180deg,rgba(240,252,237,.82),rgba(230,247,228,.86)); }
|
||||
.log-card .panel-card-toggle { background: linear-gradient(180deg,rgba(255,242,231,.84),rgba(253,235,220,.86)); }
|
||||
|
||||
.sound-category-controls,
|
||||
.log-push-controls {
|
||||
margin-top: 10px;
|
||||
|
|
@ -61,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 */
|
||||
|
|
@ -91,25 +62,7 @@
|
|||
@media (max-width: 760px) { .sound-panel { right: auto; left: 0; } }
|
||||
|
||||
|
||||
/* v15.12.3: collapsed event panel should be genuinely thin */
|
||||
.log-card.collapsed {
|
||||
min-height: 0 !important;
|
||||
padding: 5px 7px !important;
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
.log-card.collapsed .panel-card-toggle {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.log-card.collapsed .panel-card-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.log-card.collapsed .panel-card-state {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Collapsed card sizing is shared by the base panel rules; no event-only shrink override. */
|
||||
|
||||
/* v15.12.4: event log scroll chains to the right menu at top/bottom */
|
||||
.log {
|
||||
|
|
@ -179,16 +132,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Item icons and mobile input mode */
|
||||
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
|
||||
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
|
||||
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
|
||||
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
|
||||
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
|
||||
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
|
||||
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
|
||||
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
|
||||
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
|
||||
/* Mobile input mode */
|
||||
.mobile-mode-controls {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
# Architecture
|
||||
|
||||
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.20.16`.
|
||||
|
||||
## 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.
|
||||
- `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.20.16` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
|
||||
|
||||
Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events.
|
||||
|
||||
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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
860
docs/prompt.txt
860
docs/prompt.txt
|
|
@ -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 0–100.
|
||||
|
||||
After raw calculation, quantize each value to 10-point steps:
|
||||
|
||||
0, 10, 20, ... 100
|
||||
|
||||
Use a helper like:
|
||||
|
||||
function quantizeNeed(value) {
|
||||
return Math.max(0, Math.min(100, Math.round(value / 10) * 10));
|
||||
}
|
||||
|
||||
For UI display, show only the tens digit:
|
||||
|
||||
0, 1, 2, ... 10
|
||||
|
||||
Example:
|
||||
internal value 80 -> display 8
|
||||
internal value 100 -> display 10
|
||||
|
||||
Safety is capped at 100 like every other need.
|
||||
Safety is not a hard interrupt.
|
||||
|
||||
============================================================
|
||||
2. NEED CALCULATION
|
||||
============================================================
|
||||
|
||||
Calculate needs from existing tarinai/world state where possible.
|
||||
|
||||
Suggested mapping:
|
||||
|
||||
food:
|
||||
- hunger
|
||||
- thirst if available
|
||||
|
||||
sleep:
|
||||
- low energy
|
||||
- long awake time if available
|
||||
- night/time-of-day bonus if available
|
||||
|
||||
health:
|
||||
- disease
|
||||
- injury
|
||||
- dirtiness
|
||||
- poison or negative status if available
|
||||
|
||||
safety:
|
||||
- nearby ants
|
||||
- nearby dangerous items
|
||||
- recent damage
|
||||
- fear/panic-related existing state if any
|
||||
- being attacked
|
||||
- owned structure destruction shock
|
||||
|
||||
social:
|
||||
- loneliness if available
|
||||
- nearby friend / parent / child
|
||||
- reproduction readiness
|
||||
- enemy proximity if current relation system supports it
|
||||
|
||||
fulfill:
|
||||
- wants to play
|
||||
- has no owned structure
|
||||
- wants to return to owned grass bed
|
||||
- wants to carry/use plushie
|
||||
- wants to build a simple owned structure
|
||||
|
||||
Use existing personality fields if available:
|
||||
|
||||
aggression:
|
||||
- safety rises slightly less from danger
|
||||
- may choose intimidate instead of panic when there is a breaker/attacker
|
||||
|
||||
openness:
|
||||
- fulfill rises more easily
|
||||
- play/build actions become more likely
|
||||
|
||||
sociability:
|
||||
- social rises more easily
|
||||
- reacts less negatively when others use owned structures
|
||||
|
||||
neuroticism:
|
||||
- safety and health rise more easily
|
||||
- owned structure damage/destruction has stronger effect
|
||||
|
||||
Keep personality modifiers small and easy to tune.
|
||||
|
||||
============================================================
|
||||
3. ACTION SELECTION
|
||||
============================================================
|
||||
|
||||
Use this behavior flow:
|
||||
|
||||
1. Calculate all six raw needs.
|
||||
2. Quantize all needs.
|
||||
3. Find the maximum need value.
|
||||
4. Collect all needs equal to the maximum.
|
||||
5. Randomly choose one need from that tied top set.
|
||||
6. Choose an executable action associated with that chosen need.
|
||||
7. Store the chosen need and action in tarinai.intent.
|
||||
8. Highlight only the chosen need in the tarinai data UI.
|
||||
|
||||
If safety and food are both 100, either may be chosen randomly.
|
||||
|
||||
Add helpers:
|
||||
|
||||
- createDefaultNeeds()
|
||||
- quantizeNeed(value)
|
||||
- updateNeeds(tarinai, world)
|
||||
- chooseTopNeedRandom(needs)
|
||||
- chooseActionForNeed(need, tarinai, world)
|
||||
- buildReasonText(chosenNeed, tiedNeeds, action)
|
||||
- getNeedDisplayValue(value)
|
||||
|
||||
============================================================
|
||||
4. SIMPLE ACTION DEFINITIONS
|
||||
============================================================
|
||||
|
||||
Use a simple action list, not a complex behavior tree.
|
||||
|
||||
Each action should have:
|
||||
|
||||
- id
|
||||
- need
|
||||
- label
|
||||
- phrase
|
||||
- weight
|
||||
- condition(tarinai, world)
|
||||
- run(tarinai, world)
|
||||
|
||||
Example shape:
|
||||
|
||||
const TARINAI_ACTIONS = [
|
||||
{
|
||||
id: "eat_food",
|
||||
need: "food",
|
||||
label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b",
|
||||
phrase: "\u98df\u3079\u305f",
|
||||
weight: 50,
|
||||
condition(t, world) {
|
||||
return !!findNearestItemWithRole(world, t, "food");
|
||||
},
|
||||
run(t, world) {
|
||||
const item = findNearestItemWithRole(world, t, "food");
|
||||
moveToOrUse(t, item);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Suggested actions:
|
||||
|
||||
food:
|
||||
- eat_food
|
||||
- drink_water
|
||||
|
||||
sleep:
|
||||
- sleep_in_bed
|
||||
- sleep_in_owned_structure
|
||||
- sleep_anywhere
|
||||
|
||||
health:
|
||||
- use_medicine
|
||||
- wash_or_drink_water
|
||||
- rest_to_recover
|
||||
|
||||
safety:
|
||||
- flee
|
||||
- hide_in_nest
|
||||
- hide_at_owned_structure
|
||||
|
||||
social:
|
||||
- approach_friend
|
||||
- approach_parent_or_child
|
||||
- approach_mate
|
||||
- intimidate_enemy
|
||||
|
||||
fulfill:
|
||||
- play
|
||||
- wander_lightly
|
||||
- build_grass_bed
|
||||
- build_plushie
|
||||
- return_owned_structure
|
||||
- use_plushie
|
||||
|
||||
Use weighted random among executable actions for the chosen need.
|
||||
|
||||
If no action is executable, fall back to idle, wander, or rest.
|
||||
|
||||
Do not make tarinai perfectly optimal.
|
||||
|
||||
============================================================
|
||||
5. TIE REASON TEXT
|
||||
============================================================
|
||||
|
||||
If multiple needs are tied for highest and the chosen need is only one of them, generate:
|
||||
|
||||
“[ignored need phrase]\u3051\u3069\u3001[chosen action phrase]\u3002”
|
||||
|
||||
Examples:
|
||||
- "\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002"
|
||||
- "\u306d\u3080\u3044\u3051\u3069\u3001\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f\u3002"
|
||||
- "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b\u3051\u3069\u3001\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f\u3002"
|
||||
- "\u8abf\u5b50\u304c\u60aa\u3044\u3051\u3069\u3001\u904a\u3093\u3060\u3002"
|
||||
|
||||
When multiple ignored needs exist, mention the ignored need with highest priority:
|
||||
|
||||
safety > health > food > sleep > social > fulfill
|
||||
|
||||
Conflict phrases:
|
||||
|
||||
food:
|
||||
"\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b"
|
||||
|
||||
sleep:
|
||||
"\u306d\u3080\u3044"
|
||||
|
||||
health:
|
||||
"\u8abf\u5b50\u304c\u60aa\u3044"
|
||||
|
||||
safety:
|
||||
"\u3042\u3076\u306a\u3044"
|
||||
|
||||
social:
|
||||
"\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b"
|
||||
|
||||
fulfill:
|
||||
"\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b"
|
||||
|
||||
If there is no tie, use:
|
||||
|
||||
“[need phrase]\u306e\u3067\u3001[action phrase]\u3002”
|
||||
|
||||
Store:
|
||||
|
||||
tarinai.intent = {
|
||||
need: chosenNeed,
|
||||
tiedNeeds,
|
||||
actionId: action.id,
|
||||
actionLabel: action.label,
|
||||
reasonText
|
||||
};
|
||||
|
||||
============================================================
|
||||
6. NEED UI
|
||||
============================================================
|
||||
|
||||
Update the tarinai data UI.
|
||||
|
||||
Show six needs as 0–10 values.
|
||||
|
||||
Labels must be escaped in source:
|
||||
|
||||
food label:
|
||||
"\u6442\u990c"
|
||||
|
||||
sleep label:
|
||||
"\u7761\u7720"
|
||||
|
||||
health label:
|
||||
"\u5065\u5eb7"
|
||||
|
||||
safety label:
|
||||
"\u5b89\u5168"
|
||||
|
||||
social label:
|
||||
"\u95a2\u4fc2"
|
||||
|
||||
fulfill label:
|
||||
"\u5145\u8db3"
|
||||
|
||||
Example UI:
|
||||
|
||||
摂餌 8
|
||||
睡眠 3
|
||||
健康 1
|
||||
安全 10
|
||||
関係 4
|
||||
充足 10 ★
|
||||
|
||||
Only the need actually chosen for the current action receives the highlight.
|
||||
|
||||
Also show:
|
||||
|
||||
"\u3044\u307e":
|
||||
[action label]
|
||||
|
||||
"\u7406\u7531":
|
||||
[reasonText]
|
||||
|
||||
Do not show detailed need breakdowns.
|
||||
Do not add graphs or extra panels.
|
||||
|
||||
============================================================
|
||||
7. ITEM ROLES AND NEED EFFECTS
|
||||
============================================================
|
||||
|
||||
Use minimal item roles and needEffects.
|
||||
|
||||
Do not create a broad tag system.
|
||||
|
||||
Item example:
|
||||
|
||||
item.roles = {
|
||||
food: true,
|
||||
drink: true,
|
||||
medicine: true,
|
||||
sleepPlace: true,
|
||||
danger: true,
|
||||
grassMaterial: true
|
||||
};
|
||||
|
||||
item.needEffects = {
|
||||
food: 0,
|
||||
sleep: 0,
|
||||
health: 0,
|
||||
safety: 0,
|
||||
social: 0,
|
||||
fulfill: 0
|
||||
};
|
||||
|
||||
Only add roles that are actually needed by the new behavior.
|
||||
|
||||
Useful helpers:
|
||||
|
||||
- findNearestItemWithRole(world, tarinai, role)
|
||||
- findNearestSleepPlace(world, tarinai)
|
||||
- findNearbyDanger(world, tarinai)
|
||||
- findNearbyMaterial(world, tarinai, materialRole)
|
||||
|
||||
Adapt to existing item registries where possible.
|
||||
|
||||
============================================================
|
||||
8. STRUCTURE REGISTRY
|
||||
============================================================
|
||||
|
||||
Keep StructureRegistry because tarinai-built structures will expand in the future.
|
||||
|
||||
Do not collapse structures into one-off special item cases.
|
||||
|
||||
Create or adapt a small StructureRegistry for buildable tarinai structures.
|
||||
|
||||
Shared structure instance shape:
|
||||
|
||||
{
|
||||
id,
|
||||
type,
|
||||
isStructure: true,
|
||||
ownerId,
|
||||
hp,
|
||||
attachment,
|
||||
usedCount,
|
||||
x,
|
||||
y,
|
||||
roles,
|
||||
needEffects
|
||||
}
|
||||
|
||||
Do not implement:
|
||||
- repair
|
||||
- inheritance
|
||||
- ownership privacy
|
||||
- material-carrying inventory
|
||||
- complex construction logistics
|
||||
|
||||
First buildable structures:
|
||||
|
||||
- grass_bed
|
||||
- plushie
|
||||
|
||||
============================================================
|
||||
9. STRUCTURE: GRASS BED
|
||||
============================================================
|
||||
|
||||
Structure type:
|
||||
"grass_bed"
|
||||
|
||||
Japanese label, escaped:
|
||||
"\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9"
|
||||
|
||||
Concept:
|
||||
A simple bed made from grass. It is an owned place. The owner prefers it for sleep, safety, and fulfill.
|
||||
|
||||
Example registry entry shape:
|
||||
|
||||
grass_bed: {
|
||||
type: "grass_bed",
|
||||
label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9",
|
||||
buildNeed: "fulfill",
|
||||
buildTime: 4,
|
||||
maxHp: 100,
|
||||
materials: {
|
||||
grassMaterial: 1
|
||||
},
|
||||
roles: {
|
||||
sleepPlace: true,
|
||||
ownedStructure: true
|
||||
},
|
||||
needEffects: {
|
||||
food: 0,
|
||||
sleep: 60,
|
||||
health: 5,
|
||||
safety: 25,
|
||||
social: 0,
|
||||
fulfill: 40
|
||||
},
|
||||
create(owner, x, y, world) {
|
||||
return {
|
||||
id: createId("structure"),
|
||||
type: "grass_bed",
|
||||
isStructure: true,
|
||||
ownerId: owner.id,
|
||||
hp: 100,
|
||||
attachment: 20,
|
||||
usedCount: 0,
|
||||
x,
|
||||
y,
|
||||
roles: this.roles,
|
||||
needEffects: this.needEffects
|
||||
};
|
||||
},
|
||||
onUse(user, structure, world) {
|
||||
// owner gets stronger effects
|
||||
// non-owner gets weaker effects
|
||||
// owner use increases attachment
|
||||
},
|
||||
onDestroyed(structure, world, breaker) {
|
||||
// increase owner safety and fulfill
|
||||
// trigger panic or intimidate through need shock logic
|
||||
}
|
||||
}
|
||||
|
||||
Build condition:
|
||||
- chosen need is fulfill
|
||||
- tarinai has no owned grass_bed
|
||||
- grass material is nearby
|
||||
- tarinai is not sleeping, fighting, or panicking
|
||||
|
||||
Build process:
|
||||
- move near grass
|
||||
- spend short build time if existing state system supports it
|
||||
- consume/reduce grass if easy
|
||||
- create grass_bed through StructureRegistry
|
||||
|
||||
Owner behavior:
|
||||
- owner prefers own grass_bed for sleep, safety, and fulfill
|
||||
- using own grass_bed reduces sleep, safety, and fulfill
|
||||
- using own grass_bed increases attachment
|
||||
|
||||
Other tarinai:
|
||||
- may use it with weaker effects
|
||||
- if owner sees another tarinai using it:
|
||||
- aggression may cause intimidate
|
||||
- neuroticism may increase safety/fulfill
|
||||
- sociability reduces negative reaction
|
||||
|
||||
Destruction:
|
||||
- hp can be reduced by explosions, punches, weather, repeated use, or existing damage sources if easy to connect
|
||||
- when hp reaches 0, call onDestroyed and remove structure
|
||||
|
||||
============================================================
|
||||
10. STRUCTURE: PLUSHIE
|
||||
============================================================
|
||||
|
||||
Add a new tarinai-built structure:
|
||||
|
||||
type:
|
||||
"plushie"
|
||||
|
||||
Japanese label, escaped:
|
||||
"\u306c\u3044\u3050\u308b\u307f"
|
||||
|
||||
Concept:
|
||||
A small plushie item that resembles a tarinai. It is made from grass. When completed, it attaches to the owner tarinai’s head. While attached, it reduces fulfill. If damaged, it breaks and disappears. The owner receives a strong need shock and enters panic or intimidate.
|
||||
|
||||
Example registry entry shape:
|
||||
|
||||
plushie: {
|
||||
type: "plushie",
|
||||
label: "\u306c\u3044\u3050\u308b\u307f",
|
||||
buildNeed: "fulfill",
|
||||
buildTime: 4,
|
||||
maxHp: 1,
|
||||
materials: {
|
||||
grassMaterial: 1
|
||||
},
|
||||
roles: {
|
||||
carriedObject: true,
|
||||
ownedStructure: true,
|
||||
fulfillObject: true
|
||||
},
|
||||
needEffects: {
|
||||
food: 0,
|
||||
sleep: 0,
|
||||
health: 0,
|
||||
safety: 5,
|
||||
social: 0,
|
||||
fulfill: 50
|
||||
},
|
||||
create(owner, x, y, world) {
|
||||
return {
|
||||
id: createId("structure"),
|
||||
type: "plushie",
|
||||
isStructure: true,
|
||||
ownerId: owner.id,
|
||||
hp: 1,
|
||||
attachment: 30,
|
||||
usedCount: 0,
|
||||
x,
|
||||
y,
|
||||
carriedById: owner.id,
|
||||
onHead: true,
|
||||
roles: this.roles,
|
||||
needEffects: this.needEffects
|
||||
};
|
||||
},
|
||||
onUse(user, structure, world) {
|
||||
// if owner carries/uses it, reduce fulfill
|
||||
// optionally reduce safety slightly
|
||||
// increase attachment slightly
|
||||
},
|
||||
onDestroyed(structure, world, breaker) {
|
||||
// large shock to owner safety and fulfill
|
||||
// trigger panic or intimidate
|
||||
}
|
||||
}
|
||||
|
||||
Build condition:
|
||||
- chosen need is fulfill
|
||||
- tarinai does not already own/carry a plushie
|
||||
- grass material is nearby
|
||||
- tarinai is not sleeping, fighting, or panicking
|
||||
|
||||
Build process:
|
||||
- move near grass
|
||||
- spend short build time if existing state system supports it
|
||||
- consume/reduce grass if easy
|
||||
- create plushie through StructureRegistry
|
||||
- attach it to owner’s head
|
||||
|
||||
Behavior while attached:
|
||||
- follow owner position
|
||||
- render above owner’s head
|
||||
- use same or similar sprite as tarinai, scaled down
|
||||
- reduce fulfill over time or when used
|
||||
- optionally reduce safety slightly
|
||||
- do not implement inventory
|
||||
|
||||
Damage/destruction:
|
||||
- any meaningful damage destroys it
|
||||
- remove plushie
|
||||
- increase owner safety and fulfill strongly
|
||||
- trigger panic or intimidate through need shock logic
|
||||
|
||||
Do not implement:
|
||||
- repair
|
||||
- inheritance
|
||||
- trading
|
||||
- inventory
|
||||
- multiple plushies per tarinai
|
||||
|
||||
============================================================
|
||||
11. EMOTION SYSTEM SIMPLIFICATION
|
||||
============================================================
|
||||
|
||||
Remove the old detailed emotion system as a behavior driver.
|
||||
|
||||
Behavior must be selected only from the six needs:
|
||||
food, sleep, health, safety, social, fulfill.
|
||||
|
||||
Old emotions should be removed or absorbed:
|
||||
|
||||
fear -> safety
|
||||
loneliness -> social
|
||||
happiness -> low stress / low fulfill pressure
|
||||
sadness -> fulfill/social/safety pressure
|
||||
comfort -> sleep/safety/fulfill satisfaction
|
||||
boredom -> do not implement
|
||||
anger -> intimidate state only
|
||||
panic -> need shock reaction
|
||||
stress -> derived pressure from needs
|
||||
|
||||
Do not keep happiness, sadness, fear, loneliness, comfort, boredom, or anger as independent behavior-driving values.
|
||||
|
||||
============================================================
|
||||
12. STRESS REDEFINITION
|
||||
============================================================
|
||||
|
||||
Stress is not an independent emotion.
|
||||
|
||||
Stress is a derived value calculated from current need pressure.
|
||||
|
||||
Do not directly add to stress for shock events.
|
||||
For shock events, increase relevant needs first, then recalculate stress from needs.
|
||||
|
||||
Stress must not be used as a primary action-selection need.
|
||||
|
||||
Behavior is selected only from the six needs.
|
||||
|
||||
Stress may affect:
|
||||
- expression
|
||||
- minor visual intensity
|
||||
- UI display if already present
|
||||
- minor secondary effects if existing systems need it
|
||||
|
||||
Suggested pressure function:
|
||||
|
||||
function needPressure(value) {
|
||||
const x = value / 100;
|
||||
return x * x * 100;
|
||||
}
|
||||
|
||||
Suggested stress calculation:
|
||||
|
||||
stress =
|
||||
needPressure(food) * 0.10 +
|
||||
needPressure(sleep) * 0.12 +
|
||||
needPressure(health) * 0.25 +
|
||||
needPressure(safety) * 0.30 +
|
||||
needPressure(social) * 0.08 +
|
||||
needPressure(fulfill) * 0.15;
|
||||
|
||||
Adjust weights if needed, but keep stress derived from needs.
|
||||
|
||||
============================================================
|
||||
13. PANIC REDEFINITION
|
||||
============================================================
|
||||
|
||||
Panic is not a need.
|
||||
Panic is not a persistent emotion.
|
||||
|
||||
Panic is a short temporary reaction state caused by large sudden increases in safety, health, or fulfill.
|
||||
|
||||
Food, sleep, and social should not normally trigger panic.
|
||||
|
||||
Track previous needs or otherwise compute need delta.
|
||||
|
||||
Suggested rule:
|
||||
|
||||
panic triggers if:
|
||||
- safety increases suddenly by 40 or more
|
||||
- health increases suddenly by 45 or more
|
||||
- fulfill increases suddenly by 50 or more
|
||||
|
||||
Panic should last for a short timer, such as 2–4 seconds.
|
||||
|
||||
After panic timer ends, return to normal need-based action selection.
|
||||
|
||||
If panic would be triggered and the tarinai has high aggression and a known breaker/attacker, use intimidate instead of panic.
|
||||
|
||||
Example:
|
||||
|
||||
if (shockTriggered) {
|
||||
if (breaker && t.personality?.aggression > 0.6) {
|
||||
t.state = "intimidate";
|
||||
t.target = breaker;
|
||||
} else {
|
||||
t.state = "panic";
|
||||
t.panicTimer = 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
============================================================
|
||||
14. EXPRESSION / VISUAL EMOTION COMPATIBILITY
|
||||
============================================================
|
||||
|
||||
If the old emotion system is deeply tied to expressions, keep only a small compatibility layer for visuals.
|
||||
|
||||
Expressions should be derived from:
|
||||
- current state
|
||||
- chosen need
|
||||
- stress level
|
||||
- panic/intimidate
|
||||
|
||||
Suggested mapping:
|
||||
|
||||
state panic:
|
||||
panic expression
|
||||
|
||||
state intimidate:
|
||||
angry/threat expression
|
||||
|
||||
chosen need safety:
|
||||
worried expression
|
||||
|
||||
chosen need food:
|
||||
hungry expression
|
||||
|
||||
chosen need sleep:
|
||||
sleepy expression
|
||||
|
||||
chosen need health:
|
||||
unwell expression
|
||||
|
||||
chosen need social:
|
||||
social/looking expression
|
||||
|
||||
chosen need fulfill:
|
||||
neutral/pleased expression
|
||||
|
||||
Do not let old expression emotions drive behavior.
|
||||
|
||||
============================================================
|
||||
15. SAVE COMPATIBILITY
|
||||
============================================================
|
||||
|
||||
Backward compatibility is not required.
|
||||
|
||||
Do not implement save migrations.
|
||||
Do not add compatibility shims for old saves unless the current code would immediately crash during normal startup.
|
||||
|
||||
It is acceptable if old saves are incompatible after this feature.
|
||||
|
||||
============================================================
|
||||
16. DO NOT ADD
|
||||
============================================================
|
||||
|
||||
Do not add:
|
||||
- long-term memory
|
||||
- boredom/habit system
|
||||
- player-intervention profile
|
||||
- ReactionRegistry
|
||||
- WorldRules
|
||||
- full behavior tree
|
||||
- complex utility AI
|
||||
- detailed need breakdown UI
|
||||
- repair system
|
||||
- inheritance system
|
||||
- material-carrying inventory
|
||||
- structure privacy system
|
||||
|
||||
============================================================
|
||||
17. ACCEPTANCE CRITERIA
|
||||
============================================================
|
||||
|
||||
After implementation:
|
||||
|
||||
- Each tarinai has six needs:
|
||||
- food
|
||||
- sleep
|
||||
- health
|
||||
- safety
|
||||
- social
|
||||
- fulfill
|
||||
|
||||
- Needs are clamped 0–100.
|
||||
- Needs are quantized to 10-point steps.
|
||||
- UI displays needs as 0–10.
|
||||
- Only the chosen action-driving need is highlighted.
|
||||
- If multiple needs are tied for highest, one is chosen randomly.
|
||||
- Safety does not force interruption.
|
||||
- If safety and another need are tied, non-safety behavior can happen.
|
||||
- Tie reason text uses the “Xけど、Yした。” format.
|
||||
- Newly added Japanese source strings are Unicode escaped.
|
||||
- Actions are defined in a simple action list.
|
||||
- Items expose minimal roles and needEffects.
|
||||
- StructureRegistry exists and supports at least grass_bed and plushie.
|
||||
- grass_bed consumes grass to build.
|
||||
- grass_bed is preferred by its owner.
|
||||
- grass_bed destruction increases owner safety/fulfill and can trigger panic/intimidate.
|
||||
- plushie consumes grass to build.
|
||||
- plushie attaches to the owner’s head.
|
||||
- plushie visually resembles a small tarinai.
|
||||
- plushie reduces fulfill while carried/used.
|
||||
- plushie breaks and disappears when damaged.
|
||||
- plushie destruction strongly increases owner safety/fulfill and triggers panic/intimidate.
|
||||
- Old detailed emotion values no longer drive behavior.
|
||||
- Stress is derived from needs.
|
||||
- Panic is caused by sudden safety/health/fulfill increases.
|
||||
- Old save compatibility is not required.
|
||||
226
index.html
226
index.html
|
|
@ -3,24 +3,24 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>たりない観察</title>
|
||||
<title>個体観察</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=15.24.14" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=15.24.14" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=15.24.14" />
|
||||
<link rel="stylesheet" href="css/components.css?v=15.24.14" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=15.24.14" />
|
||||
<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">たりないコロニー</div>
|
||||
<h1 class="loading-title">読み込み中…</h1>
|
||||
<p id="loadingText" class="loading-text">準備しています</p>
|
||||
<div class="loading-kicker">個体コロニー</div>
|
||||
<h1 class="loading-title">読み込み中…</h1>
|
||||
<p id="loadingText" class="loading-text">準備しています。</p>
|
||||
<div id="loadingSpriteStrip" class="loading-sprite-strip" aria-hidden="true"></div>
|
||||
<div class="loading-progress-track"><div id="loadingBar" class="loading-progress-bar" style="width:0%"></div></div>
|
||||
<div class="loading-progress-meta"><span>進行状況</span><strong id="loadingPercent">0%</strong></div>
|
||||
<p class="loading-hint">初期表示に必要な画像だけ先に読み込み、残りは後から順次ロードします。</p>
|
||||
<div class="loading-progress-meta"><span>進行状況</span><strong id="loadingPercent">0%</strong></div>
|
||||
<p class="loading-hint">初期表示に必要な画像だけ先に読み込み、残りは後から順次ロードします。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app">
|
||||
|
|
@ -28,45 +28,51 @@
|
|||
<div class="top-left">
|
||||
<a class="btn room-link" href="https://host.nishi.boats/~333/">333の部屋に移動</a>
|
||||
<div class="brand">
|
||||
<h1>たりない観察</h1>
|
||||
<h1>個体観察</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="pauseBtn" class="btn primary">一時停止</button>
|
||||
<button id="speedBtn" class="btn">速度 x1</button>
|
||||
<button id="ecologyBtn" class="btn">たりないの生態</button>
|
||||
<button id="ecologyBtn" class="btn">個体の生態</button>
|
||||
<div id="soundMenu" class="sound-menu">
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音設定 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="効果音設定">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<button id="soundBtn" class="btn sound-menu-btn" type="button" aria-haspopup="true" aria-expanded="false">音設定 ON</button>
|
||||
<div id="soundPanel" class="sound-panel hidden" role="menu" aria-label="効果音設定">
|
||||
<label class="sound-master"><input id="soundMasterToggle" type="checkbox" checked><span>効果音</span></label>
|
||||
<div class="sound-panel-divider"></div>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>たりないの声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
<label><input type="checkbox" data-sound-category="voice"><span>個体の声</span></label>
|
||||
<label><input type="checkbox" data-sound-category="notify" checked><span>通知</span></label>
|
||||
<label><input type="checkbox" data-sound-category="ops" checked><span>操作</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="creditsBtn" class="btn">クレジット</button>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="saveBtn" class="btn">セーブ</button>
|
||||
<button id="resetBtn" class="btn danger">リセット</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="stage-wrap">
|
||||
<canvas id="gameCanvas" aria-label="たりない観察キャンバス"></canvas>
|
||||
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="スマホ操作モード">
|
||||
<button type="button" data-mobile-mode="auto" class="active">自動</button>
|
||||
<button type="button" data-mobile-mode="camera">カメラ</button>
|
||||
<button type="button" data-mobile-mode="tool">道具</button>
|
||||
<button type="button" data-mobile-mode="family">家系図</button>
|
||||
<canvas id="gameCanvas" aria-label="個体観察キャンバス"></canvas>
|
||||
<div id="mobileModeControls" class="mobile-mode-controls" aria-label="スマホ操作モード">
|
||||
<button type="button" data-mobile-mode="auto" class="active">自動</button>
|
||||
<button type="button" data-mobile-mode="camera">カメラ</button>
|
||||
<button type="button" data-mobile-mode="tool">道具</button>
|
||||
<button type="button" data-mobile-mode="family">家系図</button>
|
||||
</div>
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</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>たりないデータ</h2>
|
||||
<h2>個体データ</h2>
|
||||
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="観察をやめる">×</button>
|
||||
</div>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
|
|
@ -86,11 +92,12 @@
|
|||
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">コロニー</span><span class="panel-card-state">折り畳み</span></button>
|
||||
<div class="panel-card-body">
|
||||
<div class="stats colony-stats">
|
||||
<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"><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="地面をきりかえる"><strong id="statGroundType">土</strong></button></div>
|
||||
</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="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="コロニーの推移グラフ" role="img"></div>
|
||||
</div>
|
||||
|
|
@ -151,7 +158,7 @@
|
|||
<h2 id="creditsDialogTitle">クレジット</h2>
|
||||
<div class="credits-content">
|
||||
<p><strong>製作:</strong> 333</p>
|
||||
<p><strong>たりない:</strong> むらさき氏</p>
|
||||
<p><strong>個体:</strong> むらさき氏</p>
|
||||
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
|
||||
<p><strong>アリ素材:</strong> いらすとや: <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">家系図をリセットしますか?</h2>
|
||||
<p class="hint">現在の親子関係と世代情報を消去します。個体そのものや庭の状態は消えません。</p>
|
||||
<p class="hint">現在の親子関係と世代情報を消去します。個体その物量や庭の状態は消えません。</p>
|
||||
<div class="field-dialog-actions archive-reset-actions">
|
||||
<button id="archiveResetCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
<button id="archiveResetConfirmBtn" class="btn danger" type="button">リセットする</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">たりないの生態</h2>
|
||||
<h2 id="ecologyDialogTitle">個体の生態</h2>
|
||||
<div id="ecologyGrid" class="ecology-grid"></div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="ecologyCloseBtn" class="btn" type="button">閉じる</button>
|
||||
|
|
@ -183,67 +190,94 @@
|
|||
</div>
|
||||
|
||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="js/version.js?v=15.24.14" defer></script>
|
||||
<script src="js/event_bus.js?v=15.24.14" defer></script>
|
||||
<script src="js/performance_manager.js?v=15.24.14" defer></script>
|
||||
<script src="js/registry_base.js?v=15.24.14" defer></script>
|
||||
<script src="js/disease_registry.js?v=15.24.14" defer></script>
|
||||
<script src="js/effect_registry.js?v=15.24.14" defer></script>
|
||||
<script src="js/sound_pack.js?v=15.24.14" defer></script>
|
||||
<script src="js/data.js?v=15.24.14" defer></script>
|
||||
<script src="js/item_visual_registry.js?v=15.24.14" defer></script>
|
||||
<script src="js/food_registry.js?v=15.24.14" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=15.24.14" defer></script>
|
||||
<script src="js/math.js?v=15.24.14" defer></script>
|
||||
<script src="js/physics_helpers.js?v=15.24.14" defer></script>
|
||||
<script src="js/assets.js?v=15.24.14" defer></script>
|
||||
<script src="js/audio.js?v=15.24.14" defer></script>
|
||||
<script src="js/render.js?v=15.24.14" defer></script>
|
||||
<script src="js/sim_core.js?v=15.24.14" defer></script>
|
||||
<script src="js/structures.js?v=15.24.14" defer></script>
|
||||
<script src="js/items.js?v=15.24.14" defer></script>
|
||||
<script src="js/ants.js?v=15.24.14" defer></script>
|
||||
<script src="js/health.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=15.24.14" defer></script>
|
||||
<script src="js/tarinai_render.js?v=15.24.14" defer></script>
|
||||
<script src="js/world.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_view.js?v=15.24.14" defer></script>
|
||||
<script src="js/family_graph.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_family_social.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_environment.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_ants_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/weather_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_update.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_placement_log.js?v=15.24.14" defer></script>
|
||||
<script src="js/world_bootstrap.js?v=15.24.14" defer></script>
|
||||
<script src="js/text_catalog.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_log.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_helpers.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_selected.js?v=15.24.14" defer></script>
|
||||
<script src="js/snapshot_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/save_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/patch_helpers.js?v=15.24.14" defer></script>
|
||||
<script src="js/freeze_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/relation_event_system.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_tools.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_bind.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_charts.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_family_data.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_family_async.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=15.24.14" defer></script>
|
||||
<script src="js/ui_family_render.js?v=15.24.14" defer></script>
|
||||
<script src="js/main.js?v=15.24.14" defer></script>
|
||||
<script src="js/debug_tools.js?v=15.24.14" 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>
|
||||
|
|
|
|||
16
js/ants.js
16
js/ants.js
|
|
@ -94,7 +94,8 @@ class AntActor {
|
|||
}
|
||||
|
||||
homeNest() {
|
||||
return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null;
|
||||
const nest = this.world?.itemById?.(this.homeId) || null;
|
||||
return nest && nest.type === "ant_nest" ? nest : null;
|
||||
}
|
||||
|
||||
adoptableNest() {
|
||||
|
|
@ -165,7 +166,8 @@ class AntActor {
|
|||
// filter or rank candidates here while preserving sleep-disease hauling.
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const t of this.world?.tarinai || []) {
|
||||
const candidates = this.world?.nearbyTarinai?.(this.x, this.y, maxDist, true) || this.world?.tarinai || [];
|
||||
for (const t of candidates) {
|
||||
if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue;
|
||||
const d = distXY(this.x, this.y, t.x, t.y);
|
||||
if (d > maxDist) continue;
|
||||
|
|
@ -344,10 +346,10 @@ class AntActor {
|
|||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
corpse.workerSprite = this.workerSpriteId();
|
||||
this.world.items.push(corpse);
|
||||
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
|
||||
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
|
||||
if (home) syncAntNestCount(this.world, home);
|
||||
this.world.updateItemCounts?.();
|
||||
this.world.ensureItemBuckets?.("ant-corpse");
|
||||
this.world.drawListDirty = true;
|
||||
}
|
||||
|
||||
|
|
@ -370,11 +372,9 @@ class AntActor {
|
|||
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
|
||||
if (!force && this.world.placementBlocked?.(base)) return null;
|
||||
audio.antNest?.();
|
||||
this.world.items.push(base);
|
||||
this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1;
|
||||
this.world.markTerrainDirty?.("ant-nest-built");
|
||||
this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base);
|
||||
this.world.emit?.("ant:nest-built", { nest: base, queen: this });
|
||||
this.world.rebuildSpatial?.(true);
|
||||
this.world.ensureSpatial?.("ant-nest-built");
|
||||
this.world.drawListDirty = true;
|
||||
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");
|
||||
return base;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ const stainOverlayCache = new Map();
|
|||
const SPRITE_CACHE_LIMIT = 128;
|
||||
const STAIN_CACHE_LIMIT = 64;
|
||||
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck"]);
|
||||
const SUNBATH_IMAGE_IDS = new Set((typeof SPRITES !== "undefined" ? SPRITES : [])
|
||||
.map(asset => asset?.id || "")
|
||||
.filter(id => String(id).startsWith("sunbath")));
|
||||
const INITIAL_IMAGE_IDS = new Set(["smile", "hungry_70", "zunchi_slave", "zunchi_slave_alt", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
const EARLY_IMAGE_IDS = new Set(["smile", "angry", "teary", "jito", "drool", "cry", "sleep", "pokan", "normal_smirk", "normal_tongue", "normal_happy", "weak", "hurt", "hurt2", "fear", "flee", "flee_fear2", "fear_blue", "fear_cry", "sleep2", "stress_dizzy", "stress_sweat", "intimidate", "birth_ritual", "zunchi_slave", "zunchi_slave_alt", "hungry_70", "zunchi", "zunchi_02", "genkotsu", "pushpin", "pushpin_stuck", "oshibyo", "oshibyo_stuck", ...SUNBATH_IMAGE_IDS]);
|
||||
|
||||
function trimCanvasCache(cache, limit = 128) {
|
||||
while (cache.size > limit) {
|
||||
|
|
|
|||
23
js/audio.js
23
js/audio.js
|
|
@ -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();
|
||||
|
|
@ -298,6 +277,7 @@ const audio = {
|
|||
if (s.includes("Zzz")) return this.playSample("sfx_sleep", 1.25, "voice") || this.play("sfx_sleep", { category: "voice", minGap: 1.25 });
|
||||
if (s.includes("\u306f\u3041") || s.includes("\u306f\u3042")) return this.playSample("voice_flee", 0.70, "voice") || this.play("sfx_damage_heavy", { category: "voice", minGap: 0.70 });
|
||||
if (s.includes("\u306f\u3046") || s.includes("\u306f\u3045")) return this.playSample("voice_hau", 0.45, "voice") || this.play("sfx_wake", { category: "voice", minGap: 0.45 });
|
||||
if (s.includes("\u307d\u304b")) return this.playSample("voice_sunbath", 0.95, "voice") || this.play("sfx_heal", { category: "voice", minGap: 0.95 });
|
||||
return false;
|
||||
},
|
||||
eat() { if (!this.playSample("sfx_eat", 0.20, "voice")) this.play("sfx_eat", { category: "voice", minGap: 0.20 }); },
|
||||
|
|
@ -323,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); },
|
||||
|
|
|
|||
152
js/data.js
152
js/data.js
|
|
@ -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, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
|
||||
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026文字×3行まで\u3002" },
|
||||
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "食べ物や薬をセットすると、自動供給してくれる。" },
|
||||
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\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
|
||||
{ id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] },
|
||||
{ id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator"] },
|
||||
{ id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] },
|
||||
]);
|
||||
|
||||
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: "小", medium: "中", large: "大" });
|
||||
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
|
||||
function normalizedToolSizeFor(worldRef = null, type = "") {
|
||||
const def = itemDefinition(type);
|
||||
if (!def?.scalable) return "medium";
|
||||
const value = worldRef?.toolSizes?.[type] || worldRef?.toolSize || "medium";
|
||||
return TOOL_SIZE_ORDER.includes(value) ? value : "medium";
|
||||
}
|
||||
function toolSizeScaleForValue(size = "medium") { return TOOL_SIZE_SCALES[size] || TOOL_SIZE_SCALES.medium; }
|
||||
function toolSizeScaleFor(worldRef = null, type = "") { return itemDefinition(type)?.scalable ? toolSizeScaleForValue(normalizedToolSizeFor(worldRef, type)) : 1; }
|
||||
|
||||
function scalableToolIds() {
|
||||
return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id);
|
||||
}
|
||||
|
||||
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 },
|
||||
|
|
@ -235,13 +91,10 @@ const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c
|
|||
const CONFIG = {
|
||||
initialPopulation: 18,
|
||||
maxPopulation: Number.MAX_SAFE_INTEGER,
|
||||
itemLimit: 260,
|
||||
fenceLimit: 72,
|
||||
maxFenceCollisionChecks: 18,
|
||||
zunchiLimit: 56,
|
||||
traceLimit: 64,
|
||||
splatLimit: 48,
|
||||
effectLimit: 78,
|
||||
grassLimit: 99,
|
||||
grassLimitsByField: { cage: 19, garden: 99, park: 299 },
|
||||
worldPadding: 30,
|
||||
hungerPerMinute: 36.0,
|
||||
lonelinessPerMinute: 7.6,
|
||||
|
|
@ -255,7 +108,6 @@ const CONFIG = {
|
|||
weatherChangeMin: 46,
|
||||
weatherChangeMax: 96,
|
||||
bubbleLimit: 32,
|
||||
grassLimit: 180,
|
||||
highPopulationMode: 70,
|
||||
ultraPopulationMode: 120,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,20 +19,31 @@
|
|||
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 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 ?? "?"}`,
|
||||
`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}`,
|
||||
`drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`,
|
||||
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
|
||||
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`,
|
||||
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
|
||||
`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);
|
||||
|
|
|
|||
|
|
@ -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, reasonText: "へこ餅で繁殖できる相手を探している" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" });
|
||||
}
|
||||
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, reasonText: "けんか餅で気に入らない相手を探している" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" });
|
||||
}
|
||||
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, reasonText: "ねむり薬で眠ろうとしている" });
|
||||
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" });
|
||||
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);
|
||||
|
|
@ -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
162
js/freeze_panel.js
Normal 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);
|
||||
97
js/freeze_snapshot_adapter.js
Normal file
97
js/freeze_snapshot_adapter.js
Normal 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
45
js/freeze_store.js
Normal 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);
|
||||
|
|
@ -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:"折り畳み"; font-size:11px; opacity:.62; font-weight:700; }
|
||||
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"展開"; }
|
||||
.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);
|
||||
global.showToast?.(`${t.name}を冷凍保存しました。`);
|
||||
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
|
||||
global.renderStats?.();
|
||||
global.render?.();
|
||||
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] });
|
||||
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 === "親" || l.relation === "子") ? 1 : 0;
|
||||
const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8);
|
||||
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
|
||||
else {
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 });
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35));
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true });
|
||||
}
|
||||
t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)");
|
||||
t.recordChangeCause?.("冷凍中の\u8a03\u5831", "ストレス", { value: stress });
|
||||
t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death");
|
||||
worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] });
|
||||
}
|
||||
|
||||
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?.("解凍されて少し驚いている");
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
|
||||
t.thought = "解凍されて少し驚いている";
|
||||
t.frozenAt = 0;
|
||||
updateLiveIdAfterThaw(worldRef, t);
|
||||
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
|
||||
worldRef.tarinai.push(t);
|
||||
worldRef.recordFamily?.(t);
|
||||
applyPendingLossesOnThaw(t, entry, worldRef);
|
||||
list.splice(index, 1);
|
||||
worldRef.selected = t;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.familyTreeDirty = true;
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.rebuildSpatial?.(true);
|
||||
saveFrozenStore(worldRef);
|
||||
renderFrozenPanel(worldRef);
|
||||
global.showToast?.(`${t.name}を解凍しました。`);
|
||||
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
|
||||
global.renderStats?.();
|
||||
global.render?.();
|
||||
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] });
|
||||
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);
|
||||
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
|
||||
});
|
||||
}
|
||||
const canvas = global.canvas || document.getElementById("gameCanvas");
|
||||
if (canvas && canvas.dataset.freezeDropBound !== "1") {
|
||||
canvas.dataset.freezeDropBound = "1";
|
||||
canvas.addEventListener("dragover", (e) => {
|
||||
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
canvas.classList.add("freeze-drop-ready");
|
||||
});
|
||||
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
|
||||
canvas.addEventListener("drop", (e) => {
|
||||
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
|
||||
if (!id) return;
|
||||
e.preventDefault();
|
||||
canvas.classList.remove("freeze-drop-ready");
|
||||
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
|
||||
})();
|
||||
thawFrozen(id, p.x, p.y, worldRef);
|
||||
});
|
||||
}
|
||||
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");
|
||||
},
|
||||
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
76
js/ground_types.js
Normal 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,
|
||||
});
|
||||
|
|
@ -134,7 +134,7 @@ const HEALTH = (() => {
|
|||
if (actualLoss > 0.01) {
|
||||
cause = rememberDamage(t, actualLoss, reason);
|
||||
t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason });
|
||||
const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
|
||||
const plushie = (t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
|
||||
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
|
||||
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
|
||||
audio.damage?.(actualLoss);
|
||||
|
|
|
|||
626
js/item_lifecycle_runtime.js
Normal file
626
js/item_lifecycle_runtime.js
Normal 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
682
js/item_registry.js
Normal 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
533
js/item_render_runtime.js
Normal 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();
|
||||
}
|
||||
});
|
||||
115
js/item_type_initializers.js
Normal file
115
js/item_type_initializers.js
Normal 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
200
js/item_update_scheduler.js
Normal 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);
|
||||
|
|
@ -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);
|
||||
1388
js/items.js
1388
js/items.js
File diff suppressed because it is too large
Load diff
22
js/main.js
22
js/main.js
|
|
@ -3,8 +3,6 @@ let last = nowSec();
|
|||
let statsTimer = 0;
|
||||
let fpsTimer = 0;
|
||||
let fpsFrames = 0;
|
||||
let perfResizeTimer = 0;
|
||||
let lastPerfLevel = 0;
|
||||
window.__tarinaiFps = 0;
|
||||
|
||||
const LOADING_SPRITES = [
|
||||
|
|
@ -80,27 +78,24 @@ 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, world);
|
||||
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 perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
perfResizeTimer += rawDt;
|
||||
if (perfResizeTimer > 1.5) {
|
||||
if (perf !== lastPerfLevel) {
|
||||
lastPerfLevel = perf;
|
||||
resizeCanvas();
|
||||
}
|
||||
perfResizeTimer = 0;
|
||||
}
|
||||
const statsInterval = world.performanceQuality?.().statsInterval ?? (perf >= 3 ? 1.20 : perf === 2 ? 0.92 : perf === 1 ? 0.68 : 0.48);
|
||||
const statsInterval = 2.5;
|
||||
if (statsTimer > statsInterval) {
|
||||
renderStats();
|
||||
statsTimer = 0;
|
||||
|
|
@ -139,4 +134,3 @@ if ("serviceWorker" in navigator) {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
115
js/perf_profiler.js
Normal file
115
js/perf_profiler.js
Normal 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);
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
class TarinaiPerformanceManager {
|
||||
constructor() {
|
||||
this.fps = 60;
|
||||
this.level = 0;
|
||||
this.lastLevel = 0;
|
||||
this.samples = [];
|
||||
this.sampleLimit = 20;
|
||||
}
|
||||
|
||||
updateFps(fps, worldRef = null) {
|
||||
const n = Number(fps);
|
||||
if (!Number.isFinite(n) || n <= 0) return this.level;
|
||||
this.samples.push(n);
|
||||
if (this.samples.length > this.sampleLimit) this.samples.shift();
|
||||
const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length;
|
||||
this.fps = Math.round(avg);
|
||||
const next = this.computeLevel(avg);
|
||||
if (next !== this.level) {
|
||||
this.lastLevel = this.level;
|
||||
this.level = next;
|
||||
window.TarinaiEvents?.emit("performance:level", { level: this.level, previous: this.lastLevel, fps: this.fps, world: worldRef || null });
|
||||
}
|
||||
return this.level;
|
||||
}
|
||||
|
||||
computeLevel(fps = this.fps) {
|
||||
if (!fps || !Number.isFinite(fps)) return 0;
|
||||
if (fps < 22) return 3;
|
||||
if (fps < 34) return 2;
|
||||
if (fps < 48) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
quality(worldRef = null) {
|
||||
const level = this.level;
|
||||
let antCount = 0;
|
||||
for (const ant of worldRef?.ants || []) if (ant && !ant.dead) antCount += 1;
|
||||
const itemCount = (worldRef?.items || []).length || 0;
|
||||
const effectCount = (worldRef?.effects || []).length || 0;
|
||||
const antStride = antCount >= 95 || level >= 3 ? 3 : (antCount >= 35 || level >= 2 ? 2 : 1);
|
||||
return {
|
||||
fps: this.fps,
|
||||
level,
|
||||
antStride,
|
||||
itemCount,
|
||||
antCount,
|
||||
effectCount,
|
||||
effectLimit: level >= 3 ? 34 : level >= 2 ? 52 : level >= 1 ? 68 : 88,
|
||||
statsInterval: level >= 3 ? 1.20 : level === 2 ? 0.92 : level === 1 ? 0.68 : 0.48,
|
||||
terrainRedrawInterval: level >= 3 ? 1.30 : level === 2 ? 1.05 : level === 1 ? 0.82 : 0.62,
|
||||
backgroundLightStep: level >= 3 ? 64 : level === 2 ? 96 : 180,
|
||||
uiUpdateInterval: level >= 3 ? 1.2 : level === 2 ? 0.9 : level === 1 ? 0.65 : 0.45,
|
||||
};
|
||||
}
|
||||
|
||||
debugSnapshot(worldRef = null) {
|
||||
return { version: window.TARINAI_VERSION || "", fps: this.fps, level: this.level, quality: this.quality(worldRef) };
|
||||
}
|
||||
}
|
||||
|
||||
window.TarinaiPerformance = window.TarinaiPerformance || new TarinaiPerformanceManager();
|
||||
|
|
@ -1,86 +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 "親";
|
||||
if (deadFamily && (observer.children || []).includes(deadFamily)) return "子";
|
||||
if (observerFamily && (dead.parents || []).includes(observerFamily)) return "子";
|
||||
if (observerFamily && (dead.children || []).includes(observerFamily)) return "親";
|
||||
const rel = deadId ? observer.relationships?.[deadId] : null;
|
||||
const reverse = observerId ? dead.relationships?.[observerId] : null;
|
||||
if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達";
|
||||
return "";
|
||||
}
|
||||
|
||||
function applyLiveDeathShock(observer, dead, relation, worldRef) {
|
||||
const strong = relation === "親" || relation === "子";
|
||||
if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた");
|
||||
const stress = strong ? 18 : 11;
|
||||
const fear = strong ? 2.1 : 1.45;
|
||||
if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" });
|
||||
else {
|
||||
observer.sleeping = false;
|
||||
observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true });
|
||||
observer.fearTimer = Math.max(observer.fearTimer || 0, fear);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 });
|
||||
observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)");
|
||||
}
|
||||
observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress });
|
||||
observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death");
|
||||
}
|
||||
|
||||
function notifyDeathToRelations(dead, reason = "") {
|
||||
const worldRef = this || global.world;
|
||||
if (!worldRef || !dead) return;
|
||||
const deadFamily = worldRef.tarinaiFamilyKey ? worldRef.tarinaiFamilyKey(dead) : (dead.familyKey || "");
|
||||
const deadId = dead.id || dead.releasedLiveId || "";
|
||||
let liveHits = 0;
|
||||
for (const other of worldRef.tarinai || []) {
|
||||
if (!other || other === dead || other.dead) continue;
|
||||
const relation = relationWithDead(other, dead, worldRef);
|
||||
if (!relation) continue;
|
||||
applyLiveDeathShock(other, dead, relation, worldRef);
|
||||
liveHits += 1;
|
||||
}
|
||||
for (const entry of (global.TarinaiFreezeSystem?.frozenList?.(worldRef) || [])) {
|
||||
const data = entry.data || entry;
|
||||
const relation = relationWithDead(data, dead, worldRef);
|
||||
if (!relation) continue;
|
||||
if (!Array.isArray(entry.pendingLosses)) entry.pendingLosses = [];
|
||||
const duplicate = entry.pendingLosses.some(l => (deadFamily && l.familyKey === deadFamily) || (deadId && l.liveId === deadId));
|
||||
if (!duplicate) {
|
||||
entry.pendingLosses.unshift({
|
||||
relation,
|
||||
name: dead.name || "たりない",
|
||||
familyKey: deadFamily,
|
||||
liveId: deadId,
|
||||
reason: reason || dead.deathReason || "死亡",
|
||||
time: worldRef.time || 0,
|
||||
});
|
||||
if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES;
|
||||
}
|
||||
}
|
||||
if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] });
|
||||
global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef);
|
||||
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
|
||||
}
|
||||
|
||||
function patchWorldPrototype() {
|
||||
const apply = (proto) => { proto.notifyDeathToRelations = notifyDeathToRelations; };
|
||||
if (global.TarinaiPatcher?.patchWorld?.("relationEvents", apply)) return;
|
||||
const World = global.World;
|
||||
if (!World) return;
|
||||
apply(World.prototype);
|
||||
}
|
||||
|
||||
global.TarinaiRelationEvents = { relationWithDead, applyLiveDeathShock, notifyDeathToRelations, patchWorldPrototype };
|
||||
patchWorldPrototype();
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
405
js/render.js
405
js/render.js
|
|
@ -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) {
|
||||
|
|
@ -348,6 +441,113 @@ function isEntityVisibleInRect(entity, rect, extra = 0) {
|
|||
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
|
||||
}
|
||||
|
||||
function renderStackSignature(worldRef) {
|
||||
return [
|
||||
(worldRef.items || []).length,
|
||||
(worldRef.tarinai || []).length,
|
||||
(worldRef.ants || []).length,
|
||||
worldRef.terrainVersion || 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function ensureWorldRenderStack(worldRef) {
|
||||
if (!worldRef._renderStack) {
|
||||
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
|
||||
}
|
||||
const stack = worldRef._renderStack;
|
||||
const signature = renderStackSignature(worldRef);
|
||||
if (!worldRef.drawListDirty && stack.signature === signature) return stack;
|
||||
|
||||
stack.backItems.length = 0;
|
||||
stack.layered.length = 0;
|
||||
stack.carriedPlushies.length = 0;
|
||||
stack.lodgedPins.length = 0;
|
||||
|
||||
for (const it of worldRef.items || []) {
|
||||
if (!it || it.dead || isCachedTerrainItem(it)) continue;
|
||||
if (isPinType(it.type) && it.pinState === "lodged") {
|
||||
stack.lodgedPins.push(it);
|
||||
continue;
|
||||
}
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) {
|
||||
stack.carriedPlushies.push(it);
|
||||
continue;
|
||||
}
|
||||
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
|
||||
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
|
||||
}
|
||||
for (const t of worldRef.tarinai || []) {
|
||||
if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
|
||||
}
|
||||
for (const a of worldRef.ants || []) {
|
||||
if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
|
||||
}
|
||||
|
||||
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
|
||||
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
|
||||
stack.signature = signature;
|
||||
worldRef.drawListDirty = false;
|
||||
return stack;
|
||||
}
|
||||
|
||||
|
||||
function 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) {
|
||||
|
|
@ -435,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;
|
||||
|
|
@ -454,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");
|
||||
|
|
@ -471,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;
|
||||
|
|
@ -506,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);
|
||||
|
|
@ -519,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);
|
||||
|
|
@ -539,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);
|
||||
|
|
@ -558,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);
|
||||
|
|
@ -655,8 +877,7 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
|
|||
function drawRain(ctx, w, h, t) {
|
||||
if (world.weather !== "light_rain") return;
|
||||
ctx.save();
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
const count = perf >= 2 ? 64 : 138;
|
||||
const count = 96;
|
||||
const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites.
|
||||
const slant = Math.tan(angle);
|
||||
for (let i = 0; i < count; i++) {
|
||||
|
|
@ -680,7 +901,7 @@ function drawRain(ctx, w, h, t) {
|
|||
ctx.moveTo(x - dx * 0.5, y - dy * 0.5);
|
||||
ctx.lineTo(x + dx * 0.5, y + dy * 0.5);
|
||||
ctx.stroke();
|
||||
if (perf < 3 && i % 4 === 0) {
|
||||
if (i % 4 === 0) {
|
||||
ctx.globalAlpha = 0.13 * alpha;
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.70)";
|
||||
ctx.lineWidth = 0.7;
|
||||
|
|
@ -746,60 +967,31 @@ function render() {
|
|||
}
|
||||
ctx.restore();
|
||||
|
||||
const perf = world.performanceLevel ? world.performanceLevel() : 0;
|
||||
if (perf < 2) drawLightRays(ctx, screenW, screenH, lighting);
|
||||
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);
|
||||
|
||||
// Low bedding is drawn one step behind the live sprite stack.
|
||||
const backItems = [];
|
||||
const layered = [];
|
||||
for (const it of world.items) {
|
||||
if (!it || it.dead || isCachedTerrainItem(it)) continue;
|
||||
if (isPinType(it.type) && it.pinState === "lodged") continue;
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById) continue;
|
||||
if (!isEntityVisibleInRect(it, visibleRect)) continue;
|
||||
if (isBehindSpriteLayerItem(it)) backItems.push(it);
|
||||
else layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
|
||||
// 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);
|
||||
}
|
||||
backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
|
||||
for (const it of backItems) it.draw(ctx, world.time, lighting);
|
||||
|
||||
ctx.save();
|
||||
ctx.lineWidth = 1.15;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeStyle = light > 0.42 ? "rgba(115, 151, 86, 0.32)" : "rgba(202, 220, 255, 0.22)";
|
||||
for (const t of world.tarinai) {
|
||||
if (!isEntityVisibleInRect(t, visibleRect, 80)) continue;
|
||||
const p = t.parentToFollow?.();
|
||||
if (!p || !t.juvenile || t.dead || p.dead || dist(t, p) > 96 || !isEntityVisibleInRect(p, visibleRect, 80)) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(t.x, t.y);
|
||||
ctx.quadraticCurveTo((t.x + p.x) * 0.5, Math.min(t.y, p.y) - 10, p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
|
||||
|
||||
for (const t of world.tarinai || []) {
|
||||
if (t && !t.dead && isEntityVisibleInRect(t, visibleRect)) layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
|
||||
for (const entry of renderStack.layered) {
|
||||
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
|
||||
}
|
||||
for (const a of world.ants || []) {
|
||||
if (a && !a.dead && isEntityVisibleInRect(a, visibleRect)) layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
|
||||
}
|
||||
layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
|
||||
for (const entry of layered) entry.entity.draw(ctx, world.time, lighting);
|
||||
|
||||
for (const it of world.items) {
|
||||
if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
for (const it of renderStack.carriedPlushies) {
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
for (const it of world.items) {
|
||||
if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
for (const it of renderStack.lodgedPins) {
|
||||
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
for (const ef of world.effects) {
|
||||
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
|
||||
|
|
@ -869,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
35
js/restore_coordinator.js
Normal 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
139
js/save_codec.js
Normal 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
47
js/save_storage.js
Normal 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);
|
||||
|
|
@ -1,75 +1,52 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const STORAGE_PREFIX = "tarinai_save_slot_v1_";
|
||||
const EXPORT_PREFIX = "TARINAI_SAVE_V1:";
|
||||
const SLOT_COUNT = 9;
|
||||
const Snapshot = global.TarinaiSnapshot;
|
||||
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js");
|
||||
const createSnapshot = (...args) => Snapshot.createSnapshot(...args);
|
||||
const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args);
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function base64ToBytes(text) {
|
||||
let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (b64.length % 4) b64 += "=";
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeSnapshot(snapshot) {
|
||||
const json = JSON.stringify(snapshot);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
return EXPORT_PREFIX + bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function decodeSnapshot(text) {
|
||||
const raw = String(text || "").trim();
|
||||
if (!raw) throw new Error("empty import text");
|
||||
if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw);
|
||||
const bytes = base64ToBytes(raw);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
|
||||
|
||||
function saveSlot(slot) {
|
||||
const snapshot = createSnapshot(global.world);
|
||||
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function loadSlot(slot) {
|
||||
const raw = localStorage.getItem(slotKey(slot));
|
||||
if (!raw) throw new Error("empty slot");
|
||||
return restoreSnapshot(JSON.parse(raw), global.world);
|
||||
}
|
||||
|
||||
function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); }
|
||||
|
||||
function readSlot(slot) {
|
||||
const raw = localStorage.getItem(slotKey(slot));
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
const 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;
|
||||
|
||||
function createSnapshot(worldRef = global.world) {
|
||||
return Snapshot.createSnapshot(worldRef);
|
||||
}
|
||||
|
||||
function restoreSnapshot(snapshot, worldRef = global.world, options = {}) {
|
||||
return Restore.restoreSnapshot(snapshot, worldRef, { source: options.source || "save", syncUi: options.syncUi });
|
||||
}
|
||||
|
||||
async function encodeSnapshot(snapshot = createSnapshot(global.world)) {
|
||||
return Codec.encodeSnapshot(snapshot);
|
||||
}
|
||||
|
||||
async function decodeSnapshot(text) {
|
||||
return Codec.decodeSnapshot(text);
|
||||
}
|
||||
|
||||
async function saveSlot(slot) {
|
||||
const snapshot = createSnapshot(global.world);
|
||||
return Storage.writeSlot(slot, snapshot);
|
||||
}
|
||||
|
||||
async function loadSlot(slot) {
|
||||
const snapshot = Storage.requireSlot(slot);
|
||||
return restoreSnapshot(snapshot, global.world, { source: "slot" });
|
||||
}
|
||||
|
||||
function deleteSlot(slot) { Storage.deleteSlot(slot); }
|
||||
function readSlot(slot) { return Storage.readSlot(slot); }
|
||||
|
||||
function formatSlotSummary(snapshot) {
|
||||
if (!snapshot) return "空き";
|
||||
const d = snapshot.summary || {};
|
||||
const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明";
|
||||
return `${date} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 0}匹`;
|
||||
if (!snapshot) return "\u7a7a\u304d";
|
||||
const m = snapshot.m || [];
|
||||
const date = snapshot.c ? new Date(snapshot.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e";
|
||||
const day = m[0] || 1;
|
||||
const population = m[1] || 0;
|
||||
const fieldType = m[2] || "\u5ead";
|
||||
const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : "";
|
||||
return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339`;
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
|
|
@ -141,7 +118,9 @@
|
|||
openDialog();
|
||||
});
|
||||
const dialog = ensureDialog();
|
||||
dialog.addEventListener("click", (e) => {
|
||||
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]");
|
||||
const loadBtn = e.target.closest("[data-load-slot]");
|
||||
|
|
@ -149,61 +128,61 @@
|
|||
if (saveBtn) {
|
||||
const slot = Number(saveBtn.dataset.saveSlot);
|
||||
try {
|
||||
saveSlot(slot);
|
||||
await saveSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.notify?.();
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}に\u4fdd\u5b58しました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("\u4fdd\u5b58に失敗しました。空き容量を確認してください。");
|
||||
global.showToast?.("\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (loadBtn) {
|
||||
const slot = Number(loadBtn.dataset.loadSlot);
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みます。現在の状態は上書きされます。`)) return;
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
|
||||
try {
|
||||
loadSlot(slot);
|
||||
await loadSlot(slot);
|
||||
closeDialog();
|
||||
global.audio?.notify?.();
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("読み込みに失敗しました。");
|
||||
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (deleteBtn) {
|
||||
const slot = Number(deleteBtn.dataset.deleteSlot);
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しますか?`)) return;
|
||||
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return;
|
||||
deleteSlot(slot);
|
||||
renderSlotList();
|
||||
global.audio?.delete?.();
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しました。`);
|
||||
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#saveCloseBtn")) closeDialog();
|
||||
if (e.target.closest("#saveExportBtn")) {
|
||||
const area = dialog.querySelector("#saveHashText");
|
||||
if (area) {
|
||||
area.value = encodeSnapshot(createSnapshot(global.world));
|
||||
area.value = await encodeSnapshot(createSnapshot(global.world));
|
||||
area.focus();
|
||||
area.select();
|
||||
}
|
||||
global.showToast?.("現在の状態をハッシュテキストに書き出しました。");
|
||||
global.showToast?.("\u73fe\u5728\u306e\u72b6\u614b\u3092\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306b\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002");
|
||||
}
|
||||
if (e.target.closest("#saveImportBtn")) {
|
||||
const area = dialog.querySelector("#saveHashText");
|
||||
const text = area?.value || "";
|
||||
if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; }
|
||||
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307fます。現在の状態は上書きされます。")) return;
|
||||
if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
|
||||
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
|
||||
try {
|
||||
restoreSnapshot(decodeSnapshot(text), global.world);
|
||||
restoreSnapshot(await decodeSnapshot(text), global.world, { source: "import" });
|
||||
closeDialog();
|
||||
global.showToast?.("ハッシュテキストから読み込みました。");
|
||||
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
global.showToast?.("読み込みに失敗しました。テキストを確認してください。");
|
||||
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30c6\u30ad\u30b9\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
190
js/sim_core.js
190
js/sim_core.js
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
const SNAPSHOT_VERSION = 3;
|
||||
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
|
||||
|
||||
function isPlainObject(value) {
|
||||
|
|
@ -56,6 +57,52 @@
|
|||
return target;
|
||||
}
|
||||
|
||||
function q(value, scale = 1, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? Math.round(n * scale) : fallback;
|
||||
}
|
||||
|
||||
function u(value, scale = 1, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n / scale : fallback;
|
||||
}
|
||||
|
||||
function needsToArray(needs = {}) {
|
||||
return NEED_KEYS.map(k => q(needs[k], 1));
|
||||
}
|
||||
|
||||
function arrayToNeeds(arr = []) {
|
||||
const out = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
|
||||
NEED_KEYS.forEach((k, i) => { out[k] = u(arr[i], 1); });
|
||||
return out;
|
||||
}
|
||||
|
||||
function flagPack(t = {}) {
|
||||
let flags = 0;
|
||||
if (t.dead) flags |= 1 << 0;
|
||||
if (t.favorite) flags |= 1 << 1;
|
||||
if (t.zunchiDisease) flags |= 1 << 2;
|
||||
if (t.sleepDisease) flags |= 1 << 3;
|
||||
if (t.explosionDisease) flags |= 1 << 4;
|
||||
if (t.fightDisease) flags |= 1 << 5;
|
||||
if (t.isZunchiSlave) flags |= 1 << 6;
|
||||
if (t.zunchiSlaveLocked) flags |= 1 << 7;
|
||||
if (t.birthRitualLeader) flags |= 1 << 8;
|
||||
return flags;
|
||||
}
|
||||
|
||||
function flagApply(t, flags = 0) {
|
||||
t.dead = !!(flags & (1 << 0));
|
||||
t.favorite = !!(flags & (1 << 1));
|
||||
t.zunchiDisease = !!(flags & (1 << 2));
|
||||
t.sleepDisease = !!(flags & (1 << 3));
|
||||
t.explosionDisease = !!(flags & (1 << 4));
|
||||
t.fightDisease = !!(flags & (1 << 5));
|
||||
t.isZunchiSlave = !!(flags & (1 << 6));
|
||||
t.zunchiSlaveLocked = !!(flags & (1 << 7));
|
||||
t.birthRitualLeader = !!(flags & (1 << 8));
|
||||
}
|
||||
|
||||
function refFor(worldRef, value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" };
|
||||
|
|
@ -75,77 +122,307 @@
|
|||
}
|
||||
|
||||
function worldData(worldRef) {
|
||||
const keys = [
|
||||
"w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai",
|
||||
"fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount",
|
||||
"liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason",
|
||||
"relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents",
|
||||
"lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength"
|
||||
return {
|
||||
fieldType: worldRef?.fieldType || "garden",
|
||||
groundType: worldRef?.groundType || "soil",
|
||||
time: Number(worldRef?.time) || 0,
|
||||
day: Number(worldRef?.day) || 1,
|
||||
cameraX: Number(worldRef?.cameraX) || 0,
|
||||
cameraY: Number(worldRef?.cameraY) || 0,
|
||||
weather: worldRef?.weather || "sunny",
|
||||
nextWeatherChange: Number(worldRef?.nextWeatherChange) || 0,
|
||||
deadCount: Number(worldRef?.deadCount) || 0,
|
||||
liveIdNext: Number(worldRef?.liveIdNext) || 1,
|
||||
liveIdSerial: Number(worldRef?.liveIdSerial) || 1,
|
||||
lastBirthAt: Number(worldRef?.lastBirthAt) || -999,
|
||||
maxGeneration: Number(worldRef?.maxGeneration) || 1,
|
||||
colonyMood: worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed",
|
||||
};
|
||||
}
|
||||
|
||||
function compactWorld(worldRef) {
|
||||
const mood = worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed";
|
||||
return [
|
||||
worldRef?.fieldType || "garden",
|
||||
q(worldRef?.time, 10),
|
||||
q(worldRef?.day, 1, 1),
|
||||
q(worldRef?.cameraX, 1),
|
||||
q(worldRef?.cameraY, 1),
|
||||
worldRef?.weather || "sunny",
|
||||
q(worldRef?.nextWeatherChange, 10),
|
||||
q(worldRef?.deadCount, 1),
|
||||
q(worldRef?.liveIdNext, 1, 1),
|
||||
q(worldRef?.liveIdSerial, 1, 1),
|
||||
q(worldRef?.lastBirthAt, 10, -9990),
|
||||
q(worldRef?.maxGeneration, 1, 1),
|
||||
mood,
|
||||
worldRef?.groundType || "soil",
|
||||
];
|
||||
const out = {};
|
||||
for (const key of keys) {
|
||||
const cloned = clonePlain(worldRef?.[key]);
|
||||
if (cloned !== undefined) out[key] = cloned;
|
||||
}
|
||||
|
||||
function applyCompactWorld(worldRef, arr = []) {
|
||||
worldRef.fieldType = arr[0] || "garden";
|
||||
worldRef.time = u(arr[1], 10);
|
||||
worldRef.day = u(arr[2], 1, 1);
|
||||
worldRef.cameraX = u(arr[3], 1);
|
||||
worldRef.cameraY = u(arr[4], 1);
|
||||
worldRef.weather = arr[5] || "sunny";
|
||||
worldRef.nextWeatherChange = u(arr[6], 10);
|
||||
worldRef.deadCount = u(arr[7], 1);
|
||||
worldRef.liveIdNext = Math.max(1, u(arr[8], 1, 1));
|
||||
worldRef.liveIdSerial = Math.max(1, u(arr[9], 1, 1));
|
||||
worldRef.lastBirthAt = u(arr[10], 10, -999);
|
||||
worldRef.maxGeneration = Math.max(1, u(arr[11], 1, 1));
|
||||
const moodId = arr[12] || "relaxed";
|
||||
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || worldRef.colonyMoodDefinition?.("relaxed") || { id: moodId, label: moodId, effects: { personality: {} } };
|
||||
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 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 {
|
||||
actionId: arr[0] || "",
|
||||
need: arr[1] || "",
|
||||
subNeed: arr[2] || "",
|
||||
phase: arr[3] || "",
|
||||
source: arr[4] || "need",
|
||||
reason: arr[5] || "",
|
||||
text: arr[6] || arr[5] || "",
|
||||
label: arr[7] || "",
|
||||
forcedId: arr[8] || "",
|
||||
target: expandBehaviorTarget(arr[9]),
|
||||
};
|
||||
}
|
||||
|
||||
function compactRelationships(t, tarinaiIndex) {
|
||||
const out = [];
|
||||
for (const [id, rel] of Object.entries(t.relationships || {})) {
|
||||
const idx = tarinaiIndex.get(id);
|
||||
if (!Number.isInteger(idx) || idx < 0) continue;
|
||||
const affinity = Number(rel?.affinity) || 0;
|
||||
const fear = Number(rel?.fear) || 0;
|
||||
const wins = Number(rel?.fightsWon) || 0;
|
||||
const losses = Number(rel?.fightsLost) || 0;
|
||||
if (Math.abs(affinity) < 1 && fear < 1 && wins < 1 && losses < 1) continue;
|
||||
out.push([idx, q(affinity, 10), q(fear, 10), q(wins, 1), q(losses, 1)]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
|
||||
const data = copyOwnData(entity, skip);
|
||||
const targetRef = refFor(worldRef, entity?.target);
|
||||
const panicTargetRef = refFor(worldRef, entity?.panicTarget);
|
||||
const targetRefObject = refFor(worldRef, entity?.targetRef);
|
||||
if (targetRef) data.__targetRef = targetRef;
|
||||
if (panicTargetRef) data.__panicTargetRef = panicTargetRef;
|
||||
if (targetRefObject) data.__targetRefObject = targetRefObject;
|
||||
return data;
|
||||
function expandRelationships(rows = [], tarinaiList = [], worldRef = null) {
|
||||
const out = {};
|
||||
for (const row of rows || []) {
|
||||
if (!Array.isArray(row)) continue;
|
||||
const other = tarinaiList[row[0]];
|
||||
if (!other?.id) continue;
|
||||
out[other.id] = {
|
||||
affinity: u(row[1], 10),
|
||||
fear: u(row[2], 10),
|
||||
fightsWon: u(row[3], 1),
|
||||
fightsLost: u(row[4], 1),
|
||||
lastEvent: (u(row[3], 1) || u(row[4], 1)) ? "fight" : "",
|
||||
lastTime: worldRef?.time || 0,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compactTarinai(t, idx, familyIndex, tarinaiIndex, itemIndex) {
|
||||
const parentIdx = (t.parents || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
|
||||
const childIdx = (t.children || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
|
||||
const timers = [
|
||||
q(t.reproductionTimer, 10), q(t.fightCooldown, 10), q(t.loveMochiTimer, 10), q(t.fightMochiTimer, 10),
|
||||
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.postBirthPeaceTimer, 10), q(t.awakeLockTimer, 10),
|
||||
q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100),
|
||||
];
|
||||
const modes = [t.powerItemMode || "", t.sizeItemMode || "", t.lifeItemMode || ""];
|
||||
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
|
||||
const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1;
|
||||
return [
|
||||
t.name || "", t.type || "smile", t.birthPersonality || null, t.currentPersonality || null, t.genetics || null,
|
||||
q(t.x, 1), q(t.y, 1), q(t.vx, 10), q(t.vy, 10), q(t.scale, 1000), q(t.adultScale, 1000),
|
||||
q(t.age, 10), q(t.lifeSpan, 10), q(t.birthTime, 10), q(t.generation, 1, 1), t.hasPaired ? 1 : 0,
|
||||
q(t.hunger, 1), q(t.loneliness, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1), needsToArray(t.needs || t.needRaw || {}),
|
||||
t.state || "idle", flagPack(t), parentIdx, childIdx, compactRelationships(t, tarinaiIndex), timers, modes,
|
||||
pinIdx, nestIdx, q(t.totalFightWins, 1), q(t.totalFightLosses, 1), t.deathReason || "", compactBehavior(t.behavior, tarinaiIndex, itemIndex),
|
||||
];
|
||||
}
|
||||
|
||||
function itemExtra(item, tarinaiIndex = new Map()) {
|
||||
const type = item?.type || "";
|
||||
if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0, item.plushieSpriteId || ""];
|
||||
if (type === "grass") return [q(item.growth, 1000), q(item.health, 1000), q(item.seedTimer, 10), q(item.eatenAmount, 10), q(item.fertilityBoost, 1000), q(item.lifeSpan, 10), q(item.wither, 1000), item.grassLimitExempt || item.manualGrass || item.placedByPlayer ? 1 : 0];
|
||||
if (type === "bed") return [q(item.comfort, 1000), q(item.wear, 1000)];
|
||||
if (type === "signboard") return [item.text || ""];
|
||||
if (type === "duplicator") return [item.storedFoodType || "", item.storedFoodLabel || ""];
|
||||
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000)];
|
||||
if (type === "zunchi") return [item.stage || "fresh", q(item.stageTimer, 10), q(item.fertility, 1000), q(item.freshness, 1000), item.zunchiVariant || ""];
|
||||
if (type === "ant_nest") return [q(item.antCount, 1), Array.isArray(item.antWorkers) ? item.antWorkers.map(v => q(v, 10)) : [], q(item.queenSpawnAt, 10)];
|
||||
if (type === "ant_corpse") return [item.workerSprite || "ant_worker", q(item.decayTimer, 10)];
|
||||
if (type === "firecracker") return [q(item.fuseTimer, 10), q(item.fuseMax, 10)];
|
||||
if (typeof isPinType === "function" && isPinType(type)) return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000), item.pinState || "loose", tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
|
||||
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return [item.toolSize || "medium", q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
|
||||
return null;
|
||||
}
|
||||
|
||||
function compactItem(item, index, tarinaiIndex = new Map()) {
|
||||
return [
|
||||
item?.type || "", q(item?.x, 1), q(item?.y, 1), q(item?.r, 10), q(item?.amount, 10), q(item?.age, 10), q(item?.seed, 10), item?.dead ? 1 : 0, itemExtra(item, tarinaiIndex),
|
||||
];
|
||||
}
|
||||
|
||||
function applyItemExtra(item, extra, tarinaiList = []) {
|
||||
if (!Array.isArray(extra)) return;
|
||||
const type = item.type || "";
|
||||
if (extra[0] === "S") {
|
||||
item.isStructure = true;
|
||||
item.hp = u(extra[1], 10);
|
||||
item.maxHp = u(extra[2], 10);
|
||||
item.attachment = u(extra[3], 10);
|
||||
item.usedCount = u(extra[4], 1);
|
||||
item.ownerId = tarinaiList[extra[5]]?.id || "";
|
||||
item.carriedById = tarinaiList[extra[6]]?.id || "";
|
||||
item.onHead = !!extra[7];
|
||||
item.plushieSpriteId = extra[8] || item.plushieSpriteId || "";
|
||||
const def = global.StructureRegistry?.get?.(type);
|
||||
if (def) {
|
||||
item.label = def.label;
|
||||
item.roles = clonePlain(def.roles) || item.roles || {};
|
||||
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === "grass") {
|
||||
item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000); 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") {
|
||||
item.text = String(extra[0] || "");
|
||||
} else if (type === "duplicator") {
|
||||
item.storedFoodType = extra[0] || ""; item.storedFoodLabel = extra[1] || "";
|
||||
item.roles.food = Boolean(item.storedFoodType);
|
||||
} else if (type === "ball") {
|
||||
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000);
|
||||
} else if (type === "zunchi") {
|
||||
item.stage = extra[0] || "fresh"; item.stageTimer = u(extra[1], 10); item.fertility = u(extra[2], 1000); item.freshness = u(extra[3], 1000); item.zunchiVariant = extra[4] || item.zunchiVariant || "zunchi";
|
||||
} else if (type === "ant_nest") {
|
||||
item.antCount = u(extra[0], 1); item.antWorkers = Array.isArray(extra[1]) ? extra[1].map(v => u(v, 10)) : []; item.queenSpawnAt = u(extra[2], 10);
|
||||
} else if (type === "ant_corpse") {
|
||||
item.workerSprite = extra[0] || "ant_worker"; item.decayTimer = u(extra[1], 10);
|
||||
} else if (type === "firecracker") {
|
||||
item.fuseTimer = u(extra[0], 10); item.fuseMax = u(extra[1], 10);
|
||||
} else if (typeof isPinType === "function" && isPinType(type)) {
|
||||
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000); item.pinState = extra[4] || "loose"; item.pinTargetId = tarinaiList[extra[5]]?.id || ""; item.pinAttachAngle = u(extra[6], 1000); item.pinAttachDistance = u(extra[7], 10); item.pinOffsetY = u(extra[8], 10);
|
||||
} else if (typeof isServingFoodType === "function" && isServingFoodType(type)) {
|
||||
item.toolSize = extra[0] || "medium"; item.foodServingsRemaining = u(extra[1], 10); item.foodServingsMax = u(extra[2], 10); item.amount = item.foodServingsRemaining;
|
||||
}
|
||||
}
|
||||
|
||||
function compactAnt(ant, itemIndex = new Map(), tarinaiIndex = new Map()) {
|
||||
return [ant.kind || "worker", itemIndex.get(ant.homeId) ?? -1, tarinaiIndex.get(ant.targetId) ?? -1, q(ant.x, 1), q(ant.y, 1), q(ant.vx, 10), q(ant.vy, 10), q(ant.hp, 10), q(ant.maxHp, 10), ant.state || "search", q(ant.age, 10), q(ant.seed, 10), q(ant.returnTimer, 10), q(ant.foundingX, 1), q(ant.foundingY, 1), q(ant.foundingDelayUntil, 10), q(ant.foundingAttempts, 1), ant.dead ? 1 : 0];
|
||||
}
|
||||
|
||||
function createSnapshot(worldRef = global.world) {
|
||||
if (!worldRef) throw new Error("world is not ready");
|
||||
const selectedRef = refFor(worldRef, worldRef.selected);
|
||||
const tarinaiLive = (worldRef.tarinai || []).filter(t => t && !t.dead);
|
||||
const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead);
|
||||
const tarinaiIndex = new Map();
|
||||
const familyIndex = new Map();
|
||||
tarinaiLive.forEach((t, i) => {
|
||||
if (t.id) tarinaiIndex.set(t.id, i);
|
||||
if (t.familyKey) familyIndex.set(t.familyKey, i);
|
||||
});
|
||||
const itemIndex = new Map();
|
||||
itemsLive.forEach((it, i) => { if (it.id) itemIndex.set(it.id, i); });
|
||||
const now = Date.now();
|
||||
return {
|
||||
version: SNAPSHOT_VERSION,
|
||||
app: "tarinai_colony_game",
|
||||
createdAt: Date.now(),
|
||||
summary: {
|
||||
day: worldRef.day || 1,
|
||||
time: worldRef.clockString ? worldRef.clockString() : "",
|
||||
fieldType: worldRef.fieldType || "garden",
|
||||
population: (worldRef.tarinai || []).filter(t => t && !t.dead).length,
|
||||
items: (worldRef.items || []).filter(it => it && !it.dead).length,
|
||||
},
|
||||
world: worldData(worldRef),
|
||||
tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)),
|
||||
items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)),
|
||||
ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)),
|
||||
effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)),
|
||||
logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [],
|
||||
selectedRef,
|
||||
v: SNAPSHOT_VERSION,
|
||||
a: "tcg",
|
||||
c: now,
|
||||
m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length, 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)),
|
||||
n: (worldRef.ants || []).filter(a => a && !a.dead).map(a => compactAnt(a, itemIndex, tarinaiIndex)),
|
||||
s: null,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTarinaiData(worldRef, data, opts = {}) {
|
||||
function expandTarinai(row, index, worldRef) {
|
||||
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 = "";
|
||||
}
|
||||
const opts = {
|
||||
familyKey: `sf${index}`,
|
||||
id: `st${index}`,
|
||||
liveToken: 1,
|
||||
name: row[0] || undefined,
|
||||
type: row[1] || "smile",
|
||||
birthPersonality: row[2] || undefined,
|
||||
currentPersonality: row[3] || undefined,
|
||||
genetics: row[4] || undefined,
|
||||
x: u(row[5], 1), y: u(row[6], 1), vx: u(row[7], 10), vy: u(row[8], 10),
|
||||
scale: u(row[9], 1000, 0.28), adultScale: u(row[10], 1000, 0.28),
|
||||
age: u(row[11], 10), lifeSpan: u(row[12], 10, 1600), birthTime: u(row[13], 10), generation: Math.max(1, u(row[14], 1, 1)),
|
||||
hasPaired: !!row[15], hunger: u(row[16], 1), loneliness: u(row[17], 1), energy: u(row[18], 1), circadianSleepPressure: u(row[19], 1),
|
||||
needs: arrayToNeeds(row[20]), state: row[21] || "idle", behavior: expandBehavior(row[33]),
|
||||
};
|
||||
const t = new TarinaiClass(worldRef, opts);
|
||||
t.familyKey = `sf${index}`;
|
||||
t.id = `st${index}`;
|
||||
t.liveToken = 1;
|
||||
t.needRaw = { ...t.needs };
|
||||
t.needDisplay = { ...t.needs };
|
||||
t.previousNeeds = { ...t.needs };
|
||||
flagApply(t, Number(row[22]) || 0);
|
||||
t.__savedParentIdx = Array.isArray(row[23]) ? row[23] : [];
|
||||
t.__savedChildIdx = Array.isArray(row[24]) ? row[24] : [];
|
||||
t.__savedRelationships = Array.isArray(row[25]) ? row[25] : [];
|
||||
const timers = Array.isArray(row[26]) ? row[26] : [];
|
||||
t.reproductionTimer = u(timers[0], 10); t.fightCooldown = u(timers[1], 10); t.loveMochiTimer = u(timers[2], 10); t.fightMochiTimer = u(timers[3], 10);
|
||||
t.zunchiDiseaseSeverity = u(timers[4], 10); t.zunchiStain = u(timers[5], 10); t.postBirthPeaceTimer = u(timers[6], 10); t.awakeLockTimer = u(timers[7], 10);
|
||||
t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[8], 10), ammo: u(timers[9], 10) };
|
||||
t.mercuryLifeMultiplier = u(timers[10], 100, t.mercuryLifeMultiplier || 1);
|
||||
const modes = Array.isArray(row[27]) ? row[27] : [];
|
||||
t.powerItemMode = modes[0] || ""; t.sizeItemMode = modes[1] || ""; t.lifeItemMode = modes[2] || "";
|
||||
t.__savedPinIdx = row[28]; t.__savedNestIdx = row[29];
|
||||
t.totalFightWins = u(row[30], 1); t.totalFightLosses = u(row[31], 1); t.deathReason = row[32] || "";
|
||||
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
|
||||
return t;
|
||||
}
|
||||
|
||||
function restoreSnapshot(snapshot, worldRef = global.world) {
|
||||
if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data");
|
||||
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid save data");
|
||||
if (!worldRef) throw new Error("world is not ready");
|
||||
const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot));
|
||||
const fieldType = data.world?.fieldType || "garden";
|
||||
const fieldType = snapshot.w?.[0] || "garden";
|
||||
if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType);
|
||||
if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType);
|
||||
|
||||
|
|
@ -153,92 +430,92 @@
|
|||
worldRef.items = [];
|
||||
worldRef.ants = [];
|
||||
worldRef.effects = [];
|
||||
worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : [];
|
||||
worldRef.logs = [];
|
||||
worldRef.selected = null;
|
||||
|
||||
applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"]));
|
||||
worldRef.events = global.TarinaiEvents || null;
|
||||
worldRef.liveTarinai = new Map();
|
||||
worldRef.liveIdFree = [];
|
||||
applyCompactWorld(worldRef, snapshot.w || []);
|
||||
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
for (const saved of items) {
|
||||
if (!saved || !saved.type) continue;
|
||||
const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0);
|
||||
applyOwnData(item, saved, new Set(["target", "targetRef", "world"]));
|
||||
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
||||
for (let idx = 0; idx < tarRows.length; idx++) {
|
||||
const t = expandTarinai(tarRows[idx] || [], idx, worldRef);
|
||||
worldRef.tarinai.push(t);
|
||||
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 1, target: t });
|
||||
}
|
||||
for (const [idx, t] of worldRef.tarinai.entries()) {
|
||||
t.parents = (t.__savedParentIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
|
||||
t.children = (t.__savedChildIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
|
||||
delete t.__savedParentIdx; delete t.__savedChildIdx;
|
||||
}
|
||||
|
||||
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
||||
for (let idx = 0; idx < itemRows.length; idx++) {
|
||||
const row = itemRows[idx] || [];
|
||||
const type = row[0] || "";
|
||||
if (!type) continue;
|
||||
let item = null;
|
||||
if (Array.isArray(row[8]) && row[8][0] === "S" && global.StructureRegistry?.get?.(type)) {
|
||||
const owner = worldRef.tarinai[row[8][5]] || null;
|
||||
item = global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef);
|
||||
} else {
|
||||
item = new Item(type, u(row[1], 1), u(row[2], 1));
|
||||
}
|
||||
item.id = `si${idx}`;
|
||||
item.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);
|
||||
item.seed = u(row[6], 10, item.seed || 0);
|
||||
item.dead = !!row[7];
|
||||
applyItemExtra(item, row[8], worldRef.tarinai);
|
||||
worldRef.items.push(item);
|
||||
}
|
||||
|
||||
const tarinaiList = Array.isArray(data.tarinai) ? data.tarinai : [];
|
||||
for (const saved of tarinaiList) {
|
||||
if (!saved) continue;
|
||||
const t = restoreTarinaiData(worldRef, saved, { clearTransient: false });
|
||||
worldRef.tarinai.push(t);
|
||||
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t });
|
||||
for (const t of worldRef.tarinai) {
|
||||
if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null;
|
||||
if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null;
|
||||
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
|
||||
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships;
|
||||
}
|
||||
|
||||
const ants = Array.isArray(data.ants) ? data.ants : [];
|
||||
for (const saved of ants) {
|
||||
if (!saved) continue;
|
||||
const ant = new AntActor(worldRef, saved);
|
||||
applyOwnData(ant, saved, new Set(["world", "targetRef"]));
|
||||
ant.world = worldRef;
|
||||
const antRows = Array.isArray(snapshot.n) ? snapshot.n : [];
|
||||
for (let idx = 0; idx < antRows.length; idx++) {
|
||||
const row = antRows[idx] || [];
|
||||
const ant = new AntActor(worldRef, {
|
||||
id: `sa${idx}`,
|
||||
kind: row[0] || "worker",
|
||||
homeId: worldRef.items[row[1]]?.id || "",
|
||||
targetId: worldRef.tarinai[row[2]]?.id || "",
|
||||
targetToken: worldRef.tarinai[row[2]]?.liveToken || 0,
|
||||
x: u(row[3], 1), y: u(row[4], 1), vx: u(row[5], 10), vy: u(row[6], 10), hp: u(row[7], 10), maxHp: u(row[8], 10), state: row[9] || "search", age: u(row[10], 10), seed: u(row[11], 10), returnTimer: u(row[12], 10), foundingX: u(row[13], 1), foundingY: u(row[14], 1), foundingDelayUntil: u(row[15], 10), foundingAttempts: u(row[16], 1), dead: !!row[17],
|
||||
});
|
||||
ant.targetRef = ant.targetId ? worldRef.liveTarinaiById?.(ant.targetId, ant.targetToken) || null : null;
|
||||
worldRef.ants.push(ant);
|
||||
}
|
||||
|
||||
const effects = Array.isArray(data.effects) ? data.effects : [];
|
||||
for (const saved of effects) {
|
||||
if (!saved || !saved.type) continue;
|
||||
const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved);
|
||||
applyOwnData(ef, saved, new Set(["world", "target", "targetRef"]));
|
||||
worldRef.effects.push(ef);
|
||||
}
|
||||
|
||||
const itemById = new Map(worldRef.items.map(it => [it.id, it]));
|
||||
const antById = new Map(worldRef.ants.map(a => [a.id, a]));
|
||||
const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t]));
|
||||
const resolve = (ref) => {
|
||||
if (!ref) return null;
|
||||
if (ref.kind === "item") return itemById.get(ref.id) || null;
|
||||
if (ref.kind === "ant") return antById.get(ref.id) || null;
|
||||
if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null;
|
||||
return resolveRef(worldRef, ref);
|
||||
};
|
||||
for (const [i, saved] of tarinaiList.entries()) {
|
||||
const t = worldRef.tarinai[i];
|
||||
if (!t) continue;
|
||||
t.target = resolve(saved.__targetRef);
|
||||
t.panicTarget = resolve(saved.__panicTargetRef);
|
||||
}
|
||||
for (const [i, saved] of ants.entries()) {
|
||||
const ant = worldRef.ants[i];
|
||||
if (!ant) continue;
|
||||
ant.targetRef = resolve(saved.__targetRefObject);
|
||||
}
|
||||
|
||||
worldRef.selected = resolve(data.selectedRef);
|
||||
worldRef.family = clonePlain(data.world?.family) || worldRef.family || {};
|
||||
worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {};
|
||||
worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {};
|
||||
worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {};
|
||||
worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } };
|
||||
worldRef.family = {};
|
||||
worldRef.familyVersion = (worldRef.familyVersion || 0) + 1;
|
||||
for (const t of worldRef.tarinai) worldRef.recordFamily?.(t);
|
||||
worldRef.familyCleanVersion = null;
|
||||
worldRef.familyTreeDirty = true;
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef.terrainDirty = true;
|
||||
worldRef.relationNotices = {};
|
||||
worldRef.resolvedFightIds = {};
|
||||
worldRef.eventCounters = {};
|
||||
if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts();
|
||||
if (typeof worldRef.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");
|
||||
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 = {
|
||||
|
|
@ -249,9 +526,7 @@
|
|||
refFor,
|
||||
resolveRef,
|
||||
worldData,
|
||||
snapshotEntity,
|
||||
createSnapshot,
|
||||
restoreSnapshot,
|
||||
restoreTarinaiData,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
(function (global) {
|
||||
// External audio replacement point. Add files and edit paths here; audio.js consumes this pack at startup.
|
||||
global.TARINAI_SOUND_PACK = Object.freeze({
|
||||
version: "1.1",
|
||||
version: "1.2",
|
||||
categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 },
|
||||
idGain: {
|
||||
sfx_notify: 2.60,
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
sfx_ant_die: 0.14,
|
||||
sfx_notify: 0.10,
|
||||
sfx_log_push: 0.10,
|
||||
voice_sunbath: 0.95,
|
||||
},
|
||||
samplePaths: {
|
||||
voice_hau: "assets/sounds/voice_001_hau.wav",
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
sfx_eat: "assets/sounds/voice_004_eat.wav",
|
||||
voice_stress: "assets/sounds/voice_005_stress.wav",
|
||||
sfx_sleep: "assets/sounds/voice_006_sleep.wav",
|
||||
voice_sunbath: "assets/sounds/voice_007_sunbath_pokapoka.wav",
|
||||
sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3"
|
||||
},
|
||||
categories: {
|
||||
|
|
@ -46,7 +48,7 @@
|
|||
ops: "\u64cd\u4f5c"
|
||||
},
|
||||
soundMap: {
|
||||
voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice",
|
||||
voice_hau: "voice", voice_flee: "voice", voice_poop: "voice", voice_stress: "voice", voice_sunbath: "voice",
|
||||
sfx_eat: "voice", sfx_sleep: "voice", sfx_wake: "voice", sfx_birth: "voice", sfx_death: "voice",
|
||||
sfx_damage_soft: "voice", sfx_damage_heavy: "voice", sfx_fight_hit: "voice", sfx_fight_finish: "voice",
|
||||
sfx_disease: "voice", sfx_heal: "voice", sfx_ant_drag: "voice", sfx_ant_die: "voice", sfx_queen_ant: "voice",
|
||||
|
|
|
|||
90
js/structure_lifecycle.js
Normal file
90
js/structure_lifecycle.js
Normal 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);
|
||||
|
|
@ -22,13 +22,17 @@
|
|||
this.usedCount = 0;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.r = definition.type === "plushie" ? 13 : (definition.type === "grass_bed" ? 18 : 24);
|
||||
this.r = definition.type === "plushie" ? 6.5 : (definition.type === "grass_bed" ? 18 : 24);
|
||||
this.amount = this.hp;
|
||||
this.seed = Math.random() * 1000;
|
||||
this.roles = clonePlain(definition.roles);
|
||||
this.needEffects = clonePlain(definition.needEffects);
|
||||
this.carriedById = definition.type === "plushie" ? this.ownerId : "";
|
||||
this.onHead = definition.type === "plushie";
|
||||
if (definition.type === "plushie") {
|
||||
const sprites = typeof spawnableSprites === "function" ? spawnableSprites().map(s => s.id).filter(Boolean) : [];
|
||||
this.plushieSpriteId = sprites.length ? sprites[Math.floor(Math.random() * sprites.length)] : (owner?.type || "smile");
|
||||
}
|
||||
this.dead = false;
|
||||
this.world = world || null;
|
||||
}
|
||||
|
|
@ -50,15 +54,27 @@
|
|||
return;
|
||||
}
|
||||
this.x = owner.x;
|
||||
this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78);
|
||||
this.y = owner.y - Math.max(15, (owner.radius || 24) * 0.70);
|
||||
if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8);
|
||||
}
|
||||
if (this.type === "grass_bed") {
|
||||
const owner = this.owner(worldRef);
|
||||
if (!owner) {
|
||||
this.unownedDecayTimer = (this.unownedDecayTimer || 0) + dt;
|
||||
const rainBoost = worldRef?.weather === "light_rain" ? 1.65 : 1;
|
||||
const ageBoost = 1 + Math.min(1.8, this.unownedDecayTimer / 360);
|
||||
this.damage(dt * 0.070 * rainBoost * ageBoost, worldRef, null);
|
||||
if (this.dead) return;
|
||||
} else {
|
||||
this.unownedDecayTimer = 0;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
|
@ -78,7 +94,7 @@
|
|||
ctx.translate(this.x, this.y);
|
||||
const night = (lighting?.light || 1) < 0.42;
|
||||
if (this.type === "grass_bed") {
|
||||
// かんたんベッド: 緑色の小さい干草寝床。通常の干草寝床より一回り小さく、草束感を強める。
|
||||
// \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9: \u7dd1\u8272\u306e\u5c0f\u3055\u3044\u5e72\u8349\u5bdd\u5e8a\u3002\u901a\u5e38\u306e\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u56de\u308a\u5c0f\u3055\u304f\u3001\u8349\u675f\u611f\u3092\u5f37\u3081\u308b\u3002
|
||||
const grassFill = night ? "rgba(78,132,67,0.90)" : "rgba(84,177,72,0.93)";
|
||||
const grassStroke = night ? "rgba(39,78,43,0.76)" : "rgba(45,112,48,0.78)";
|
||||
const strawStroke = night ? "rgba(168,207,119,0.42)" : "rgba(205,234,128,0.66)";
|
||||
|
|
@ -106,13 +122,14 @@
|
|||
ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (this.type === "plushie") {
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile");
|
||||
const spriteId = this.plushieSpriteId || "smile";
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(spriteId, "smile") : (global.images?.get?.(spriteId) || global.images?.get?.("smile"));
|
||||
ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05);
|
||||
if (img) {
|
||||
const w = this.r * 2.2;
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null;
|
||||
const h = w * (metrics?.ratio || 0.92);
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h);
|
||||
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(spriteId) || getImageMetrics("smile") : null;
|
||||
const w = this.r * 3.0;
|
||||
const h = w * (metrics?.ratio || 0.74);
|
||||
ctx.drawImage(img, -w * 0.58, -h * 0.60, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "#f7dfc0";
|
||||
ctx.strokeStyle = "rgba(86,62,48,0.70)";
|
||||
|
|
@ -174,19 +191,33 @@
|
|||
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) {
|
||||
onUse(user, structure, worldRef, options = {}) {
|
||||
if (user?.id && !structure.ownerId) {
|
||||
structure.ownerId = user.id;
|
||||
structure.ownerName = user.name || "";
|
||||
structure.attachment = Math.max(structure.attachment || 0, 24);
|
||||
worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted");
|
||||
worldRef?.markTerrainDirty?.("grass-bed-owner-adopted");
|
||||
worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user });
|
||||
}
|
||||
const ownerUse = user?.id && user.id === structure.ownerId;
|
||||
if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2);
|
||||
structure.usedCount = (structure.usedCount || 0) + 1;
|
||||
if (user) {
|
||||
user.needShock = user.needShock || {};
|
||||
user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8));
|
||||
user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4));
|
||||
user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6));
|
||||
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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -38,6 +37,11 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
const growthAtConstruct = generationOpt > 1 ? clamp(((this.world?.time || 0) - this.birthTime) / Math.max(1, CONFIG.childGrowTime || 120), 0, 1) : 1;
|
||||
this.x = opts.x ?? rand(100, world.w - 100);
|
||||
this.y = opts.y ?? rand(100, world.h - 100);
|
||||
this._lastValidX = Number.isFinite(this.x) ? this.x : null;
|
||||
this._lastValidY = Number.isFinite(this.y) ? this.y : null;
|
||||
this._lastSunbathDrawX = Number.isFinite(opts._lastSunbathDrawX) ? opts._lastSunbathDrawX : null;
|
||||
this._lastSunbathDrawY = Number.isFinite(opts._lastSunbathDrawY) ? opts._lastSunbathDrawY : null;
|
||||
this._activeSunbathSpriteId = typeof opts._activeSunbathSpriteId === "string" ? opts._activeSunbathSpriteId : "";
|
||||
this.vx = opts.vx ?? rand(-12, 12);
|
||||
this.vy = opts.vy ?? rand(-12, 12);
|
||||
this.impulseVx = opts.impulseVx ?? 0;
|
||||
|
|
@ -67,11 +71,13 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
this.lastSatisfactionSource = opts.lastSatisfactionSource || "";
|
||||
this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needRaw || this.needs) : 0;
|
||||
this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...defaultNeeds, ...opts.previousNeeds } : { ...this.needs };
|
||||
this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null;
|
||||
this.currentAction = opts.currentAction && typeof opts.currentAction === "object" ? { ...opts.currentAction } : null;
|
||||
this.activeBehavior = opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null;
|
||||
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
|
||||
this.need = "";
|
||||
this.state = opts.state || "idle";
|
||||
this.behaviorSerial = opts.behaviorSerial ?? 0;
|
||||
this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function"
|
||||
? globalThis.normalizeTarinaiBehaviorState(opts, this)
|
||||
: (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : null);
|
||||
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
|
||||
this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {};
|
||||
this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {};
|
||||
this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null;
|
||||
|
|
@ -79,9 +85,8 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
this.hpBarTimer = opts.hpBarTimer ?? 0;
|
||||
this.stressBarTimer = opts.stressBarTimer ?? 0;
|
||||
this.affection = opts.affection ?? rand(0, 30);
|
||||
this.need = "";
|
||||
this.state = opts.state || "idle";
|
||||
this.dead = !!opts.dead;
|
||||
this.lifeStatus = opts.lifeStatus || (this.dead ? "dead" : "alive");
|
||||
this.deathReason = opts.deathReason || "";
|
||||
this.lastDamageCause = opts.lastDamageCause || "";
|
||||
this.lastDamageAmount = opts.lastDamageAmount ?? 0;
|
||||
|
|
@ -123,6 +128,8 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
this.sunbathCooldown = opts.sunbathCooldown ?? 0;
|
||||
this.sunbathFrameTimer = opts.sunbathFrameTimer ?? 0;
|
||||
this.sunbathSpriteVariant = opts.sunbathSpriteVariant ?? 1;
|
||||
this.sunbathAnchorX = Number.isFinite(opts.sunbathAnchorX) ? opts.sunbathAnchorX : null;
|
||||
this.sunbathAnchorY = Number.isFinite(opts.sunbathAnchorY) ? opts.sunbathAnchorY : null;
|
||||
this.intimidateTimer = opts.intimidateTimer ?? 0;
|
||||
this.intimidateTargetId = opts.intimidateTargetId || null;
|
||||
this.intimidatedTimer = opts.intimidatedTimer ?? 0;
|
||||
|
|
@ -174,7 +181,10 @@ class Tarinai { constructor(world, opts = {}) {
|
|||
this.fightTargetId = opts.fightTargetId || null;
|
||||
this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []);
|
||||
this.nextHeadbutt = opts.nextHeadbutt ?? 0;
|
||||
this.digest = opts.digest ?? rand(0, 2.4);
|
||||
const savedDigest = Number(opts.digest);
|
||||
this.digest = Number.isFinite(savedDigest)
|
||||
? (savedDigest > 3 ? Math.min(2.99, savedDigest / 12.4 * 3) : Math.max(0, savedDigest))
|
||||
: 0;
|
||||
this.poopCount = opts.poopCount ?? 0;
|
||||
this.oshiriByoZunchiStock = opts.oshiriByoZunchiStock ?? 0;
|
||||
this.stretchTimer = opts.stretchTimer ?? 0;
|
||||
|
|
|
|||
558
js/tarinai_action_definitions.js
Normal file
558
js/tarinai_action_definitions.js
Normal 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 });
|
||||
193
js/tarinai_action_spec.js
Normal file
193
js/tarinai_action_spec.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
const registry = new Map();
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function toList(value) {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).map(String);
|
||||
if (value == null || value === "") return [];
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function hasOwnFn(obj, key) {
|
||||
return typeof obj?.[key] === "function";
|
||||
}
|
||||
|
||||
function resolveActionSpec(action) {
|
||||
if (!action) return null;
|
||||
return typeof action === "string" ? getTarinaiActionSpec(action) : action;
|
||||
}
|
||||
|
||||
function buildActionContext(tarinai, world, ctx = {}, spec = null) {
|
||||
const base = isObject(ctx) ? { ...ctx } : {};
|
||||
if (base.target === undefined && spec && hasOwnFn(spec, "selectTarget")) {
|
||||
base.target = spec.selectTarget(tarinai, world, base);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function normalizeTarinaiActionSpec(input = {}) {
|
||||
if (!isObject(input)) throw new TypeError("ActionSpec must be an object");
|
||||
const id = String(input.id || input.actionId || "").trim();
|
||||
if (!id) throw new TypeError("ActionSpec.id is required");
|
||||
|
||||
// Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text.
|
||||
const spec = {
|
||||
...input,
|
||||
id,
|
||||
kind: String(input.kind || "need_action"),
|
||||
need: String(input.need || "fulfill"),
|
||||
subNeed: String(input.subNeed || ""),
|
||||
label: String(input.label || input.phrase || id),
|
||||
phrase: String(input.phrase || input.label || id),
|
||||
weight: finiteOr(input.weight, 1),
|
||||
priority: finiteOr(input.priority, 0),
|
||||
tags: toList(input.tags),
|
||||
timerBacked: Boolean(input.timerBacked),
|
||||
interruptible: input.interruptible !== false,
|
||||
};
|
||||
|
||||
const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null;
|
||||
const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null;
|
||||
const originalStart = hasOwnFn(input, "start") ? input.start : null;
|
||||
const originalTick = hasOwnFn(input, "tick") ? input.tick : null;
|
||||
const originalFinish = hasOwnFn(input, "finish") ? input.finish : null;
|
||||
const originalFail = hasOwnFn(input, "fail") ? input.fail : null;
|
||||
const originalText = hasOwnFn(input, "text") ? input.text : null;
|
||||
|
||||
spec.selectTarget = function selectTarget(tarinai, world, ctx = {}) {
|
||||
if (originalSelectTarget) return originalSelectTarget.call(spec, tarinai, world, ctx);
|
||||
return ctx.target ?? tarinai?.target ?? null;
|
||||
};
|
||||
|
||||
spec.canStart = function canStart(tarinai, world, ctx = {}) {
|
||||
if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx));
|
||||
return true;
|
||||
};
|
||||
|
||||
spec.start = function start(tarinai, world, ctx = {}) {
|
||||
if (originalStart) return originalStart.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);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
spec.finish = function finish(tarinai, world, ctx = {}) {
|
||||
return originalFinish ? originalFinish.call(spec, tarinai, world, ctx) : true;
|
||||
};
|
||||
|
||||
spec.fail = function fail(tarinai, world, ctx = {}) {
|
||||
return originalFail ? originalFail.call(spec, tarinai, world, ctx) : false;
|
||||
};
|
||||
|
||||
spec.text = function text(tarinai, world, ctx = {}) {
|
||||
if (originalText) return String(originalText.call(spec, tarinai, world, ctx) || "");
|
||||
if (ctx.phase === "finished") return spec.phrase;
|
||||
return spec.label;
|
||||
};
|
||||
|
||||
Object.defineProperty(spec, "__actionSpec", {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
});
|
||||
return spec;
|
||||
}
|
||||
|
||||
function registerTarinaiActionSpecs(definitions = []) {
|
||||
const specs = [];
|
||||
for (const definition of definitions || []) {
|
||||
const spec = normalizeTarinaiActionSpec(definition);
|
||||
registry.set(spec.id, spec);
|
||||
specs.push(spec);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function registerTarinaiActionSpec(definition = {}) {
|
||||
const spec = normalizeTarinaiActionSpec(definition);
|
||||
registry.set(spec.id, spec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
function getTarinaiActionSpec(id = "") {
|
||||
return registry.get(String(id || "")) || null;
|
||||
}
|
||||
|
||||
function listTarinaiActionSpecs() {
|
||||
return Array.from(registry.values());
|
||||
}
|
||||
|
||||
function selectTarinaiActionTarget(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
if (!spec) return null;
|
||||
return hasOwnFn(spec, "selectTarget") ? spec.selectTarget(tarinai, world, ctx) : (ctx.target ?? tarinai?.target ?? null);
|
||||
}
|
||||
|
||||
function canStartTarinaiAction(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
if (!spec) return false;
|
||||
return hasOwnFn(spec, "canStart") ? Boolean(spec.canStart(tarinai, world, isObject(ctx) ? ctx : {})) : true;
|
||||
}
|
||||
|
||||
function startTarinaiAction(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
if (!spec) return false;
|
||||
const canCtx = isObject(ctx) ? ctx : {};
|
||||
if (hasOwnFn(spec, "canStart") && !spec.canStart(tarinai, world, canCtx)) return false;
|
||||
const nextCtx = buildActionContext(tarinai, world, canCtx, spec);
|
||||
return hasOwnFn(spec, "start") ? spec.start(tarinai, world, nextCtx) : spec.run?.(tarinai, world, nextCtx);
|
||||
}
|
||||
|
||||
function tickTarinaiAction(action, tarinai, world, dt = 0, needs = null, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
if (!spec) return undefined;
|
||||
const nextCtx = buildActionContext(tarinai, world, ctx, spec);
|
||||
return hasOwnFn(spec, "tick") ? spec.tick(tarinai, world, dt, needs, nextCtx) : spec.update?.(tarinai, world, dt, needs, nextCtx);
|
||||
}
|
||||
|
||||
function finishTarinaiAction(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
return hasOwnFn(spec, "finish") ? spec.finish(tarinai, world, ctx) : true;
|
||||
}
|
||||
|
||||
function failTarinaiAction(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
return hasOwnFn(spec, "fail") ? spec.fail(tarinai, world, ctx) : false;
|
||||
}
|
||||
|
||||
function textTarinaiAction(action, tarinai, world, ctx = {}) {
|
||||
const spec = resolveActionSpec(action);
|
||||
if (!spec) return "";
|
||||
return hasOwnFn(spec, "text") ? String(spec.text(tarinai, world, isObject(ctx) ? ctx : {}) || "") : String(spec.label || spec.phrase || spec.id || "");
|
||||
}
|
||||
|
||||
Object.assign(global, {
|
||||
normalizeTarinaiActionSpec,
|
||||
createTarinaiActionSpec: normalizeTarinaiActionSpec,
|
||||
registerTarinaiActionSpec,
|
||||
registerTarinaiActionSpecs,
|
||||
getTarinaiActionSpec,
|
||||
listTarinaiActionSpecs,
|
||||
selectTarinaiActionTarget,
|
||||
canStartTarinaiAction,
|
||||
startTarinaiAction,
|
||||
tickTarinaiAction,
|
||||
finishTarinaiAction,
|
||||
failTarinaiAction,
|
||||
textTarinaiAction,
|
||||
TarinaiActionSpecRegistry: registry,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -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,17 +23,74 @@
|
|||
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;
|
||||
},
|
||||
|
||||
beginEating(item = null, reason = "") {
|
||||
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている");
|
||||
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b");
|
||||
const ok = this.setActionState("eat", {
|
||||
target: item || null,
|
||||
reason: text,
|
||||
sleeping: false,
|
||||
});
|
||||
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
|
||||
if (typeof 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 || "待っている");
|
||||
return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", 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,15 +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",
|
||||
};
|
||||
}
|
||||
this.intent = this.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: reason || "眠っている", reasonText: "ねむいので、眠っている。", startedAt: now };
|
||||
this.currentAction = this.currentAction || { id: this.intent.actionId || "sleep", need: "sleep", startedAt: this.sleepSession.startedAt, minDuration: this.sleepSession.minDuration, lockSeconds: this.sleepSession.minDuration };
|
||||
const text = reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている");
|
||||
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "眠っている", reasonText: text, target, phase: "perform", source: "behavior" });
|
||||
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 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 });
|
||||
},
|
||||
|
|
@ -81,8 +163,8 @@
|
|||
},
|
||||
|
||||
currentBehaviorText() {
|
||||
if (typeof activeBehaviorText === "function") return activeBehaviorText(this);
|
||||
return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim();
|
||||
if (typeof behaviorText === "function") return behaviorText(this);
|
||||
return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim();
|
||||
},
|
||||
|
||||
enterPanic(opts = {}) {
|
||||
|
|
@ -100,18 +182,23 @@
|
|||
if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock);
|
||||
}
|
||||
let handled = false;
|
||||
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている");
|
||||
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b");
|
||||
if (!handled) {
|
||||
let target = null;
|
||||
if ("target" in opts) target = opts.target;
|
||||
else if (opts.destination) target = opts.destination;
|
||||
else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination);
|
||||
const panicReason = opts.reason || this.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
|
||||
this.setActionState("panic", {
|
||||
target,
|
||||
reason: opts.reason || this.thought || "パニックになっている",
|
||||
reason: panicReason,
|
||||
wake: opts.wake !== false,
|
||||
sleeping: false,
|
||||
});
|
||||
if (typeof setBehaviorText === "function") {
|
||||
const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044");
|
||||
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);
|
||||
}
|
||||
if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer);
|
||||
|
|
@ -124,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;
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
246
js/tarinai_behavior_state.js
Normal file
246
js/tarinai_behavior_state.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use strict";
|
||||
|
||||
(function (global) {
|
||||
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" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function nowFor(tarinai = null) {
|
||||
return finiteOr(tarinai?.world?.time, 0);
|
||||
}
|
||||
|
||||
function normalizePhase(value, fallback = "acting") {
|
||||
const phase = String(value || fallback || "acting");
|
||||
return PHASES.has(phase) ? phase : phase;
|
||||
}
|
||||
|
||||
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) {
|
||||
const src = isObject(value) ? value : {};
|
||||
const prev = isObject(fallback) ? fallback : {};
|
||||
const uid = String(src.uid || src.forcedId || prev.uid || prev.forcedId || "");
|
||||
const id = String(src.id || src.actionId || prev.id || prev.actionId || "");
|
||||
if (!uid && !id) return null;
|
||||
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.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)),
|
||||
startedAt: finiteOr(src.startedAt, finiteOr(src.activeSince, finiteOr(prev.startedAt, finiteOr(prev.activeSince, 0)))),
|
||||
activeSince: finiteOr(src.activeSince, finiteOr(src.startedAt, finiteOr(prev.activeSince, finiteOr(prev.startedAt, 0)))),
|
||||
expiresAt: Number.isFinite(Number(src.expiresAt)) ? Number(src.expiresAt) : (Number.isFinite(Number(prev.expiresAt)) ? Number(prev.expiresAt) : undefined),
|
||||
minDuration: finiteOr(src.minDuration, finiteOr(prev.minDuration, 0)),
|
||||
duration: finiteOr(src.duration, finiteOr(prev.duration, 0)),
|
||||
attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)),
|
||||
failReason: String(src.failReason || prev.failReason || ""),
|
||||
};
|
||||
}
|
||||
|
||||
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 now = nowFor(tarinai);
|
||||
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,
|
||||
(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
|
||||
);
|
||||
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 behavior;
|
||||
}
|
||||
|
||||
function normalizeBehaviorState(opts = {}, tarinai = null) {
|
||||
const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null;
|
||||
return normalizeBehaviorValue(opts.behavior || null, tarinai, previous);
|
||||
}
|
||||
|
||||
function ensureBehavior(tarinai) {
|
||||
if (!tarinai || !isObject(tarinai.behavior)) return null;
|
||||
const b = tarinai.behavior;
|
||||
return b;
|
||||
}
|
||||
|
||||
function assignBehavior(tarinai, value) {
|
||||
if (!tarinai) return null;
|
||||
const next = normalizeBehaviorValue(value, tarinai, tarinai.behavior || null);
|
||||
tarinai.behavior = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function patchTarinaiBehavior(tarinai, fields = {}) {
|
||||
if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai);
|
||||
const previous = ensureBehavior(tarinai) || {};
|
||||
const next = normalizeBehaviorValue({ ...previous, ...fields }, tarinai, previous);
|
||||
tarinai.behavior = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function clearTarinaiBehavior(tarinai, opts = {}) {
|
||||
if (!tarinai) return null;
|
||||
tarinai.behavior = null;
|
||||
if (opts.clearTarget) tarinai.target = null;
|
||||
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) {
|
||||
if (!tarinai) return null;
|
||||
const b = ensureBehavior(tarinai);
|
||||
if (!b) return null;
|
||||
if (!value) {
|
||||
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.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 (!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 || "" };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
168
js/tarinai_behavior_text.js
Normal file
168
js/tarinai_behavior_text.js
Normal 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 });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
116
js/tarinai_building_behavior.js
Normal file
116
js/tarinai_building_behavior.js
Normal 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;
|
||||
}
|
||||
150
js/tarinai_consumable_behavior.js
Normal file
150
js/tarinai_consumable_behavior.js
Normal 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;
|
||||
}
|
||||
|
|
@ -70,22 +70,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
this.sleepDiseaseCooldown = 8;
|
||||
this.sleepDiseaseAnchorX = this.x;
|
||||
this.sleepDiseaseAnchorY = this.y;
|
||||
this.startSleeping(null, "ねむり病で眠り続けている");
|
||||
this.startSleeping(null, "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
|
||||
this.fightTimer = 0;
|
||||
this.intimidateTimer = 0;
|
||||
this.fightTargetIds = [];
|
||||
|
|
@ -104,7 +95,7 @@
|
|||
this.sleepDiseaseCooldown = 14;
|
||||
this.sleepDiseaseAnchorX = null;
|
||||
this.sleepDiseaseAnchorY = null;
|
||||
this.goIdle(reason || "ねむり病が治った");
|
||||
this.goIdle(reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
this.sleeping = false;
|
||||
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
|
||||
audio.heal?.();
|
||||
|
|
@ -181,7 +172,7 @@
|
|||
const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy);
|
||||
if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x;
|
||||
if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y;
|
||||
this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている");
|
||||
this.startSleeping(null, beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
if (beingDraggedByAnt) {
|
||||
|
|
@ -204,15 +195,10 @@
|
|||
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)) return true;
|
||||
if (!(this.state === "sleep" || this.state === "seek_bed")) return false;
|
||||
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;
|
||||
if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false;
|
||||
return this.target === box || this.insideNestBoxId === box.id;
|
||||
},
|
||||
|
||||
|
|
@ -228,7 +214,7 @@
|
|||
}
|
||||
this.insideNestBoxId = box.id;
|
||||
this.target = box;
|
||||
if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている");
|
||||
if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
|
||||
if (this.state === "sleep") this.sleeping = true;
|
||||
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y };
|
||||
const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1);
|
||||
|
|
@ -278,8 +264,8 @@
|
|||
}
|
||||
|
||||
if (currentBox) {
|
||||
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) {
|
||||
this.startSleeping(box, "巣箱の中で眠っている");
|
||||
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) {
|
||||
this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
|
||||
this.sleeping = true;
|
||||
this.target = currentBox;
|
||||
}
|
||||
|
|
|
|||
466
js/tarinai_forced_behavior.js
Normal file
466
js/tarinai_forced_behavior.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -166,14 +151,14 @@
|
|||
rel.fightsWon = rel.fightsWon || 0;
|
||||
rel.fightsLost = rel.fightsLost || 0;
|
||||
if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
|
||||
this.relationCache = null;
|
||||
},
|
||||
|
||||
strongestRelation(kind = "friend", liveOnly = true) {
|
||||
let bestId = null;
|
||||
let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD;
|
||||
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id).filter(Boolean)) : null;
|
||||
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
||||
if (liveIds && !liveIds.has(id)) continue;
|
||||
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
|
||||
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
|
||||
if (score > bestScore) { bestId = id; bestScore = score; }
|
||||
}
|
||||
|
|
@ -187,13 +172,16 @@
|
|||
},
|
||||
|
||||
currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) {
|
||||
const now = this.world?.time || 0;
|
||||
const cacheKey = `${minScore}:${liveOnly ? 1 : 0}`;
|
||||
if (this.relationCache?.friendCountKey === cacheKey && now < (this.relationCache.friendCountUntil || 0)) return this.relationCache.friendCount;
|
||||
let count = 0;
|
||||
const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null;
|
||||
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
||||
if ((rel.affinity || 0) <= minScore) continue;
|
||||
if (liveIds && !liveIds.has(id)) continue;
|
||||
if (liveOnly && (!this.world?.liveTarinaiById?.(id) || id === this.id)) continue;
|
||||
count += 1;
|
||||
}
|
||||
this.relationCache = { ...(this.relationCache || {}), friendCountKey: cacheKey, friendCount: count, friendCountUntil: now + 1.4 };
|
||||
return count;
|
||||
},
|
||||
|
||||
|
|
@ -203,16 +191,23 @@
|
|||
},
|
||||
|
||||
bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) {
|
||||
const now = this.world?.time || 0;
|
||||
const cacheKey = `${minScore}:${Number.isFinite(maxDist) ? Math.round(maxDist) : "inf"}`;
|
||||
if (this.relationCache?.bestFriendKey === cacheKey && now < (this.relationCache.bestFriendUntil || 0)) {
|
||||
const cached = this.world?.liveTarinaiById?.(this.relationCache.bestFriendId);
|
||||
if (cached && (!Number.isFinite(maxDist) || dist(this, cached) <= maxDist)) return cached;
|
||||
}
|
||||
let best = null, bestScore = minScore;
|
||||
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
||||
const score = rel.affinity || 0;
|
||||
if (score <= bestScore) continue;
|
||||
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
|
||||
const t = this.world.liveTarinaiById?.(id) || null;
|
||||
if (!t) continue;
|
||||
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||
best = t;
|
||||
bestScore = score;
|
||||
}
|
||||
this.relationCache = { ...(this.relationCache || {}), bestFriendKey: cacheKey, bestFriendId: best?.id || "", bestFriendUntil: now + 1.0 };
|
||||
return best;
|
||||
},
|
||||
|
||||
|
|
@ -223,7 +218,7 @@
|
|||
const when = rel.lastTime || -Infinity;
|
||||
if (this.world.time - when > maxAge) continue;
|
||||
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
|
||||
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
|
||||
const t = this.world.liveTarinaiById?.(id) || null;
|
||||
if (!t) continue;
|
||||
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
||||
if (when > bestT) { best = t; bestT = when; }
|
||||
|
|
@ -262,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); },
|
||||
|
|
@ -308,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;
|
||||
},
|
||||
|
|
@ -371,7 +347,7 @@
|
|||
this.targetGiveupKey = key;
|
||||
this.targetGiveupUntil = this.world.time + 8;
|
||||
this.target = null;
|
||||
this.goIdle?.("柵に阻まれて行き先を変えている");
|
||||
this.goIdle?.("\u67f5\u306b\u963b\u307e\u308c\u3066\u884c\u304d\u5148\u3092\u5909\u3048\u3066\u3044\u308b");
|
||||
this.wanderAngle += rand(-1.4, 1.4);
|
||||
return;
|
||||
}
|
||||
|
|
@ -379,7 +355,7 @@
|
|||
this.targetGiveupKey = key;
|
||||
this.targetGiveupUntil = this.world.time + 12;
|
||||
this.target = null;
|
||||
this.goIdle?.("辿り着けず、あきらめた");
|
||||
this.goIdle?.("\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f");
|
||||
this.wanderAngle += rand(-2.2, 2.2);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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?.() || [
|
||||
|
|
@ -218,14 +218,22 @@
|
|||
applyFoodHungerRelief(amount = 0) {
|
||||
const raw = Math.max(0, Number(amount) || 0);
|
||||
const actual = this.hasItemEffect("laxative") ? raw * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0) : raw;
|
||||
if (actual > 0.2) this.lastAteAt = this.world?.time || 0;
|
||||
if (actual >= 6) {
|
||||
const now = this.world?.time || 0;
|
||||
if (actual > 0.2) this.lastAteAt = now;
|
||||
if (actual >= 3.5) {
|
||||
const day = Number(this.world?.config?.dayLength || CONFIG?.dayLength || 120) || 120;
|
||||
const base = day / 4;
|
||||
const base = day / 3.65;
|
||||
const jitter = typeof stableUnit === "function" ? (0.82 + stableUnit(this.familyKey || this.id, "meal-cooldown") * 0.36) : 1;
|
||||
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, (this.world?.time || 0) + base * jitter);
|
||||
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, now + base * jitter);
|
||||
this.needSatisfaction = this.needSatisfaction || (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : {});
|
||||
this.needSatisfaction.food = Math.max(this.needSatisfaction.food || 0, Math.min(88, actual * 1.85));
|
||||
this.actionCooldowns = this.actionCooldowns || {};
|
||||
this.actionCooldowns.food = Math.max(this.actionCooldowns.food || 0, 5.2);
|
||||
}
|
||||
this.hunger -= actual;
|
||||
if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !(typeof 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;
|
||||
},
|
||||
|
||||
|
|
@ -267,7 +275,7 @@
|
|||
if (!this.world || this.dead) return;
|
||||
if (this.hasLodgedPinEffect?.("blocksZunchi")) {
|
||||
this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1);
|
||||
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している";
|
||||
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
|
||||
return;
|
||||
}
|
||||
const side = this.facingDir ? this.facingDir() : (this.facing || 1);
|
||||
|
|
@ -277,10 +285,8 @@
|
|||
this.poopCount = (this.poopCount || 0) + 1;
|
||||
this.digest = Math.max(0, (this.digest || 0) - 3.0);
|
||||
const now = this.world?.time || 0;
|
||||
if (now >= (this.nextPoopBubbleAt || -999)) {
|
||||
this.nextPoopBubbleAt = now + 2.2;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative);
|
||||
}
|
||||
this.nextPoopBubbleAt = now + 2.2;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 1.2, ITEM_EFFECT_COLORS.laxative, { force: true });
|
||||
if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) {
|
||||
this.nextLaxativeLogAt = (this.world.time || 0) + 9;
|
||||
this.world.log?.(`${this.name}\u306f\u4e0b\u5264\u306e\u52b9\u679c\u3067\u305a\u3093\u3061\u3092\u653e\u51fa\u3057\u305f\u3002`, "accident", { participants: [this] });
|
||||
|
|
|
|||
344
js/tarinai_item_targeting.js
Normal file
344
js/tarinai_item_targeting.js
Normal 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
298
js/tarinai_needs_core.js
Normal 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
|
|
@ -3,13 +3,194 @@
|
|||
(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;
|
||||
const active = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
|
||||
if (!active) return false;
|
||||
|
||||
this.preloadSunbathSprites?.();
|
||||
const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"];
|
||||
const candidates = (Array.isArray(ids) && ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"]).filter(id => String(id || "").startsWith("sunbath"));
|
||||
const ready = (id) => typeof isImageReady === "function"
|
||||
? isImageReady(id)
|
||||
: !!(typeof images !== "undefined" && images?.get?.(id)?.complete && (images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width));
|
||||
const imageFor = (id) => {
|
||||
if (!id) return null;
|
||||
if (typeof getRenderableImage === "function") return getRenderableImage(id, "");
|
||||
return (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null;
|
||||
};
|
||||
|
||||
let requested = this.sunbathSpriteId ? this.sunbathSpriteId() : this.normalizeSunbathSpriteId?.(this.sunbathSpriteVariant);
|
||||
if (!requested || !candidates.includes(requested)) requested = candidates[0] || "sunbath_1";
|
||||
|
||||
const locked = this._activeSunbathSpriteId && candidates.includes(this._activeSunbathSpriteId) && ready(this._activeSunbathSpriteId)
|
||||
? this._activeSunbathSpriteId
|
||||
: "";
|
||||
const ordered = [locked, requested, this._lastRenderableSunbathSpriteId, ...candidates].filter(Boolean);
|
||||
let renderSprite = ordered.find(id => candidates.includes(id) && ready(id));
|
||||
let img = renderSprite ? imageFor(renderSprite) : null;
|
||||
|
||||
if (img && ready(renderSprite)) {
|
||||
this._activeSunbathSpriteId = renderSprite;
|
||||
this._lastRenderableSunbathSpriteId = renderSprite;
|
||||
this.sunbathSpriteVariant = renderSprite;
|
||||
this.visibleSpriteId = renderSprite;
|
||||
} else {
|
||||
const fallbackId = this._lastNonSunbathSpriteId || this.goodMode || this.type || "smile";
|
||||
renderSprite = fallbackId;
|
||||
img = typeof getRenderableImage === "function"
|
||||
? getRenderableImage(fallbackId, "smile")
|
||||
: (typeof images !== "undefined" ? (images.get?.(fallbackId) || images.get?.("smile")) : null);
|
||||
if (!img) {
|
||||
renderSprite = "smile";
|
||||
img = typeof getRenderableImage === "function"
|
||||
? getRenderableImage("smile", "smile")
|
||||
: (typeof images !== "undefined" ? images.get?.("smile") : null);
|
||||
}
|
||||
}
|
||||
|
||||
const worldW = Math.max(1, Number(this.world?.w || 1000) || 1000);
|
||||
const worldH = Math.max(1, Number(this.world?.h || 720) || 720);
|
||||
const pad = Math.max(28, CONFIG.worldPadding || 30);
|
||||
const lastX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : NaN);
|
||||
const lastY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : NaN);
|
||||
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = Number.isFinite(lastX) ? lastX : (Number.isFinite(this.x) ? this.x : pad);
|
||||
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = Number.isFinite(lastY) ? lastY : (Number.isFinite(this.y) ? this.y : pad);
|
||||
let drawX = this.sunbathAnchorX;
|
||||
let drawY = this.sunbathAnchorY;
|
||||
if (!Number.isFinite(drawX)) drawX = Number.isFinite(lastX) ? lastX : pad;
|
||||
if (!Number.isFinite(drawY)) drawY = Number.isFinite(lastY) ? lastY : pad;
|
||||
drawX = clamp(drawX, pad, Math.max(pad, worldW - pad));
|
||||
drawY = clamp(drawY, pad, Math.max(pad, worldH - pad));
|
||||
this.sunbathAnchorX = drawX;
|
||||
this.sunbathAnchorY = drawY;
|
||||
this._lastSunbathDrawX = drawX;
|
||||
this._lastSunbathDrawY = drawY;
|
||||
this._lastValidX = drawX;
|
||||
this._lastValidY = drawY;
|
||||
if (!Number.isFinite(this.x) || Math.abs(this.x - drawX) > 1.5) this.x = drawX;
|
||||
if (!Number.isFinite(this.y) || Math.abs(this.y - drawY) > 1.5) this.y = drawY;
|
||||
|
||||
const metrics = getImageMetrics(renderSprite) || getImageMetrics(img) || getImageMetrics("smile") || {};
|
||||
const w = metrics?.w || img?.naturalWidth || img?.width || 512;
|
||||
const h = metrics?.h || img?.naturalHeight || img?.height || 512;
|
||||
const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale;
|
||||
const drawW = w * visualScale * 0.30;
|
||||
const drawH = h * visualScale * 0.30;
|
||||
const breathing = Math.sin(t * 1.25 + this.age * 0.18);
|
||||
const bob = breathing * 0.55;
|
||||
const faceRight = this.facingDir ? this.facingDir() > 0 : (this.facing || 1) > 0;
|
||||
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1;
|
||||
if (nestHideAlpha <= 0.01) return true;
|
||||
|
||||
const lightState = lighting || getLightingState(this.world);
|
||||
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 });
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = nestHideAlpha;
|
||||
ctx.translate(drawX, drawY + bob);
|
||||
ctx.rotate((faceRight ? 1 : -1) * Math.PI / 28);
|
||||
if (faceRight) ctx.scale(-1, 1);
|
||||
if (img) {
|
||||
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
|
||||
} else {
|
||||
ctx.fillStyle = "#f4ead6";
|
||||
ctx.strokeStyle = "rgba(92,71,49,0.48)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, drawW * 0.42, drawH * 0.36, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (showDecor) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.18 * nestHideAlpha;
|
||||
ctx.fillStyle = lightState.goldenStrength > 0.25 ? "rgba(255, 223, 137, 0.95)" : "rgba(255, 244, 172, 0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX - drawW * 0.28, drawY - drawH * 0.35, Math.max(3, drawW * 0.06), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
draw(ctx, t, lighting = null) {
|
||||
if (this.dead) return;
|
||||
const sunbathActive = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
|
||||
if (!sunbathActive && Number.isFinite(this.x) && Number.isFinite(this.y)) {
|
||||
this._lastValidX = this.x;
|
||||
this._lastValidY = this.y;
|
||||
}
|
||||
const lightState = lighting || getLightingState(this.world);
|
||||
const sprite = this.spriteId();
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, "smile") : (images.get(sprite) || images.get(this.type));
|
||||
const metrics = getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile");
|
||||
if (sunbathActive && this.drawSunbathPose?.(ctx, t, lightState)) return;
|
||||
if (!sunbathActive && String(this.visibleSpriteId || "").startsWith("sunbath")) {
|
||||
this.visibleSpriteId = "";
|
||||
this.spriteLockUntil = 0;
|
||||
}
|
||||
const showDecor = shouldShowTarinaiDecor(this);
|
||||
if (sunbathActive) this.preloadSunbathSprites?.();
|
||||
let sprite = this.spriteId();
|
||||
if (!sunbathActive && String(sprite || "").startsWith("sunbath")) {
|
||||
this.visibleSpriteId = "";
|
||||
this.spriteLockUntil = 0;
|
||||
sprite = this.rawSpriteId ? this.rawSpriteId() : (this.goodMode || this.type || "smile");
|
||||
}
|
||||
if (!String(sprite || "").startsWith("sunbath")) this._lastNonSunbathSpriteId = sprite;
|
||||
const sunbathSprite = String(sprite || "").startsWith("sunbath");
|
||||
const sunbathCandidates = sunbathSprite && this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : [];
|
||||
if (sunbathSprite) {
|
||||
for (const id of sunbathCandidates) {
|
||||
if (typeof ensureImage === "function") ensureImage(id);
|
||||
else if (globalThis.TarinaiAssets?.ensureImage) globalThis.TarinaiAssets.ensureImage(id);
|
||||
}
|
||||
}
|
||||
const spriteFallback = "smile";
|
||||
let renderSprite = sprite;
|
||||
let img = typeof getRenderableImage === "function" ? getRenderableImage(sprite, spriteFallback) : (images.get(sprite) || images.get(spriteFallback) || images.get(this.type));
|
||||
if (sunbathSprite) {
|
||||
const ready = (id) => typeof isImageReady === "function" ? isImageReady(id) : !!(images?.get?.(id)?.complete || images?.get?.(id)?.naturalWidth || images?.get?.(id)?.width);
|
||||
const imageFor = (id) => (typeof images !== "undefined" ? images.get?.(id) : globalThis.TarinaiAssets?.images?.get?.(id)) || null;
|
||||
if (ready(sprite)) {
|
||||
this._lastRenderableSunbathSpriteId = sprite;
|
||||
} else {
|
||||
const orderedFallbacks = [this._lastRenderableSunbathSpriteId, ...sunbathCandidates].filter(Boolean);
|
||||
const fallbackSunbath = orderedFallbacks.find(id => ready(id));
|
||||
if (fallbackSunbath) {
|
||||
renderSprite = fallbackSunbath;
|
||||
img = imageFor(fallbackSunbath);
|
||||
this._lastRenderableSunbathSpriteId = fallbackSunbath;
|
||||
this.sunbathSpriteVariant = fallbackSunbath;
|
||||
}
|
||||
}
|
||||
if (!img && this._lastRenderableSunbathSpriteId && ready(this._lastRenderableSunbathSpriteId)) {
|
||||
renderSprite = this._lastRenderableSunbathSpriteId;
|
||||
img = imageFor(this._lastRenderableSunbathSpriteId);
|
||||
}
|
||||
if (!img) img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : (images.get("smile") || images.get(this.type));
|
||||
}
|
||||
const metrics = getImageMetrics(renderSprite) || getImageMetrics(sprite) || getImageMetrics(this.type) || getImageMetrics("smile");
|
||||
const w = metrics?.w || 512;
|
||||
const h = metrics?.h || 512;
|
||||
const moveSpeed = Math.hypot(this.vx, this.vy);
|
||||
|
|
@ -63,18 +244,18 @@
|
|||
|
||||
const shadow = projectedShadowParams(lightState, 0.95 + visualScale * 0.8);
|
||||
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
|
||||
if (img) {
|
||||
if (showDecor && img) {
|
||||
drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
|
||||
faceRight,
|
||||
alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92) * nestHideAlpha,
|
||||
widthScale: this.state === "sleep" ? 1.12 : 1,
|
||||
heightScale: this.state === "sleep" ? 0.88 : 1,
|
||||
});
|
||||
} else {
|
||||
} else if (showDecor) {
|
||||
drawProjectedShadow(ctx, this.x, shadowContactY, drawW * 0.52, drawH * 0.13, { ...shadow, alpha: shadow.alpha * 0.82 * nestHideAlpha });
|
||||
}
|
||||
drawCreatureGlow(ctx, this, drawW, drawH, lightState);
|
||||
if (this.powerItemMode === "protein" || this.powerItemMode === "niteropu") {
|
||||
if (showDecor) drawCreatureGlow(ctx, this, drawW, drawH, lightState);
|
||||
if (showDecor && (this.powerItemMode === "protein" || this.powerItemMode === "niteropu")) {
|
||||
ctx.save();
|
||||
const up = this.powerItemMode === "protein";
|
||||
const baseColor = up ? "rgba(224,54,40,0.92)" : "rgba(116,80,190,0.88)";
|
||||
|
|
@ -106,7 +287,7 @@
|
|||
ctx.restore();
|
||||
}
|
||||
if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) {
|
||||
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
|
||||
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
|
||||
const hx = partner ? (this.x + partner.x) / 2 : this.x;
|
||||
const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60;
|
||||
const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10;
|
||||
|
|
@ -194,7 +375,7 @@
|
|||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(focusLabel, 0, ly);
|
||||
ctx.restore();
|
||||
} else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
|
||||
} else if (showDecor && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)";
|
||||
|
|
@ -231,7 +412,7 @@
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
|
||||
if (showDecor && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
|
||||
ctx.save();
|
||||
const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0;
|
||||
ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
|
||||
|
|
@ -262,7 +443,19 @@
|
|||
const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8);
|
||||
const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8);
|
||||
if (img) {
|
||||
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img;
|
||||
let spriteCacheKey = renderSprite || sprite;
|
||||
try {
|
||||
if (typeof images !== "undefined" && images.get?.(renderSprite || sprite) !== img) {
|
||||
let fallbackId = "fallback";
|
||||
for (const [imageId, imageObj] of images) {
|
||||
if (imageObj === img) { fallbackId = imageId; break; }
|
||||
}
|
||||
spriteCacheKey = `${renderSprite || sprite}:fallback:${fallbackId}`;
|
||||
}
|
||||
} catch (err) {
|
||||
spriteCacheKey = renderSprite || sprite;
|
||||
}
|
||||
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(spriteCacheKey, img, cacheW, cacheH) : img;
|
||||
ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh);
|
||||
} else {
|
||||
ctx.save();
|
||||
|
|
@ -276,7 +469,7 @@
|
|||
ctx.restore();
|
||||
}
|
||||
if (img && (this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") {
|
||||
const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5);
|
||||
const stainCanvas = getCachedStainOverlayCanvas(renderSprite || sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5);
|
||||
if (stainCanvas) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88);
|
||||
|
|
@ -300,16 +493,6 @@
|
|||
drawDawnRimLight(ctx, dw, dh, lightState);
|
||||
if (faceRight) ctx.scale(-1, 1);
|
||||
|
||||
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.80;
|
||||
ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "rgba(42,36,29,0.72)";
|
||||
const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?");
|
||||
ctx.fillText(mark, 0, -drawH * 0.72);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
|
|
@ -322,8 +505,10 @@
|
|||
ctx.font = `${starSize}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.shadowColor = "rgba(255, 198, 28, 0.82)";
|
||||
ctx.shadowBlur = 14;
|
||||
if (showDecor) {
|
||||
ctx.shadowColor = "rgba(255, 198, 28, 0.82)";
|
||||
ctx.shadowBlur = 14;
|
||||
}
|
||||
ctx.lineWidth = Math.max(2, starSize * 0.12);
|
||||
ctx.strokeStyle = "rgba(92, 62, 0, 0.86)";
|
||||
ctx.fillStyle = "rgba(255, 218, 46, 1)";
|
||||
|
|
@ -334,7 +519,7 @@
|
|||
|
||||
const hpActive = (this.hpBarTimer || 0) > 0;
|
||||
const stressActive = (this.stressBarTimer || 0) > 0;
|
||||
if (hpActive || stressActive) {
|
||||
if ((hpActive || stressActive) && (showDecor || this.world.selected === this || this.hurtTimer > 0.04)) {
|
||||
const bw = Math.max(28, this.radius * 2.35);
|
||||
const bh = 5;
|
||||
const x = this.x - bw / 2;
|
||||
|
|
|
|||
121
js/tarinai_social_action_runtime.js
Normal file
121
js/tarinai_social_action_runtime.js
Normal 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;
|
||||
}
|
||||
|
|
@ -6,8 +6,12 @@
|
|||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
interactWithOthers(dt) {
|
||||
let closeCount = 0;
|
||||
const scanLimit = 8;
|
||||
let scanned = 0;
|
||||
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
|
||||
if (o === this || o.dead) continue;
|
||||
if (scanned >= scanLimit && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04) break;
|
||||
scanned += 1;
|
||||
const dx = o.x - this.x, dy = o.y - this.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < 0.01 || d > 74) continue;
|
||||
|
|
@ -22,8 +26,8 @@
|
|||
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 36, safety: dt * 5 }, o);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * 30, safety: dt * 5 }, this);
|
||||
if (typeof queueForcedTarinaiBehavior === "function") {
|
||||
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${o.name || "相手"}と喧嘩しようとしている` });
|
||||
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${this.name || "相手"}と喧嘩しようとしている` });
|
||||
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,14 +140,14 @@
|
|||
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
|
||||
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
|
||||
const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false;
|
||||
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) {
|
||||
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) {
|
||||
this.conflictTargetId = o.id;
|
||||
o.conflictTargetId = this.id;
|
||||
const urge = 48 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 12 : 0);
|
||||
const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0);
|
||||
this.conflictUrge = Math.max(this.conflictUrge || 0, urge);
|
||||
o.conflictUrge = Math.max(o.conflictUrge || 0, urge * 0.82);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * 0.32, safety: dt * (battleDrug ? 4 : 1.6) }, o);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * 0.24, safety: dt * (battleDrug ? 4 : 1.6) }, this);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * (battleDrug ? 0.32 : 0.18), safety: dt * (battleDrug ? 4 : 0.9) }, o);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * (battleDrug ? 0.24 : 0.14), safety: dt * (battleDrug ? 4 : 0.9) }, this);
|
||||
}
|
||||
|
||||
const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave;
|
||||
|
|
@ -156,10 +160,12 @@
|
|||
applyNeedShock(o, { social: dt * (loveDrug ? 34 : 14) }, this);
|
||||
}
|
||||
if (loveDrug && typeof queueForcedTarinaiBehavior === "function") {
|
||||
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${o.name || "相手"}と繁殖したくて近づいている` });
|
||||
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${this.name || "相手"}と繁殖したくて近づいている` });
|
||||
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
|
||||
}
|
||||
const mateIntent = this.activeBehavior?.id === "approach_mate" || o.activeBehavior?.id === "approach_mate" || this.intent?.actionId === "approach_mate" || o.intent?.actionId === "approach_mate";
|
||||
const 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);
|
||||
}
|
||||
|
|
@ -186,10 +192,23 @@
|
|||
|
||||
if (this.state === "sunbath") {
|
||||
this.target = null;
|
||||
this.vx *= Math.pow(0.90, dt * 60);
|
||||
this.vy *= Math.pow(0.90, dt * 60);
|
||||
this.x = clamp(this.x + Math.sin((this.world?.time || 0) * 1.3 + this.seed) * 0.03, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
|
||||
this.y = clamp(this.y + Math.cos((this.world?.time || 0) * 1.1 + this.seed) * 0.02, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
const pad = Math.max(28, CONFIG.worldPadding || 30);
|
||||
const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000);
|
||||
const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720);
|
||||
const fallbackX = Number.isFinite(this._lastSunbathDrawX) ? this._lastSunbathDrawX : (Number.isFinite(this._lastValidX) ? this._lastValidX : (Number.isFinite(this.x) ? this.x : pad));
|
||||
const fallbackY = Number.isFinite(this._lastSunbathDrawY) ? this._lastSunbathDrawY : (Number.isFinite(this._lastValidY) ? this._lastValidY : (Number.isFinite(this.y) ? this.y : pad));
|
||||
if (!Number.isFinite(this.sunbathAnchorX)) this.sunbathAnchorX = fallbackX;
|
||||
if (!Number.isFinite(this.sunbathAnchorY)) this.sunbathAnchorY = fallbackY;
|
||||
this.sunbathAnchorX = clamp(this.sunbathAnchorX, pad, safeW - pad);
|
||||
this.sunbathAnchorY = clamp(this.sunbathAnchorY, pad, safeH - pad);
|
||||
this.x = this.sunbathAnchorX;
|
||||
this.y = this.sunbathAnchorY;
|
||||
this._lastSunbathDrawX = this.x;
|
||||
this._lastSunbathDrawY = this.y;
|
||||
this._lastValidX = this.x;
|
||||
this._lastValidY = this.y;
|
||||
return;
|
||||
}
|
||||
if (this.state === "sleep") {
|
||||
|
|
@ -197,7 +216,7 @@
|
|||
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0;
|
||||
const crowdedSleep = bedCrowd > 5;
|
||||
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
|
||||
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort });
|
||||
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
|
||||
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
|
||||
if (this.target && !this.target.dead && this.target.type === "nest_box") {
|
||||
this.updateNestBoxPresence(dt);
|
||||
|
|
@ -226,7 +245,7 @@
|
|||
}
|
||||
|
||||
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") {
|
||||
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
|
||||
const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
|
||||
if (partner) {
|
||||
const midX = (this.x + partner.x) / 2;
|
||||
const midY = (this.y + partner.y) / 2;
|
||||
|
|
@ -276,9 +295,9 @@
|
|||
const d = Math.hypot(dx, dy) || 1;
|
||||
if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) {
|
||||
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander");
|
||||
this.currentAction = null;
|
||||
this.intentLockTimer = 0;
|
||||
this.goIdle?.("少し歩いた");
|
||||
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
|
||||
this.behaviorLockTimer = 0;
|
||||
this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f");
|
||||
return;
|
||||
}
|
||||
if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) {
|
||||
|
|
@ -289,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -406,11 +427,15 @@
|
|||
this.dead = true;
|
||||
this.deathReason = finalReason;
|
||||
this.world.deadCount += 1;
|
||||
this.world.items.push(new Item("trace", this.x, this.y));
|
||||
this.world.items.push(new Item("splat", this.x, this.y));
|
||||
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
|
||||
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
|
||||
if (Math.random() < 0.55) {
|
||||
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
|
||||
if (spot) this.world.items.push(new Item("grass", spot.x, spot.y));
|
||||
if (spot) {
|
||||
const deathGrass = new Item("grass", spot.x, spot.y);
|
||||
if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass");
|
||||
else this.world.items.push(deathGrass);
|
||||
}
|
||||
}
|
||||
this.world.markDead(this, finalReason);
|
||||
this.releasedLiveToken = this.liveToken || 0;
|
||||
|
|
|
|||
|
|
@ -53,36 +53,68 @@ const TEXT_CATALOG = {
|
|||
|
||||
|
||||
TEXT_CATALOG.stateLabels = Object.freeze({
|
||||
idle: "待機",
|
||||
seek_food: "食べ物へ向かう",
|
||||
seek_bed: "寝床へ向かう",
|
||||
play_ball: "ボール遊び",
|
||||
seek_water: "水を探す",
|
||||
zunchi_sick: "ずんち病",
|
||||
fight_sick: "きずつき病",
|
||||
system: "ずんち病",
|
||||
seek_friend: "仲間を探す",
|
||||
follow_parent: "親についていく",
|
||||
cursor_friend: "カーソルについていく",
|
||||
cursor_enemy: "カーソルを避ける",
|
||||
fight: "喧嘩中",
|
||||
sleep: "睡眠中",
|
||||
eat: "食事中",
|
||||
panic: "パニック",
|
||||
intimidate: "威嚇中",
|
||||
birth_ritual: "誕生前",
|
||||
sunbath: "日光浴中",
|
||||
ant_attack: "アリ攻撃中",
|
||||
ant_intimidate: "アリ威嚇中",
|
||||
frozen: "冷凍中",
|
||||
idle: "\u5f85\u6a5f",
|
||||
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
|
||||
seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
|
||||
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
|
||||
seek_water: "\u6c34\u3092\u63a2\u3059",
|
||||
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",
|
||||
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
|
||||
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
|
||||
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
|
||||
cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
|
||||
fight: "\u55a7\u5629\u4e2d",
|
||||
sleep: "\u7761\u7720\u4e2d",
|
||||
eat: "\u98df\u4e8b\u4e2d",
|
||||
panic: "\u30d1\u30cb\u30c3\u30af",
|
||||
intimidate: "\u5a01\u5687\u4e2d",
|
||||
birth_ritual: "\u8a95\u751f\u524d",
|
||||
sunbath: "\u65e5\u5149\u6d74\u4e2d",
|
||||
ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d",
|
||||
ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d",
|
||||
frozen: "\u51b7\u51cd\u4e2d",
|
||||
});
|
||||
|
||||
TEXT_CATALOG.targetLabelOverrides = Object.freeze({
|
||||
system: "ずんち病",
|
||||
zunchi_sick: "ずんち病",
|
||||
fight_sick: "きずつき病",
|
||||
|
||||
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) {
|
||||
|
|
@ -90,53 +122,57 @@ TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) {
|
|||
};
|
||||
|
||||
TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
|
||||
if (!target) return "なし";
|
||||
if (!target) return "\u306a\u3057";
|
||||
if (target.name) return target.name;
|
||||
if (target.kind === "queen") return "女王アリ";
|
||||
if (target.kind === "worker") return "働きアリ";
|
||||
if (target.kind === "queen") return "\u5973\u738b\u30a2\u30ea";
|
||||
if (target.kind === "worker") return "\u50cd\u304d\u30a2\u30ea";
|
||||
if (target.type) {
|
||||
const type = String(target.type || "");
|
||||
if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type];
|
||||
const label = typeof toolLabel === "function" ? toolLabel(type) : "";
|
||||
return label || type;
|
||||
}
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置";
|
||||
return "不明";
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u4f4d\u7f6e";
|
||||
return "\u4e0d\u660e";
|
||||
};
|
||||
|
||||
TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
|
||||
if (!t) return "なし";
|
||||
if ((t.pinchThoughtTimer || 0) > 0) return "つままれている";
|
||||
const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
|
||||
if (behaviorText) return behaviorText;
|
||||
if (!t) return "\u306a\u3057";
|
||||
if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
|
||||
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 !== "なし" ? `${target}を食べている` : "食べている";
|
||||
if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している";
|
||||
if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている";
|
||||
if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している";
|
||||
if (t.state === "ant_intimidate") return "アリを威嚇している";
|
||||
if (t.state === "ant_attack") return "アリを攻撃している";
|
||||
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている";
|
||||
if (t.state === "sunbath") return "晴れた日なたでストレスを下げている";
|
||||
if (t.state === "frozen") return "フィールド外で冷凍保存されている";
|
||||
if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている";
|
||||
if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している";
|
||||
if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している";
|
||||
if (t.state === "play_ball") return "ボールが気になっている";
|
||||
if (t.state === "seek_friend" && t.intent?.actionId === "approach_mate") return t.target?.name ? `${t.target.name}に繁殖のため近づいている` : "繁殖できる相手を探している";
|
||||
if (t.state === "seek_friend") return "仲間を探している";
|
||||
if (t.state === "follow_parent") return "親についていく";
|
||||
if (t.state === "cursor_friend") return "カーソルについていく";
|
||||
if (t.state === "cursor_enemy") return "カーソルを避けている";
|
||||
if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している";
|
||||
if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている";
|
||||
if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い";
|
||||
if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している";
|
||||
if ((t.fearTimer || 0) > 0.08) return "怖がっている";
|
||||
if ((t.grassEatTimer || 0) > 0.04) return "草を食べている";
|
||||
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
|
||||
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "ant_attack") return "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "sunbath") return "\u6674\u308c\u305f\u65e5\u306a\u305f\u3067\u30b9\u30c8\u30ec\u30b9\u3092\u4e0b\u3052\u3066\u3044\u308b";
|
||||
if (t.state === "frozen") return "\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3055\u308c\u3066\u3044\u308b";
|
||||
if (t.state === "seek_food") return target !== "\u306a\u3057" ? `${target}\u3092\u63a2\u3057\u3066\u3044\u308b` : "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
|
||||
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "seek_bed") return target !== "\u306a\u3057" ? `${target}\u3078\u5411\u304b\u3063\u3066\u3044\u308b` : "\u7720\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "play_ball") return "\u30dc\u30fc\u30eb\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "seek_friend" && (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate") return t.target?.name ? `${t.target.name}\u306b\u7e41\u6b96\u306e\u305f\u3081\u8fd1\u3065\u3044\u3066\u3044\u308b` : "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "seek_friend") return "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "follow_parent") return "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
|
||||
if (t.state === "cursor_friend") return "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
|
||||
if (t.state === "cursor_enemy") return "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b";
|
||||
if (t.state === "fight") return target !== "\u306a\u3057" ? `${target}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5\u3067\u8abf\u5b50\u304c\u60aa\u3044";
|
||||
if ((t.hurtTimer || 0) > 0.08) return "\u75db\u307f\u3067\u6df7\u4e71\u3057\u3066\u3044\u308b";
|
||||
if ((t.fearTimer || 0) > 0.08) return "\u6016\u304c\u3063\u3066\u3044\u308b";
|
||||
if ((t.grassEatTimer || 0) > 0.04) return "\u8349\u3092\u98df\u3079\u3066\u3044\u308b";
|
||||
const thought = String(t.thought || "").trim();
|
||||
if (thought) return thought;
|
||||
return "周囲を見ている";
|
||||
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
|
||||
};
|
||||
|
||||
function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); }
|
||||
|
|
|
|||
5
js/ui.js
5
js/ui.js
|
|
@ -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"]),
|
||||
|
|
|
|||
619
js/ui_bind.js
619
js/ui_bind.js
|
|
@ -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();
|
||||
|
|
@ -189,7 +252,7 @@ function bindUI() {
|
|||
document.addEventListener("click", (e) => {
|
||||
if (!isActivePlacementTool()) return;
|
||||
if (clickedInsideGameOrToolUi(e.target)) return;
|
||||
selectTool("observe"); // 道具設置モードを解除
|
||||
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
|
||||
});
|
||||
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); });
|
||||
ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog);
|
||||
|
|
@ -279,552 +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 = "フィールドに戻された";
|
||||
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
|
||||
showToast("フィールド外なので元の位置に戻しました。");
|
||||
uiCache.grabOrigin = null;
|
||||
return true;
|
||||
}
|
||||
uiCache.grabOrigin = null;
|
||||
return false;
|
||||
}
|
||||
const beginTouchGrab = (p, e) => {
|
||||
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
|
||||
if (!target) return false;
|
||||
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") {
|
||||
target.sleeping = false;
|
||||
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
|
||||
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
|
||||
target.thought = "つままれている";
|
||||
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
|
||||
} else {
|
||||
if (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?.("つままれている");
|
||||
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") {
|
||||
target.sleeping = false;
|
||||
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
|
||||
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
|
||||
target.thought = "つままれている";
|
||||
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
|
||||
} else {
|
||||
if (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?.("つままれている");
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -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 || "のんびり");
|
||||
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" }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
57
js/ui_ground.js
Normal 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
81
js/ui_input_mouse.js
Normal 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
378
js/ui_input_shared.js
Normal 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
133
js/ui_input_touch.js
Normal 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);
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
|
||||
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
|
||||
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
|
||||
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;
|
||||
|
|
@ -48,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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -169,20 +158,20 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") {
|
|||
|
||||
function selectedInventoryItems(t) {
|
||||
const worldRef = t?.world || world;
|
||||
const labels = { grass_bed: "かんたんベッド", plushie: "ぬいぐるみ" };
|
||||
const labels = { grass_bed: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", plushie: "\u306c\u3044\u3050\u308b\u307f" };
|
||||
const items = (worldRef?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id);
|
||||
const counts = new Map();
|
||||
for (const it of items) {
|
||||
const label = labels[it.type] || toolLabel(it.type) || it.type;
|
||||
counts.set(label, (counts.get(label) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} ×${count}` : label);
|
||||
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} \u00d7${count}` : label);
|
||||
}
|
||||
|
||||
function selectedInventoryHtml(t) {
|
||||
const entries = selectedInventoryItems(t);
|
||||
if (!entries.length) return `<div class="info-row"><span>所持品</span><strong>なし</strong></div>`;
|
||||
return `<div class="info-row"><span>所持品</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
|
||||
if (!entries.length) return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>\u306a\u3057</strong></div>`;
|
||||
return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
|
||||
}
|
||||
|
||||
function selectedStateText(t) {
|
||||
|
|
@ -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() : {};
|
||||
|
|
@ -216,9 +209,10 @@ function renderSelected() {
|
|||
].join(",");
|
||||
const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : [];
|
||||
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
|
||||
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
|
||||
const snapshot = [
|
||||
t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target),
|
||||
t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", t.activeBehavior?.id || "", t.activeBehavior?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
|
||||
behaviorState?.need || "", behaviorState?.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),
|
||||
|
|
@ -273,7 +267,7 @@ function renderSelected() {
|
|||
const needRowsHtml = needKeys.map(key => {
|
||||
const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key;
|
||||
const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10);
|
||||
const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : "";
|
||||
const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : "";
|
||||
return `<div class="need-cell"${chosen}><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||
}).join("");
|
||||
const healthHtml = [
|
||||
|
|
@ -286,13 +280,11 @@ function renderSelected() {
|
|||
? itemEffectSummary.map(e => `<div class="info-row"><span>${escapeHtml(e.label)}</span><strong>${escapeHtml(e.detail)}</strong></div>`).join("")
|
||||
: `<div class="info-row"><span>\u30a2\u30a4\u30c6\u30e0\u52b9\u679c</span><strong>\u306a\u3057</strong></div>`;
|
||||
const relationHtml = `
|
||||
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
|
||||
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
|
||||
<div class="info-row"><span>\u3051\u3093\u304b\u52dd\u7387</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
|
||||
`;
|
||||
const familyHtml = `
|
||||
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
|
||||
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
|
||||
<div class="info-row relation-row relation-list-row"><span>友達</span><strong>${relationListHtml(t, "friend")}</strong></div>
|
||||
<div class="info-row relation-row relation-list-row"><span>敵対</span><strong>${relationListHtml(t, "enemy")}</strong></div>
|
||||
<div class="info-row"><span>親</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "不明")}</strong></div>
|
||||
<div class="info-row"><span>子</span><strong>${t.children?.length || 0}</strong></div>
|
||||
<div class="info-row"><span>けんか勝率</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
|
||||
`;
|
||||
ui.selectedInfo.innerHTML = `
|
||||
<div class="selected-name-editor">
|
||||
|
|
@ -304,8 +296,7 @@ function renderSelected() {
|
|||
${selectedDataCategoryHtml("health", "\u6b32\u6c42", healthHtml)}
|
||||
${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)}
|
||||
${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)}
|
||||
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)}
|
||||
${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)}
|
||||
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2\u3068\u5bb6\u65cf", relationHtml)}
|
||||
${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))}
|
||||
${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><ol>${recordHtml}</ol></div>`)}
|
||||
</div>
|
||||
|
|
@ -325,19 +316,22 @@ function stateLabel(s) {
|
|||
}
|
||||
|
||||
function reasonLabel(t) {
|
||||
if (!t) return "なし";
|
||||
const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
|
||||
if (!t) return "\u306a\u3057";
|
||||
const behavior = typeof 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;
|
||||
if (t?.intent?.reasonText) return t.intent.reasonText;
|
||||
return live || "なし";
|
||||
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
|
||||
if (behaviorState?.reason || behaviorState?.text) return behaviorState.reason || behaviorState.text;
|
||||
return live || "\u306a\u3057";
|
||||
}
|
||||
|
||||
function targetLabel(target) {
|
||||
return window.TEXT_CATALOG?.targetLabel?.(target) || "なし";
|
||||
return window.TEXT_CATALOG?.targetLabel?.(target) || "\u306a\u3057";
|
||||
}
|
||||
|
||||
function fightWinRateLabel(t) {
|
||||
|
|
@ -355,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 => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c] || c));
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use strict";
|
||||
|
||||
const SCALABLE_TOOLS = new Set(scalableToolIds());
|
||||
const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose"]);
|
||||
|
||||
const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"];
|
||||
|
||||
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" };
|
||||
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
|
||||
|
||||
function renderToolPalette() {
|
||||
if (!ui?.toolPalette || typeof toolCategories !== "function") return;
|
||||
|
|
@ -23,11 +24,17 @@ 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.textContent = def.label || toolId;
|
||||
btn.dataset.toolCategory = category.id || "";
|
||||
const label = document.createElement("span");
|
||||
label.className = "tool-label";
|
||||
label.textContent = def.label || toolId;
|
||||
btn.title = label.textContent;
|
||||
btn.appendChild(label);
|
||||
attachToolSpritePreview(btn, def, toolId);
|
||||
if (toolId === world?.tool) btn.classList.add("selected");
|
||||
body.appendChild(btn);
|
||||
|
|
@ -38,31 +45,47 @@ function renderToolPalette() {
|
|||
ui.toolPalette.appendChild(frag);
|
||||
}
|
||||
|
||||
function drawToolPreviewCanvas(canvas, itemType, { watermark = false } = {}) {
|
||||
if (!canvas || !itemType) return false;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (typeof window.drawToolItemPreview === "function") {
|
||||
return window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true, watermark }) !== false;
|
||||
}
|
||||
if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType) && typeof window.drawConsumableFieldSprite === "function") {
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
const baseScale = itemType === "protein" || itemType === "niteropu" ? 0.9 : 1.0;
|
||||
ctx.scale(baseScale, baseScale);
|
||||
window.drawConsumableFieldSprite(ctx, itemType, 17.5, 112.4, { compact: true });
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createToolPreviewCanvas(className, size = 96) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = className;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function attachToolSpritePreview(btn, def, toolId) {
|
||||
const itemType = def?.itemType || toolId;
|
||||
if (!btn || !itemType || !def?.placeable) return;
|
||||
const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite;
|
||||
if (typeof drawPreview !== "function") return;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "tool-sprite-preview";
|
||||
canvas.width = 72;
|
||||
canvas.height = 72;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
let drawn = false;
|
||||
if (typeof window.drawToolItemPreview === "function") {
|
||||
drawn = window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true }) !== false;
|
||||
} else if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType)) {
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
const scale = itemType === "protein" || itemType === "niteropu" ? 0.82 : 0.92;
|
||||
ctx.scale(scale, scale);
|
||||
window.drawConsumableFieldSprite(ctx, itemType, 13.5, 112.4, { compact: true });
|
||||
drawn = true;
|
||||
}
|
||||
if (!drawn) return;
|
||||
if (toolId === "ball" || itemType === "ball") return;
|
||||
if (typeof window.drawToolItemPreview !== "function" && typeof window.drawConsumableFieldSprite !== "function") return;
|
||||
|
||||
const iconCanvas = createToolPreviewCanvas("tool-sprite-preview", 160);
|
||||
const iconDrawn = drawToolPreviewCanvas(iconCanvas, itemType);
|
||||
if (!iconDrawn) return;
|
||||
|
||||
btn.classList.add("tool-has-canvas-icon");
|
||||
btn.prepend(canvas);
|
||||
btn.prepend(iconCanvas);
|
||||
}
|
||||
|
||||
function toolSizeFor(tool) {
|
||||
|
|
|
|||
70
js/ui_tooltips.js
Normal file
70
js/ui_tooltips.js
Normal 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);
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
(function () {
|
||||
const APP_VERSION = "15.24.14";
|
||||
const APP_BUILD = "forced-behavior-sleep-rhythm";
|
||||
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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
shouldDropRainWater(worldRef, dt = 0) {
|
||||
return Boolean(
|
||||
worldRef?.weather === "light_rain" &&
|
||||
(worldRef.itemCounts?.water || 0) < 24 &&
|
||||
Math.random() < Math.max(0, Number(dt) || 0) * 0.12
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "のんびり", description: "何も無し", effects: { personality: {} } };
|
||||
this.colonyMood = { id: "relaxed", label: "安定", description: "標準状態", effects: { personality: {} } };
|
||||
this.lastColonyMoodDay = 0;
|
||||
this.foodSpoilagePenalty = 0;
|
||||
this.foodSpoilageEvents = 0;
|
||||
|
|
|
|||
|
|
@ -6,35 +6,40 @@
|
|||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
updateAnts(dt) {
|
||||
if (!this.ants) this.ants = [];
|
||||
const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
|
||||
for (const nest of nests) {
|
||||
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
|
||||
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt);
|
||||
const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
|
||||
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
|
||||
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
|
||||
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
|
||||
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
|
||||
const a = new AntActor(this, {
|
||||
kind: "worker",
|
||||
homeId: nest.id,
|
||||
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
|
||||
y: nest.y - nest.r * 1.00 + rand(-3, 3),
|
||||
hp,
|
||||
maxHp: ANT_WORKER_HP || 32,
|
||||
});
|
||||
this.ants.push(a);
|
||||
this.emit?.("ant:spawned", { ant: a, nest });
|
||||
this.antNestUpdateAccum = (this.antNestUpdateAccum || 0) + dt;
|
||||
if (this.antNestUpdateAccum >= 3.0) {
|
||||
const nestDt = this.antNestUpdateAccum;
|
||||
this.antNestUpdateAccum = 0;
|
||||
const nests = this.itemsOfType?.("ant_nest") || (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
|
||||
for (const nest of nests) {
|
||||
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
|
||||
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45;
|
||||
this.drawListDirty = true;
|
||||
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - nestDt);
|
||||
let outside = 0;
|
||||
for (const a of this.ants) if (a && !a.dead && a.kind === "worker" && a.homeId === nest.id) outside += 1;
|
||||
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
|
||||
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
|
||||
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
|
||||
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
|
||||
const a = new AntActor(this, {
|
||||
kind: "worker",
|
||||
homeId: nest.id,
|
||||
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
|
||||
y: nest.y - nest.r * 1.00 + rand(-3, 3),
|
||||
hp,
|
||||
maxHp: ANT_WORKER_HP || 32,
|
||||
});
|
||||
this.ants.push(a);
|
||||
this.emit?.("ant:spawned", { ant: a, nest });
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = rand(2.5, 5.5) + outside * 0.65;
|
||||
this.drawListDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const aliveAntCount = (this.ants || []).reduce((n, a) => n + (a && !a.dead ? 1 : 0), 0);
|
||||
const q = this.performanceQuality ? this.performanceQuality() : null;
|
||||
const stride = Math.max(1, Math.min(3, q?.antStride || (aliveAntCount >= 70 ? 3 : aliveAntCount >= 28 ? 2 : 1)));
|
||||
const stride = aliveAntCount >= 28 ? 3 : 2;
|
||||
this.antUpdatePhase = ((this.antUpdatePhase || 0) + 1) % stride;
|
||||
for (let i = 0; i < this.ants.length; i++) {
|
||||
const a = this.ants[i];
|
||||
|
|
@ -110,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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,8 +79,8 @@
|
|||
if (t.addStress) t.addStress(22 * p, { threshold: 8 });
|
||||
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
|
||||
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
|
||||
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
|
||||
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); }
|
||||
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
|
||||
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); }
|
||||
let nx = dx / d;
|
||||
let ny = dy / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
panic: true,
|
||||
target: { x, y },
|
||||
fearTimer: 1.7 + p,
|
||||
thought: "爆竹の爆風でパニックになっている",
|
||||
thought: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
});
|
||||
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
|
|
@ -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;
|
||||
|
|
@ -181,12 +181,12 @@
|
|||
panic: true,
|
||||
target: { x, y },
|
||||
fearTimer: 1.7 + q,
|
||||
thought: "爆発病の爆発に巻き込まれている",
|
||||
thought: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b",
|
||||
});
|
||||
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8);
|
||||
o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85);
|
||||
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
|
||||
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); }
|
||||
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
|
||||
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); }
|
||||
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
|
||||
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
|
||||
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
|
||||
|
|
@ -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;
|
||||
|
|
@ -279,29 +279,23 @@
|
|||
it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160);
|
||||
it.stage = "fresh";
|
||||
it.burstFromOshibyo = true;
|
||||
this.items.push(it);
|
||||
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
|
||||
this.addItem?.(it, "zunchi-burst") || this.items.push(it);
|
||||
}
|
||||
this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }));
|
||||
this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] });
|
||||
this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] });
|
||||
audio.place?.("zunchi");
|
||||
this.rebuildSpatial?.(true);
|
||||
this.ensureSpatial?.("zunchi-burst");
|
||||
},
|
||||
|
||||
spawnZunchi(x, y) {
|
||||
const zunchiLimit = CONFIG.zunchiLimit ?? 56;
|
||||
if ((this.itemCounts.zunchi || 0) >= zunchiLimit) {
|
||||
let oldest = null;
|
||||
for (const it of this.items) {
|
||||
if (it.type !== "zunchi" || it.dead) continue;
|
||||
if (!oldest || (it.age || 0) > (oldest.age || 0)) oldest = it;
|
||||
}
|
||||
if (oldest) oldest.amount = 0;
|
||||
}
|
||||
spawnZunchi(x, y, source = null) {
|
||||
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
|
||||
it.amount = 260;
|
||||
this.items.push(it);
|
||||
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
|
||||
it.stage = "fresh";
|
||||
it.producedById = source?.id || "";
|
||||
it.producedAt = this.time || 0;
|
||||
it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0);
|
||||
it.eatProtectedUntil = (this.time || 0) + 90;
|
||||
this.addItem?.(it, "zunchi-spawn") || this.items.push(it);
|
||||
audio.place?.("zunchi");
|
||||
},
|
||||
|
||||
|
|
@ -392,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;
|
||||
|
|
@ -439,32 +406,11 @@
|
|||
entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy;
|
||||
}
|
||||
if (options.panic && entity instanceof Tarinai) {
|
||||
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
|
||||
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
|
||||
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
|
||||
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
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";
|
||||
|
|
@ -492,8 +438,8 @@
|
|||
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42));
|
||||
attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42));
|
||||
this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
|
||||
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" });
|
||||
else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
|
||||
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" });
|
||||
else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
|
||||
const dx = victim.x - attacker.x;
|
||||
const dy = victim.y - attacker.y;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
|
|
@ -579,9 +525,9 @@
|
|||
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
|
||||
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
|
||||
t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu.");
|
||||
t.thought = "げんこつがこわい";
|
||||
t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044";
|
||||
} else {
|
||||
t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している";
|
||||
t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b";
|
||||
}
|
||||
t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
|
||||
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26));
|
||||
|
|
@ -613,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();
|
||||
|
|
@ -641,7 +576,7 @@
|
|||
return dist(a, ball) + ap - (dist(b, ball) + bp);
|
||||
});
|
||||
for (const t of arr.slice(5)) {
|
||||
t.goIdle("ボールが混み合っているので眺めている");
|
||||
t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b");
|
||||
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
|
||||
}
|
||||
}
|
||||
|
|
@ -682,8 +617,9 @@
|
|||
if ((this.itemCounts?.ball || 0) < 2) return;
|
||||
const now = this.time || 0;
|
||||
if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map();
|
||||
const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball");
|
||||
const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball");
|
||||
for (const a of balls) {
|
||||
if (!a || a.dead || a.type !== "ball") continue;
|
||||
const ar = a.r || 18;
|
||||
const avx = a.vx || 0;
|
||||
const avy = a.vy || 0;
|
||||
|
|
@ -769,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;
|
||||
|
|
@ -816,14 +752,14 @@
|
|||
panic: true,
|
||||
target: { x: ball.x, y: ball.y },
|
||||
fearTimer: 0.95,
|
||||
thought: "高速のボールにぶつかってパニックになっている",
|
||||
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
|
||||
});
|
||||
this.applyImpactDamage(t, damage, "\u885d\u7a81");
|
||||
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
|
||||
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
|
||||
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
|
||||
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true });
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
|
||||
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
|
||||
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
|
||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
||||
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
|
||||
|
|
@ -929,7 +865,7 @@
|
|||
panic: true,
|
||||
target: { x: impactor.x, y: impactor.y },
|
||||
fearTimer: 0.55,
|
||||
thought: "高速でぶつかって混乱している",
|
||||
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
|
||||
});
|
||||
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
|
||||
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
|
||||
|
|
@ -937,8 +873,8 @@
|
|||
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
|
||||
for (const t of [target, impactor]) {
|
||||
if (!t || t.dead) continue;
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
|
||||
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true });
|
||||
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
|
||||
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
|
|
|
|||
|
|
@ -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
75
js/world_event_effects.js
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -39,6 +46,7 @@
|
|||
this.familyVersion = 0;
|
||||
this.relationNotices = {};
|
||||
this.resolvedFightIds = {};
|
||||
this.fightPairCooldowns = {};
|
||||
this.foodSpoilagePenalty = 0;
|
||||
this.foodSpoilageEvents = 0;
|
||||
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
|
||||
|
|
@ -50,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;
|
||||
|
|
@ -62,8 +71,15 @@
|
|||
generation: 1,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
|
||||
this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
|
||||
for (let i = 0; i < grassCount; i++) {
|
||||
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");
|
||||
|
|
@ -73,7 +89,7 @@
|
|||
},
|
||||
|
||||
tarinaiFamilyKey(t) {
|
||||
return t?.familyKey || t?.archiveKey || t?.id || "";
|
||||
return t?.familyKey || t?.id || "";
|
||||
},
|
||||
|
||||
assignTarinaiLiveId(t) {
|
||||
|
|
@ -342,7 +358,7 @@
|
|||
if (!other || other === dead || other.dead) continue;
|
||||
if (other.target === dead || other.target?.id === deadId) {
|
||||
other.target = null;
|
||||
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった");
|
||||
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("\u53c2\u7167\u3057\u3066\u3044\u305f\u76f8\u624b\u304c\u3044\u306a\u304f\u306a\u3063\u305f");
|
||||
changed = true;
|
||||
}
|
||||
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
|
||||
|
|
@ -372,7 +388,6 @@
|
|||
this.markFamilyTreeDirty("death");
|
||||
}
|
||||
}
|
||||
this.notifyDeathToRelations?.(t, finalReason);
|
||||
this.familyPrunePending = true;
|
||||
},
|
||||
|
||||
|
|
@ -405,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;
|
||||
|
|
@ -428,11 +501,35 @@
|
|||
b.birthRitualRole = 1;
|
||||
a.birthRitualLeader = true;
|
||||
b.birthRitualLeader = false;
|
||||
a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" });
|
||||
b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" });
|
||||
if (typeof setLiveActionText === "function") {
|
||||
setLiveActionText(a, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${b.name || "相手"}と繁殖の前ぶれをしている`, target: b });
|
||||
setLiveActionText(b, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${a.name || "相手"}と繁殖の前ぶれをしている`, target: a });
|
||||
const 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`;
|
||||
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);
|
||||
|
|
@ -447,8 +544,8 @@
|
|||
a.birthRitualTimer = b.birthRitualTimer = 0;
|
||||
a.birthPartnerId = b.birthPartnerId = null;
|
||||
a.birthRitualLeader = b.birthRitualLeader = false;
|
||||
a.goIdle("誕生の前ぶれが終わった");
|
||||
b.goIdle("誕生の前ぶれが終わった");
|
||||
a.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
|
||||
b.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
|
||||
if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) {
|
||||
a.birthRitualRole = b.birthRitualRole = 0;
|
||||
return null;
|
||||
|
|
@ -573,17 +670,44 @@
|
|||
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;
|
||||
},
|
||||
|
||||
fightPairKey(a, b) {
|
||||
const aid = a?.id || a?.familyKey || "";
|
||||
const bid = b?.id || b?.familyKey || "";
|
||||
if (!aid || !bid) return "";
|
||||
return String(aid) < String(bid) ? `${aid}:${bid}` : `${bid}:${aid}`;
|
||||
},
|
||||
|
||||
fightPairCooldownRemaining(a, b) {
|
||||
const key = this.fightPairKey?.(a, b);
|
||||
if (!key) return 0;
|
||||
const until = Number(this.fightPairCooldowns?.[key] || 0) || 0;
|
||||
return Math.max(0, until - (this.time || 0));
|
||||
},
|
||||
|
||||
markFightPairCooldown(a, b, seconds = 3.2) {
|
||||
const key = this.fightPairKey?.(a, b);
|
||||
if (!key) return;
|
||||
this.fightPairCooldowns = this.fightPairCooldowns || {};
|
||||
this.fightPairCooldowns[key] = Math.max(Number(this.fightPairCooldowns[key] || 0) || 0, (this.time || 0) + Math.max(0.2, Number(seconds) || 3.2));
|
||||
},
|
||||
|
||||
isAlreadyFightingPair(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id) && ((b.fightTargetIds || []).includes(a.id) || b.fightTargetId === a.id);
|
||||
},
|
||||
|
||||
startForcedFight(a, b) {
|
||||
if (!a || !b || a === b || a.dead || b.dead) return false;
|
||||
if (!this.canFightPair(a, b)) return false;
|
||||
const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id);
|
||||
const alreadyFighting = this.isAlreadyFightingPair?.(a, b);
|
||||
if (alreadyFighting) return true;
|
||||
if ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04) return false;
|
||||
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
|
||||
for (const t of [a, b]) {
|
||||
t.birthRitualTimer = 0;
|
||||
t.birthRitualMax = 0;
|
||||
|
|
@ -601,7 +725,7 @@
|
|||
t.goIdle("");
|
||||
}
|
||||
this.startFightMochiIntimidation(a, b);
|
||||
this.startFight(a, b);
|
||||
this.startFight(a, b, { forced: true, initiator: a });
|
||||
return true;
|
||||
},
|
||||
|
||||
|
|
@ -629,33 +753,36 @@
|
|||
},
|
||||
|
||||
startConflict(a, b) {
|
||||
if (!a || !b || a.dead || b.dead) return;
|
||||
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return;
|
||||
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; }
|
||||
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
|
||||
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
|
||||
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
|
||||
if (!a || !b || a.dead || b.dead) return false;
|
||||
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return false;
|
||||
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) return this.startForcedFight(a, b);
|
||||
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return false;
|
||||
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
|
||||
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
|
||||
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false;
|
||||
const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
|
||||
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
|
||||
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
|
||||
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
|
||||
const actor = scoreA >= scoreB ? a : b;
|
||||
const target = actor === a ? b : a;
|
||||
if (!this.canFightPair(actor, target)) return;
|
||||
if (!this.canFightPair(actor, target)) return false;
|
||||
const actorScore = actor === a ? scoreA : scoreB;
|
||||
const targetScore = actor === a ? scoreB : scoreA;
|
||||
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
|
||||
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
|
||||
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
|
||||
if (canIntimidate && Math.random() < intimidationChance) {
|
||||
this.startIntimidation(actor, target, actorScore, targetScore);
|
||||
return;
|
||||
return this.startIntimidation(actor, target, actorScore, targetScore);
|
||||
}
|
||||
this.startFight(a, b);
|
||||
return this.startFight(actor, target, { initiator: actor });
|
||||
},
|
||||
|
||||
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
|
||||
if (!actor || !target || actor.dead || target.dead) return false;
|
||||
if ((actor.fightTimer || 0) > 0.04 || (target.fightTimer || 0) > 0.04) return false;
|
||||
if ((actor.intimidateTimer || 0) > 0.04 || (target.intimidateTimer || 0) > 0.04) return false;
|
||||
if (this.fightPairCooldownRemaining?.(actor, target) > 0.04) return false;
|
||||
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
|
||||
const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8;
|
||||
const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0);
|
||||
|
|
@ -670,17 +797,17 @@
|
|||
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
|
||||
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
|
||||
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
|
||||
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
|
||||
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65));
|
||||
actor.intimidateTargetId = target.id;
|
||||
actor.setActionState("intimidate", { target, reason: "相手を威嚇している" });
|
||||
if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: `${target.name || "相手"}を威嚇している`, target, phase: "perform", source: "behavior" });
|
||||
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
|
||||
if (typeof 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,
|
||||
color: "rgba(145, 106, 55, 0.70)",
|
||||
}));
|
||||
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
|
||||
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
|
||||
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55));
|
||||
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
|
||||
if (Math.random() >= successChance) {
|
||||
actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation");
|
||||
|
|
@ -688,20 +815,20 @@
|
|||
actor.intimidateTimer = 0;
|
||||
actor.intimidateTargetId = null;
|
||||
target.intimidatedTimer = 0;
|
||||
if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した");
|
||||
if (target.state === "panic") target.goIdle("威嚇から戻った");
|
||||
if (actor.state === "intimidate") actor.goIdle("\u5a01\u5687\u306b\u5931\u6557\u3057\u305f");
|
||||
if (target.state === "panic") target.goIdle("\u5a01\u5687\u304b\u3089\u623b\u3063\u305f");
|
||||
actor.spriteLockUntil = 0;
|
||||
target.spriteLockUntil = 0;
|
||||
this.startFight(actor, target);
|
||||
this.startFight(actor, target, { initiator: actor });
|
||||
return true;
|
||||
}
|
||||
actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation");
|
||||
target.defeatedById = actor.id;
|
||||
target.fightWinnerId = actor.id;
|
||||
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
|
||||
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
|
||||
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" });
|
||||
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); }
|
||||
target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8));
|
||||
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0));
|
||||
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" });
|
||||
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); }
|
||||
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
|
||||
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
|
||||
target.vy += rand(-18, 18);
|
||||
|
|
@ -711,21 +838,41 @@
|
|||
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] });
|
||||
actor.lastLog = target.lastLog = this.time;
|
||||
}
|
||||
this.markFightPairCooldown?.(actor, target, 2.8);
|
||||
return true;
|
||||
},
|
||||
|
||||
startFight(a, b) {
|
||||
if (!a || !b || a.dead || b.dead) return;
|
||||
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return;
|
||||
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
|
||||
a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` });
|
||||
b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` });
|
||||
if (typeof setLiveActionText === "function") {
|
||||
setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${b.name || "相手"}と喧嘩している`, target: b, phase: "perform", source: "behavior" });
|
||||
setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${a.name || "相手"}と喧嘩している`, target: a, phase: "perform", source: "behavior" });
|
||||
startFight(a, b, opts = {}) {
|
||||
if (!a || !b || a.dead || b.dead) return false;
|
||||
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return false;
|
||||
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
|
||||
if (this.isAlreadyFightingPair?.(a, b)) return true;
|
||||
const allowForcedStart = Boolean(opts?.forced);
|
||||
if (!allowForcedStart && ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04 || (a.intimidateTimer || 0) > 0.04 || (b.intimidateTimer || 0) > 0.04)) return false;
|
||||
if (!allowForcedStart && this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
|
||||
this.markFightPairCooldown?.(a, b, 3.4);
|
||||
const initiator = opts?.initiator === b ? b : a;
|
||||
const receiver = initiator === a ? b : a;
|
||||
const causeFor = (self, other) => {
|
||||
if (((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(self) : self.behavior)?.source === "fight_mochi") || (self.fightMochiTimer || 0) > 0.04) return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
|
||||
if (self === receiver && other === initiator) return `${other.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
|
||||
return `${other.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
|
||||
};
|
||||
const textFor = (self, other) => typeof buildReasonText === "function"
|
||||
? buildReasonText("social", (typeof getTarinaiBehaviorTiedNeeds === "function" ? getTarinaiBehaviorTiedNeeds(self, "social") : self.behavior?.tiedNeeds) || ["social"], { id: "fight_rival", need: "social", subNeed: "conflict", label: self === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` }, self, this, { causeText: causeFor(self, other) })
|
||||
: `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b`;
|
||||
const reasonA = textFor(a, b);
|
||||
const reasonB = textFor(b, a);
|
||||
a.counterAttackFromId = a === receiver ? initiator.id : null;
|
||||
b.counterAttackFromId = b === receiver ? initiator.id : null;
|
||||
a.setActionState("fight", { target: b, reason: reasonA });
|
||||
b.setActionState("fight", { target: a, reason: reasonB });
|
||||
if (typeof 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(4.5, 7.0));
|
||||
b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
|
||||
a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8));
|
||||
b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8));
|
||||
a.nextHeadbutt = this.time + rand(0.08, 0.18);
|
||||
b.nextHeadbutt = a.nextHeadbutt;
|
||||
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
|
||||
|
|
@ -758,9 +905,10 @@
|
|||
}));
|
||||
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;
|
||||
}
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@
|
|||
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
|
||||
<div class="signboard-editor-titlebar">
|
||||
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
|
||||
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">×</button>
|
||||
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
||||
</div>
|
||||
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT}" rows="3" spellcheck="false"></textarea>
|
||||
<div class="signboard-editor-meta"><span>6文字×3行まで</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
|
||||
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT + SIGNBOARD_LINES - 1}" rows="3" cols="6" wrap="off" spellcheck="false" inputmode="text"></textarea>
|
||||
<div class="signboard-editor-meta"><span>6\u6587\u5b57\u00d73\u884c\u307e\u3067</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
|
||||
<div class="signboard-editor-actions">
|
||||
<button id="signboardEditorClear" class="btn" type="button">\u6d88\u3059</button>
|
||||
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
|
|
@ -38,25 +38,35 @@
|
|||
return chars.slice(0, Math.max(0, limit)).join("");
|
||||
}
|
||||
|
||||
function sanitizeSignboardText(value = "") {
|
||||
function sanitizeSignboardText(value = "", opts = {}) {
|
||||
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
|
||||
const rawLines = normalized.split("\n");
|
||||
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
|
||||
const out = [];
|
||||
for (const rawLine of rawLines) {
|
||||
let rest = String(rawLine || "").trim();
|
||||
if (!rest && out.length < SIGNBOARD_LINES) { out.push(""); continue; }
|
||||
while (rest && out.length < SIGNBOARD_LINES) {
|
||||
out.push(sliceGraphemes(rest, SIGNBOARD_CHARS_PER_LINE));
|
||||
rest = Array.from(rest).slice(SIGNBOARD_CHARS_PER_LINE).join("");
|
||||
}
|
||||
if (out.length >= SIGNBOARD_LINES) break;
|
||||
for (let i = 0; i < SIGNBOARD_LINES; i += 1) {
|
||||
const rawLine = rawLines[i];
|
||||
if (rawLine == null) break;
|
||||
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
|
||||
}
|
||||
return out.slice(0, SIGNBOARD_LINES).join("\n").replace(/\n+$/g, "").trim();
|
||||
const next = out.join("\n");
|
||||
return opts.trimTrailing === false ? next : next.replace(/\n+$/g, "");
|
||||
}
|
||||
|
||||
function signboardVisibleLength(text = "") {
|
||||
return Array.from(String(text || "").replace(/\n/g, "")).length;
|
||||
}
|
||||
|
||||
function replaceSignboardEditorSelection(area, text = "") {
|
||||
if (!area) return;
|
||||
const value = String(area.value || "");
|
||||
const start = Number(area.selectionStart || 0);
|
||||
const end = Number(area.selectionEnd || start);
|
||||
const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false });
|
||||
area.value = next;
|
||||
const caret = Math.min(next.length, start + String(text || "").length);
|
||||
area.selectionStart = caret;
|
||||
area.selectionEnd = caret;
|
||||
}
|
||||
|
||||
function openSignboardEditor(sign, worldRef) {
|
||||
if (!sign) return false;
|
||||
const dialog = ensureSignboardEditor();
|
||||
|
|
@ -88,9 +98,41 @@
|
|||
dialog.__worldRef = worldRef;
|
||||
if (area) {
|
||||
area.value = sanitizeSignboardText(sign.text || "");
|
||||
area.onkeydown = (e) => {
|
||||
const value = String(area.value || "");
|
||||
const start = Number(area.selectionStart || 0);
|
||||
const end = Number(area.selectionEnd || start);
|
||||
const before = value.slice(0, start);
|
||||
const selected = value.slice(start, end);
|
||||
const lineIndex = before.split("\n").length - 1;
|
||||
const lines = value.split("\n");
|
||||
const currentLine = lines[lineIndex] || "";
|
||||
const replacing = selected.length > 0;
|
||||
if (e.key === "Enter") {
|
||||
const selectedLineBreaks = (selected.match(/\n/g) || []).length;
|
||||
e.preventDefault();
|
||||
if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) {
|
||||
replaceSignboardEditorSelection(area, "\n");
|
||||
syncCount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const selectedSameLine = !selected.includes("\n");
|
||||
const selectedCount = selectedSameLine ? Array.from(selected).length : 0;
|
||||
if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
|
||||
else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
|
||||
}
|
||||
};
|
||||
area.onpaste = (e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData?.getData?.("text") || "";
|
||||
replaceSignboardEditorSelection(area, text);
|
||||
syncCount();
|
||||
};
|
||||
area.oninput = () => {
|
||||
const clean = sanitizeSignboardText(area.value || "");
|
||||
if (area.value !== clean && signboardVisibleLength(area.value || "") > SIGNBOARD_TEXT_LIMIT) area.value = clean;
|
||||
const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false });
|
||||
if (area.value !== clean) area.value = clean;
|
||||
syncCount();
|
||||
};
|
||||
setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0);
|
||||
|
|
@ -219,6 +261,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
if (item.type === "grass_bed") {
|
||||
const spacing = Math.max(46, (item.r || 18) * 3.0);
|
||||
for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) {
|
||||
if (!it || it === item || it.dead || it.type !== "grass_bed") continue;
|
||||
const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5);
|
||||
if (distXY(item.x, item.y, it.x, it.y) < required) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
|
|
@ -302,6 +352,10 @@
|
|||
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
|
||||
}
|
||||
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
|
||||
if (foundT) {
|
||||
const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id);
|
||||
if (lodged && lodged.detachPushpin) return lodged;
|
||||
}
|
||||
if (foundT && foundItem) {
|
||||
const td = distXY(x, y, foundT.x, foundT.y);
|
||||
return td <= foundItemD + 8 ? foundT : foundItem;
|
||||
|
|
@ -311,18 +365,10 @@
|
|||
|
||||
placeItem(item, dropped = true) {
|
||||
if (!item) return null;
|
||||
if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
|
||||
showToast("\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8349\u306e\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u307e\u3059\u3002");
|
||||
if (item.type === "grass" && !this.canAddGrass?.(1)) {
|
||||
showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
|
||||
return null;
|
||||
}
|
||||
if (this.isFenceType(item.type)) {
|
||||
this.compactItems();
|
||||
this.updateItemCounts();
|
||||
if (this.liveFenceCount() >= this.fenceLimit()) {
|
||||
showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
|
||||
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");
|
||||
return null;
|
||||
|
|
@ -332,13 +378,14 @@
|
|||
item.dropTimer = item.dropMax;
|
||||
item.dropImpactDone = false;
|
||||
}
|
||||
this.items.push(item);
|
||||
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
|
||||
this.markTerrainDirty?.(`place:${item.type}`);
|
||||
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.rebuildSpatial(true);
|
||||
this.ensureSpatial?.(`place:${item.type}`);
|
||||
return item;
|
||||
},
|
||||
|
||||
|
|
@ -391,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) {
|
||||
|
|
@ -450,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,
|
||||
|
|
@ -484,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 || "",
|
||||
|
|
@ -513,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);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue