hm
This commit is contained in:
parent
7455d630fc
commit
4d3fc2cd47
84 changed files with 3429 additions and 3480 deletions
|
|
@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
|
|||
|
||||
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
|
||||
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
|
||||
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.92`.
|
||||
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.101`.
|
||||
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
|
||||
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.
|
||||
- Command path: UI/input emits command objects; `js/command_dispatcher.js` routes tool, pointer, selection, save, reset, and UI intents.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
tarinai_king_fix_v9
|
||||
- Added fire item: spreads to tarinai, ants, and grass; burning causes continuous damage and panic movement.
|
||||
- Added flame_firecracker: scatters fire when the fuse ends without blast damage/explosion.
|
||||
- Burning tarinai behavior text and fire-sighting panic text are forced through observation data.
|
||||
- Fire can be extinguished by water droplets or random extinguish checks; rain and <=10C increase extinguish chance.
|
||||
- Fixed duplicator stored-water preview color by rendering droplet sprites instead of fallback orange food shape.
|
||||
- Version bumped to 39.15.98.
|
||||
tarinai_king_fix_v10
|
||||
- Fire tool button hidden; flames spawn only from flame firecracker.
|
||||
- Burning tarinai and ants draw lightweight flame-shaped overlays.
|
||||
- Flame firecracker is redder and shows a countdown timer; normal firecracker is darker.
|
||||
- Standalone flame and fire particles use a simpler flame silhouette.
|
||||
- Version: 39.15.101
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
|
||||
"version": "39.15.92",
|
||||
"version": "39.15.101",
|
||||
"entrypoints": {
|
||||
"app": "index.html",
|
||||
"runtime": "js/main.js",
|
||||
|
|
@ -71,7 +71,9 @@
|
|||
"js/main.js",
|
||||
"js/math.js",
|
||||
"js/perf_profiler.js",
|
||||
"js/debug_tools.js"
|
||||
"js/debug_tools.js",
|
||||
"js/update_step_pipeline_runner.js",
|
||||
"js/burn_motion_util.js"
|
||||
],
|
||||
"meaning": "Bootstrap, shared constants/helpers, registries, config, profiling/debug."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
|
|||
|
||||
## Runtime Shape
|
||||
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.92`.
|
||||
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.101`.
|
||||
- Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots.
|
||||
- Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases.
|
||||
- UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "39.15.98",
|
||||
"version": "39.15.101",
|
||||
"css": [
|
||||
"css/base.css",
|
||||
"css/layout.css",
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"js/disease_registry.js",
|
||||
"js/sound_pack.js",
|
||||
"js/deterministic_helpers.js",
|
||||
"js/item_tool_metadata.js",
|
||||
"js/item_tool_definitions.js",
|
||||
"js/item_visual_definitions.js",
|
||||
"js/item_food_definitions.js",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"js/ground_types.js",
|
||||
"js/input_mode_manager.js",
|
||||
"js/math.js",
|
||||
"js/geometry_helpers.js",
|
||||
"js/collision_footprint_system.js",
|
||||
"js/physics_helpers.js",
|
||||
"js/placement_preview_system.js",
|
||||
|
|
@ -44,6 +46,8 @@
|
|||
"js/item_type_initializers.js",
|
||||
"js/item_lifecycle_support.js",
|
||||
"js/item_update_policy.js",
|
||||
"js/fire_runtime_system.js",
|
||||
"js/robot_cleaner_system.js",
|
||||
"js/item_dynamic_tool_system.js",
|
||||
"js/item_dynamic_ball_system.js",
|
||||
"js/item_dynamic_duplicator_system.js",
|
||||
|
|
@ -56,10 +60,13 @@
|
|||
"js/item_lifecycle_step_decay.js",
|
||||
"js/item_lifecycle_step_dynamic.js",
|
||||
"js/item_lifecycle_step_growth.js",
|
||||
"js/update_step_pipeline_runner.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
"js/item_runtime.js",
|
||||
"js/structure_lifecycle.js",
|
||||
"js/item_render_helpers.js",
|
||||
"js/item_render_runtime.js",
|
||||
"js/burn_motion_util.js",
|
||||
"js/ants.js",
|
||||
"js/health.js",
|
||||
"js/tarinai.js",
|
||||
|
|
@ -79,6 +86,7 @@
|
|||
"js/tarinai_building_behavior.js",
|
||||
"js/tarinai_action_definitions.js",
|
||||
"js/tarinai_needs_items.js",
|
||||
"js/tarinai_food_prototype_mixin.js",
|
||||
"js/tarinai_need_planner_system.js",
|
||||
"js/tarinai_item_interaction_context.js",
|
||||
"js/tarinai_food_interaction_system.js",
|
||||
|
|
@ -99,12 +107,16 @@
|
|||
"js/world.js",
|
||||
"js/world_view.js",
|
||||
"js/family_graph.js",
|
||||
"js/world_reset_presets.js",
|
||||
"js/world_family_social.js",
|
||||
"js/impact_core_system.js",
|
||||
"js/impact_response_system.js",
|
||||
"js/collision_response_system.js",
|
||||
"js/world_combat_effects.js",
|
||||
"js/world_environment.js",
|
||||
"js/world_temperature_system.js",
|
||||
"js/world_pathfinding_system.js",
|
||||
"js/world_grass_placement_system.js",
|
||||
"js/world_spatial_budget.js",
|
||||
"js/world_ants_system.js",
|
||||
"js/weather_system.js",
|
||||
|
|
|
|||
50
css/base.css
50
css/base.css
|
|
@ -36,23 +36,6 @@ button {
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(164, 225, 104, 0.22), transparent 32%),
|
||||
linear-gradient(180deg, rgba(239, 248, 255, 0.98), rgba(247, 245, 236, 0.98));
|
||||
transition: opacity 180ms ease, visibility 180ms ease;
|
||||
}
|
||||
.loading-screen.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.loading-panel {
|
||||
width: min(420px, 92vw);
|
||||
padding: 24px 22px;
|
||||
|
|
@ -62,18 +45,6 @@ button {
|
|||
box-shadow: 0 18px 48px rgba(64, 48, 32, 0.16);
|
||||
text-align: center;
|
||||
}
|
||||
.loading-title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.loading-text {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
min-height: 1.55em;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: min(1480px, 100vw);
|
||||
|
|
@ -120,7 +91,6 @@ h1 {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
|
@ -260,7 +230,6 @@ h1 {
|
|||
min-width: 132px;
|
||||
}
|
||||
|
||||
|
||||
.ecology-dialog-panel {
|
||||
width: min(760px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
|
|
@ -453,16 +422,15 @@ h1 {
|
|||
.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp"); }
|
||||
|
||||
/* Generated parameter item tool icons. */
|
||||
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=39.15.92"); }
|
||||
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=39.15.92"); }
|
||||
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=39.15.92"); }
|
||||
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=39.15.92"); }
|
||||
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=39.15.92"); }
|
||||
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=39.15.92"); }
|
||||
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=39.15.92"); }
|
||||
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=39.15.92"); }
|
||||
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=39.15.92"); }
|
||||
|
||||
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp"); }
|
||||
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp"); }
|
||||
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp"); }
|
||||
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp"); }
|
||||
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp"); }
|
||||
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp"); }
|
||||
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp"); }
|
||||
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp"); }
|
||||
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp"); }
|
||||
|
||||
.signboard-editor {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -762,12 +762,10 @@
|
|||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98));
|
||||
border-bottom-color: rgba(82,128,190,0.30);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-body {
|
||||
background: rgba(238, 247, 255, 0.98);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool-category-body {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
background: rgba(238, 247, 255, 0.98);
|
||||
}
|
||||
.tool-category[data-tool-category="operate"] .tool {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -2,26 +2,15 @@
|
|||
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); }
|
||||
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); }
|
||||
.tool[data-tool="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="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); --tool-icon-zoom: 1.36; }
|
||||
.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
|
||||
.tool[data-tool="area_delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); }
|
||||
.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); }
|
||||
.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; }
|
||||
|
||||
.tool[data-tool="poke"] { --tool-emoji: "\1F448"; }
|
||||
.tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; }
|
||||
.tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; }
|
||||
.tool[data-tool="clear_tool"] { --tool-emoji: "\2715"; }
|
||||
.tool[data-tool="copy"] { --tool-emoji: "\29c9 "; }
|
||||
.tool[data-tool="area_delete"] { --tool-emoji: "\d7 "; }
|
||||
.tool[data-tool="rope"] { --tool-emoji: "\2570 "; }
|
||||
.tool[data-tool="rod"] { --tool-emoji: "\2501 "; }
|
||||
.tool[data-tool="reciprocator"] { --tool-emoji: "\21c4 "; }
|
||||
.tool[data-tool="undo"] { --tool-emoji: "\21b6 "; }
|
||||
.tool[data-tool="redo"] { --tool-emoji: "\21b7 "; }
|
||||
.tool.tool-emoji-icon { --tool-icon: none; }
|
||||
.tool.tool-emoji-icon::before {
|
||||
content: var(--tool-emoji);
|
||||
content: var(--tool-emoji, "");
|
||||
background: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
|
@ -55,7 +44,6 @@
|
|||
transform: translate(-50%, -50%) scale(var(--tool-icon-zoom, 1)) !important;
|
||||
}
|
||||
|
||||
.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; }
|
||||
.tool[data-tool="observe"],
|
||||
.tool[data-tool="new"] { --tool-icon-zoom: 1.20; }
|
||||
.tool[data-tool="giant_drug"] { --tool-icon-zoom: 1.58; }
|
||||
|
|
@ -78,16 +66,16 @@
|
|||
.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: 1px solid var(--tool-cat-line, rgba(84,68,48,0.10));
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,0.34);
|
||||
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,255,255,0.36)), rgba(255,255,255,0.28));
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-category-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(84,68,48,0.08);
|
||||
background: rgba(255,252,244,0.66);
|
||||
border-bottom: 1px solid var(--tool-cat-line, rgba(84,68,48,0.08));
|
||||
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54)));
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -114,8 +102,8 @@
|
|||
.selected-category-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(84,68,48,0.08);
|
||||
background: rgba(255,252,244,0.66);
|
||||
border-bottom: 1px solid var(--tool-cat-line, rgba(84,68,48,0.08));
|
||||
background: linear-gradient(180deg, var(--tool-cat-a, rgba(255,252,244,0.66)), var(--tool-cat-b, rgba(255,252,244,0.54)));
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
|
@ -340,8 +328,6 @@ meter {
|
|||
.tool-category[data-tool-category="habitat"] { --tool-cat-a: rgba(255,245,218,0.84); --tool-cat-b: rgba(250,229,186,0.76); --tool-cat-line: rgba(174,125,54,0.28); }
|
||||
.tool-category[data-tool-category="physics"] { --tool-cat-a: rgba(226,247,246,0.84); --tool-cat-b: rgba(199,232,231,0.76); --tool-cat-line: rgba(70,145,148,0.28); }
|
||||
.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)); }
|
||||
@media (min-width: 921px) {
|
||||
.mobile-mode-controls { display: none !important; }
|
||||
}
|
||||
|
|
|
|||
424
css/mobile.css
424
css/mobile.css
|
|
@ -1,4 +1,4 @@
|
|||
/* v15.12.1: top-right sound settings panel */
|
||||
/* Sound menu */
|
||||
.sound-menu { position: relative; display: inline-block; }
|
||||
.sound-menu-btn.active { background: linear-gradient(180deg, #f3ffe9, #e7f8db); border-color: rgba(103,178,44,.46); }
|
||||
.sound-panel {
|
||||
|
|
@ -22,19 +22,8 @@
|
|||
.sound-panel input:disabled + span { opacity: .55; }
|
||||
.sound-master { font-weight: 700; }
|
||||
.sound-panel-divider { height: 1px; background: rgba(84,68,48,.12); margin: 2px 0; }
|
||||
@media (max-width: 980px) { .sound-panel { right: auto; left: 0; } }
|
||||
|
||||
|
||||
/* 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 {
|
||||
overscroll-behavior-y: auto !important;
|
||||
}
|
||||
|
||||
/* v15.12.4: sound settings live only in the top-right sound menu */
|
||||
|
||||
/* v15.17.0: mobile layout isolation and canvas touch gestures */
|
||||
/* Shared layout and gesture guards */
|
||||
#app { overflow-x: clip; }
|
||||
.layout { position: relative; z-index: 2; }
|
||||
.panel { position: relative; z-index: 3; }
|
||||
|
|
@ -55,81 +44,21 @@
|
|||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
isolation: isolate;
|
||||
}
|
||||
.lineage-main-card {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: 14px;
|
||||
clear: both;
|
||||
}
|
||||
.lineage-main-content {
|
||||
height: auto;
|
||||
min-height: 340px;
|
||||
max-height: 72vh;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree .lineage-tree-panel {
|
||||
max-width: none;
|
||||
}
|
||||
.log {
|
||||
overscroll-behavior-y: auto !important;
|
||||
}
|
||||
|
||||
/* Mobile input mode */
|
||||
.mobile-mode-controls {
|
||||
body.touch-mobile-ui .tool-tip-popover {
|
||||
max-width: min(520px, calc(100vw - 24px));
|
||||
}
|
||||
body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapsed .tool-category-body {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 24;
|
||||
gap: 5px;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 245, 0.90);
|
||||
box-shadow: 0 8px 22px rgba(80,60,35,.18);
|
||||
border: 1px solid rgba(120, 95, 60, .16);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.mobile-mode-controls button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #65533f;
|
||||
background: rgba(255,255,255,.78);
|
||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.05);
|
||||
}
|
||||
.mobile-mode-controls button.active {
|
||||
color: #3d321f;
|
||||
background: linear-gradient(180deg, #ffe9a8, #ffd376);
|
||||
box-shadow: 0 3px 10px rgba(150, 105, 30, .20);
|
||||
}
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) { display: flex; }
|
||||
body:not(.touch-mobile-ui) .mobile-mode-controls { display: none !important; }
|
||||
|
||||
/* Mobile input controls */
|
||||
.mobile-mode-controls,
|
||||
.mobile-rotate-controls {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 58px;
|
||||
z-index: 24;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
|
|
@ -139,6 +68,17 @@ body:not(.touch-mobile-ui) .mobile-mode-controls { display: none !important; }
|
|||
border: 1px solid rgba(120, 95, 60, .16);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.mobile-mode-controls {
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
gap: 5px;
|
||||
background: rgba(255, 251, 245, 0.90);
|
||||
}
|
||||
.mobile-rotate-controls {
|
||||
right: 10px;
|
||||
bottom: 58px;
|
||||
}
|
||||
.mobile-mode-controls button,
|
||||
.mobile-rotate-controls button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
|
|
@ -149,83 +89,22 @@ body:not(.touch-mobile-ui) .mobile-mode-controls { display: none !important; }
|
|||
background: rgba(255,255,255,.82);
|
||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.05);
|
||||
}
|
||||
.mobile-rotate-controls button:active { transform: translateY(1px); }
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { display: flex; }
|
||||
body:not(.touch-mobile-ui) .mobile-rotate-controls { display: none !important; }
|
||||
@media (max-width: 980px) {
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 56px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 12002;
|
||||
max-width: calc(100vw - 22px);
|
||||
}
|
||||
.mobile-mode-controls button { padding: 7px 8px; background: rgba(255,255,255,.78); }
|
||||
.mobile-mode-controls button.active {
|
||||
color: #3d321f;
|
||||
background: linear-gradient(180deg, #ffe9a8, #ffd376);
|
||||
box-shadow: 0 3px 10px rgba(150, 105, 30, .20);
|
||||
}
|
||||
|
||||
.mobile-rotate-controls button:active { transform: translateY(1px); }
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]),
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) { display: flex; }
|
||||
body:not(.touch-mobile-ui) .mobile-mode-controls,
|
||||
body:not(.touch-mobile-ui) .mobile-rotate-controls { display: none !important; }
|
||||
body.touch-mobile-ui.mobile-mode-camera #gameCanvas { cursor: grab; }
|
||||
body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; }
|
||||
|
||||
|
||||
/* Keep panel themes on narrow layouts. */
|
||||
.card.tool-card { --ui-theme-rgb: 0, 114, 178; background: linear-gradient(180deg, rgba(242, 248, 252, 0.94), rgba(232, 243, 250, 0.91)); border-color: rgba(0, 114, 178, 0.24); }
|
||||
.card.selected-card { --ui-theme-rgb: 204, 121, 167; background: linear-gradient(180deg, rgba(253, 245, 250, 0.94), rgba(249, 235, 244, 0.91)); border-color: rgba(204, 121, 167, 0.24); }
|
||||
.card.colony-card { --ui-theme-rgb: 0, 158, 115; background: linear-gradient(180deg, rgba(240, 250, 247, 0.94), rgba(229, 245, 239, 0.91)); border-color: rgba(0, 158, 115, 0.24); }
|
||||
.card.log-card { --ui-theme-rgb: 230, 159, 0; background: linear-gradient(180deg, rgba(255, 249, 236, 0.94), rgba(253, 242, 215, 0.91)); border-color: rgba(230, 159, 0, 0.24); }
|
||||
|
||||
|
||||
/* v39.15.35: mobile controls and scroll affordances */
|
||||
@media (max-width: 980px) {
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
|
||||
display: flex !important;
|
||||
}
|
||||
.panel-quick-tabs {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.panel-quick-tabs button {
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
}
|
||||
body.touch-mobile-ui .tool-tip-popover {
|
||||
max-width: min(520px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
/* v39.15.36: mobile navigation redesign without family input mode */
|
||||
@media (max-width: 980px) {
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 12001;
|
||||
max-width: calc(100vw - 22px);
|
||||
}
|
||||
.panel-quick-tabs {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
.lineage-main-card {
|
||||
margin: 10px 0 0;
|
||||
contain: content;
|
||||
}
|
||||
.lineage-main-card .lineage-head {
|
||||
gap: 8px;
|
||||
}
|
||||
.lineage-main-card .lineage-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
body.touch-mobile-ui .tool-category:not([data-tool-category="operate"]).collapsed .tool-category-body {
|
||||
display: none;
|
||||
}
|
||||
body.touch-mobile-ui .log {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
/* Back to canvas affordance */
|
||||
.back-to-game-btn {
|
||||
position: fixed;
|
||||
right: calc(env(safe-area-inset-right, 0px) + 12px);
|
||||
|
|
@ -242,180 +121,7 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.back-to-game-btn[hidden] { display: none !important; }
|
||||
@media (min-width: 981px) {
|
||||
.back-to-game-btn { display: none !important; }
|
||||
}
|
||||
|
||||
/* v39.15.38: keep desktop layout close to v39.15.36; only hide the PC quick-scroll buttons that duplicate the canvas/tool card. */
|
||||
@media (min-width: 981px) {
|
||||
.panel-quick-tabs {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.panel-quick-tabs [data-panel-tab="game"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* v39.15.38: mobile navigation/footer room and family-graph reachability. */
|
||||
@media (max-width: 980px) {
|
||||
html,
|
||||
body {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-left: max(10px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(10px, env(safe-area-inset-right, 0px));
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.topbar,
|
||||
.top-left,
|
||||
.brand,
|
||||
.layout,
|
||||
.stage-wrap,
|
||||
.panel,
|
||||
.card,
|
||||
.lineage-main-card {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.top-actions {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 1px 0 5px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.top-actions > .btn,
|
||||
.top-actions > .sound-menu,
|
||||
.top-actions .sound-menu-btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-actions .btn {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.room-link {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
body.touch-mobile-ui .mobile-mode-controls:not([hidden]) {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: 10px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 24;
|
||||
max-width: calc(100% - 18px);
|
||||
}
|
||||
body.touch-mobile-ui .panel-quick-tabs {
|
||||
position: fixed;
|
||||
left: max(8px, env(safe-area-inset-left, 0px));
|
||||
right: max(8px, env(safe-area-inset-right, 0px));
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
|
||||
top: auto;
|
||||
z-index: 12002;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(102, 169, 215, 0.34);
|
||||
border-radius: 16px;
|
||||
background: rgba(244, 250, 247, 0.95);
|
||||
box-shadow: 0 12px 28px rgba(42, 54, 64, 0.18);
|
||||
}
|
||||
body.touch-mobile-ui .panel-quick-tabs button {
|
||||
min-height: 34px;
|
||||
padding: 5px 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
body.touch-mobile-ui .selected-card,
|
||||
body.touch-mobile-ui .tool-card,
|
||||
body.touch-mobile-ui .colony-card,
|
||||
body.touch-mobile-ui .log-card,
|
||||
body.touch-mobile-ui .lineage-main-card {
|
||||
scroll-margin-top: 10px;
|
||||
scroll-margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 88px);
|
||||
}
|
||||
body.touch-mobile-ui .lineage-main-card {
|
||||
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 78px);
|
||||
}
|
||||
.back-to-game-btn {
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 62px);
|
||||
right: max(10px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Desktop rollback guard. Keep the normal PC layout; only remove duplicated quick-scroll buttons. */
|
||||
@media (min-width: 981px) {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.top-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.brand,
|
||||
.brand h1,
|
||||
h1 {
|
||||
flex: 0 0 auto;
|
||||
min-width: max-content;
|
||||
max-width: none !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
.top-actions {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-actions .btn,
|
||||
.top-actions .sound-menu-btn,
|
||||
.room-link {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
.room-link {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.panel-quick-tabs {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.panel-quick-tabs [data-panel-tab="game"] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC / smartphone UI isolation. All override UI placement is scoped by width. */
|
||||
@media (min-width: 981px) {
|
||||
html,
|
||||
body {
|
||||
|
|
@ -427,18 +133,8 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
padding: 18px;
|
||||
overflow: visible;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.top-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.topbar { gap: 12px; }
|
||||
.top-left { max-width: 100%; }
|
||||
.brand,
|
||||
.brand h1,
|
||||
h1 {
|
||||
|
|
@ -448,15 +144,7 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
}
|
||||
.top-actions {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-actions { margin-left: auto; }
|
||||
.top-actions .btn,
|
||||
.top-actions .sound-menu-btn,
|
||||
.room-link {
|
||||
|
|
@ -488,18 +176,12 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
top: 0;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
.panel-quick-tabs [data-panel-tab="game"] {
|
||||
display: none !important;
|
||||
}
|
||||
.panel-quick-tabs [data-panel-tab="game"] { display: none !important; }
|
||||
.lineage-main-card {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: auto;
|
||||
margin: 16px 0 0;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
.lineage-main-content {
|
||||
height: min(78vh, 980px);
|
||||
|
|
@ -508,6 +190,7 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
overflow: auto;
|
||||
overscroll-behavior: auto;
|
||||
}
|
||||
.back-to-game-btn { display: none !important; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
|
|
@ -540,16 +223,11 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-left { gap: 8px; }
|
||||
.brand h1,
|
||||
h1 {
|
||||
max-width: 100%;
|
||||
|
|
@ -559,8 +237,6 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
font-size: clamp(20px, 7vw, 30px);
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
|
|
@ -581,13 +257,12 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sound-panel { right: auto; left: 0; }
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
}
|
||||
.stage-wrap {
|
||||
|
|
@ -642,6 +317,15 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
z-index: 24;
|
||||
max-width: calc(100% - 18px);
|
||||
}
|
||||
body.touch-mobile-ui .mobile-rotate-controls:not([hidden]) {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 56px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 12002;
|
||||
max-width: calc(100vw - 22px);
|
||||
}
|
||||
body.touch-mobile-ui.mobile-mode-auto #gameCanvas {
|
||||
touch-action: pan-y pinch-zoom !important;
|
||||
}
|
||||
|
|
@ -653,13 +337,10 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
max-height: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
overscroll-behavior-y: auto !important;
|
||||
}
|
||||
.lineage-main-card {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
margin: 10px 0 calc(env(safe-area-inset-bottom, 0px) + 132px);
|
||||
overflow: visible !important;
|
||||
|
|
@ -678,7 +359,6 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree {
|
||||
max-width: 100%;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: visible !important;
|
||||
overscroll-behavior-x: contain;
|
||||
|
|
@ -686,9 +366,7 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
}
|
||||
.lineage-family-tree .lineage-tree-panel {
|
||||
max-width: none;
|
||||
}
|
||||
.lineage-family-tree .lineage-tree-panel { max-width: none; }
|
||||
.selected-card,
|
||||
.tool-card,
|
||||
.colony-card,
|
||||
|
|
@ -698,4 +376,8 @@ body.touch-mobile-ui .tool-tip-popover {
|
|||
scroll-margin-top: 0 !important;
|
||||
scroll-margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 104px);
|
||||
}
|
||||
.back-to-game-btn {
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 62px);
|
||||
right: max(10px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
css/panel.css
112
css/panel.css
|
|
@ -99,6 +99,7 @@ body::before {
|
|||
box-shadow: 0 3px 8px rgba(64,48,32,0.045);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.family-node.child {
|
||||
border-color: rgba(103, 178, 44, 0.38);
|
||||
|
|
@ -182,40 +183,6 @@ body::before {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-personality-tags {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(13px, auto);
|
||||
align-items: stretch;
|
||||
gap: 2px 4px;
|
||||
margin-top: 2px;
|
||||
max-width: 84px;
|
||||
width: 84px;
|
||||
max-height: 34px;
|
||||
overflow: hidden !important;
|
||||
text-overflow: clip !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.family-personality-tags em {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 1px 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(103,178,44,0.11);
|
||||
border: 1px solid rgba(103,178,44,0.20);
|
||||
color: rgba(64,72,48,0.86);
|
||||
font-size: 8.2px;
|
||||
font-style: normal;
|
||||
line-height: 1.18;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.tool[data-tip] { position: relative; }
|
||||
|
||||
|
||||
|
|
@ -353,23 +320,6 @@ body::before {
|
|||
border-radius: 9px;
|
||||
background: rgba(255,255,255,0.70);
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy {
|
||||
max-width: calc(100% - 46px);
|
||||
width: auto;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy strong,
|
||||
.lineage-family-tree .tree-node .family-copy > span:not(.family-genetic-stats):not(.family-fight-stat):not(.family-personality-tags) {
|
||||
max-width: 100%;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-personality-tags {
|
||||
max-width: 84px;
|
||||
width: 84px;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-personality-tags em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.lineage-main-content {
|
||||
height: 70vh;
|
||||
|
|
@ -421,21 +371,22 @@ body::before {
|
|||
}
|
||||
|
||||
|
||||
/* v9: keep 2x2 personality tags inside fixed family-tree nodes. */
|
||||
.lineage-family-tree .tree-node .family-personality-tags,
|
||||
/* Family tree personality tags: consolidated final cascade. */
|
||||
.family-personality-tags {
|
||||
box-sizing: border-box !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
grid-auto-rows: 13px !important;
|
||||
gap: 2px 3px !important;
|
||||
width: 86px !important;
|
||||
max-width: 86px !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 28px !important;
|
||||
max-height: 28px !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
justify-self: stretch !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-personality-tags em,
|
||||
.family-personality-tags em {
|
||||
box-sizing: border-box !important;
|
||||
display: block !important;
|
||||
|
|
@ -449,54 +400,23 @@ body::before {
|
|||
white-space: nowrap !important;
|
||||
line-height: 11px !important;
|
||||
font-size: 7.2px !important;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.08em !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* v39.15.85: push family-tree personality tags to the node's right edge. */
|
||||
.lineage-family-tree .tree-node .family-personality-tags,
|
||||
.family-personality-tags {
|
||||
margin-left: auto !important;
|
||||
margin-right: 0 !important;
|
||||
justify-self: end !important;
|
||||
border-radius: 999px;
|
||||
background: rgba(103,178,44,0.11);
|
||||
border: 1px solid rgba(103,178,44,0.20);
|
||||
color: rgba(64,72,48,0.86);
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy strong,
|
||||
.lineage-family-tree .tree-node .family-copy > span:not(.family-personality-tags) {
|
||||
max-width: calc(100% - 46px) !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy > .family-personality-tags {
|
||||
max-width: 86px !important;
|
||||
width: 86px !important;
|
||||
}
|
||||
|
||||
/* v39.15.87: let family personality tags use the full node width. */
|
||||
.lineage-family-tree .tree-node .family-copy > .family-personality-tags,
|
||||
.lineage-family-tree .tree-node .family-personality-tags,
|
||||
.family-personality-tags {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
justify-self: stretch !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy strong,
|
||||
.lineage-family-tree .tree-node .family-copy > span:not(.family-personality-tags) {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
|
||||
/* v39.15.92: keep personality tags inside the family-tree node bottom edge. */
|
||||
.lineage-family-tree .tree-node {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy {
|
||||
position: static !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy strong,
|
||||
.lineage-family-tree .tree-node .family-copy > span:not(.family-personality-tags) {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
.lineage-family-tree .tree-node .family-copy > .family-personality-tags {
|
||||
position: absolute !important;
|
||||
left: 7px !important;
|
||||
|
|
|
|||
338
index.html
338
index.html
|
|
@ -7,11 +7,11 @@
|
|||
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=16.07.17" />
|
||||
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=16.07.17" />
|
||||
<link rel="shortcut icon" href="favicon.ico?v=16.07.17" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.15.98" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.15.98" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.15.98" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.15.98" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.15.98" />
|
||||
<link rel="stylesheet" href="css/base.css?v=39.15.101" />
|
||||
<link rel="stylesheet" href="css/layout.css?v=39.15.101" />
|
||||
<link rel="stylesheet" href="css/panel.css?v=39.15.101" />
|
||||
<link rel="stylesheet" href="css/components.css?v=39.15.101" />
|
||||
<link rel="stylesheet" href="css/mobile.css?v=39.15.101" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingScreen" class="loading-screen" aria-live="polite">
|
||||
|
|
@ -159,20 +159,20 @@
|
|||
<h2 id="fieldDialogTitle">リセット設定</h2>
|
||||
<p class="field-dialog-lead">サイズとプリセットを組み合わせて観察環境を生成します。</p>
|
||||
<div class="field-dialog-section-title">サイズ</div>
|
||||
<div class="field-choice-grid reset-size-choice-grid">
|
||||
<div class="field-choice-grid">
|
||||
<button class="field-choice" data-field-type="park" type="button" aria-pressed="false"><strong>公園</strong><span>大</span></button>
|
||||
<button class="field-choice" data-field-type="garden" type="button" aria-pressed="true"><strong>庭</strong><span>中</span></button>
|
||||
<button class="field-choice" data-field-type="cage" type="button" aria-pressed="false"><strong>ケージ</strong><span>小</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-section-title">プリセット</div>
|
||||
<div class="field-choice-grid reset-preset-choice-grid">
|
||||
<button class="field-choice preset-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>過密</strong><span>大量とずんだ</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵</span></button>
|
||||
<button class="field-choice preset-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱</span></button>
|
||||
<button class="field-choice" data-reset-preset="empty" type="button" aria-pressed="false"><strong>空白</strong><span>何も無い</span></button>
|
||||
<button class="field-choice" data-reset-preset="default" type="button" aria-pressed="true"><strong>デフォルト</strong><span>初期生成</span></button>
|
||||
<button class="field-choice" data-reset-preset="athletic" type="button" aria-pressed="false"><strong>アスレチック</strong><span>柵とボール</span></button>
|
||||
<button class="field-choice" data-reset-preset="crowded" type="button" aria-pressed="false"><strong>過密</strong><span>大量のたりない。</span></button>
|
||||
<button class="field-choice" data-reset-preset="family" type="button" aria-pressed="false"><strong>家族</strong><span>2匹とへこ餅</span></button>
|
||||
<button class="field-choice" data-reset-preset="war" type="button" aria-pressed="false"><strong>戦争</strong><span>けんかと柵</span></button>
|
||||
<button class="field-choice" data-reset-preset="happy" type="button" aria-pressed="false"><strong>幸福</strong><span>掃除機と巣箱</span></button>
|
||||
</div>
|
||||
<div class="field-dialog-actions">
|
||||
<button id="fieldCancelBtn" class="btn" type="button">キャンセル</button>
|
||||
|
|
@ -217,14 +217,14 @@
|
|||
|
||||
|
||||
<div id="creditsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="creditsDialogTitle">
|
||||
<div class="field-dialog-panel credits-dialog-panel">
|
||||
<div class="field-dialog-panel">
|
||||
<h2 id="creditsDialogTitle">クレジット</h2>
|
||||
<div class="credits-content">
|
||||
<div>
|
||||
<p><strong>製作:</strong> 333</p>
|
||||
<p><strong>イラスト:</strong> むらさき氏 / いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
|
||||
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
|
||||
</div>
|
||||
<div class="field-dialog-actions credits-actions">
|
||||
<div class="field-dialog-actions">
|
||||
<button id="creditsCloseBtn" class="btn" type="button">閉じる</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -252,152 +252,164 @@
|
|||
</div>
|
||||
|
||||
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="js/version.js?v=39.15.98" defer></script>
|
||||
<script src="js/event_bus.js?v=39.15.98" defer></script>
|
||||
<script src="js/registry_base.js?v=39.15.98" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.15.98" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.15.98" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_registry.js?v=39.15.98" defer></script>
|
||||
<script src="js/data.js?v=39.15.98" defer></script>
|
||||
<script src="js/ground_types.js?v=39.15.98" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.15.98" defer></script>
|
||||
<script src="js/math.js?v=39.15.98" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.15.98" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.15.98" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/assets.js?v=39.15.98" defer></script>
|
||||
<script src="js/audio.js?v=39.15.98" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.15.98" defer></script>
|
||||
<script src="js/render.js?v=39.15.98" defer></script>
|
||||
<script src="js/sim_core.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.15.98" defer></script>
|
||||
<script src="js/structures.js?v=39.15.98" defer></script>
|
||||
<script src="js/items.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.15.98" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.15.98" defer></script>
|
||||
<script src="js/ants.js?v=39.15.98" defer></script>
|
||||
<script src="js/health.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.15.98" defer></script>
|
||||
<script src="js/world.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_view.js?v=39.15.98" defer></script>
|
||||
<script src="js/family_graph.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.15.98" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_environment.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/weather_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.15.98" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.15.98" defer></script>
|
||||
<script src="js/system_order.js?v=39.15.98" defer></script>
|
||||
<script src="js/simulation.js?v=39.15.98" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_update.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.15.98" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.15.98" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.15.98" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_log.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.15.98" defer></script>
|
||||
<script src="js/save_schema.js?v=39.15.98" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.15.98" defer></script>
|
||||
<script src="js/history_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/save_codec.js?v=39.15.98" defer></script>
|
||||
<script src="js/save_storage.js?v=39.15.98" defer></script>
|
||||
<script src="js/save_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.15.98" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.15.98" defer></script>
|
||||
<script src="js/main.js?v=39.15.98" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.15.98" defer></script>
|
||||
<script src="js/version.js?v=39.15.101" defer></script>
|
||||
<script src="js/event_bus.js?v=39.15.101" defer></script>
|
||||
<script src="js/registry_base.js?v=39.15.101" defer></script>
|
||||
<script src="js/disease_registry.js?v=39.15.101" defer></script>
|
||||
<script src="js/sound_pack.js?v=39.15.101" defer></script>
|
||||
<script src="js/deterministic_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_tool_metadata.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_tool_definitions.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_visual_definitions.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_food_definitions.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_effect_definitions.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_registry.js?v=39.15.101" defer></script>
|
||||
<script src="js/data.js?v=39.15.101" defer></script>
|
||||
<script src="js/ground_types.js?v=39.15.101" defer></script>
|
||||
<script src="js/input_mode_manager.js?v=39.15.101" defer></script>
|
||||
<script src="js/math.js?v=39.15.101" defer></script>
|
||||
<script src="js/geometry_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/collision_footprint_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/physics_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/placement_preview_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/pin_attachment_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/physics_shape_editor_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/mechanical_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/mechanical_shape_bridge.js?v=39.15.101" defer></script>
|
||||
<script src="js/constraint_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/physics_world_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/physics_projection_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/assets.js?v=39.15.101" defer></script>
|
||||
<script src="js/audio.js?v=39.15.101" defer></script>
|
||||
<script src="js/perf_profiler.js?v=39.15.101" defer></script>
|
||||
<script src="js/render.js?v=39.15.101" defer></script>
|
||||
<script src="js/sim_core.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_seed_factory.js?v=39.15.101" defer></script>
|
||||
<script src="js/structures.js?v=39.15.101" defer></script>
|
||||
<script src="js/items.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_type_initializers.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_support.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_update_policy.js?v=39.15.101" defer></script>
|
||||
<script src="js/fire_runtime_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/robot_cleaner_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_tool_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_ball_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_duplicator_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_pin_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_zunchi_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_dynamic_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_decay_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_growth_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_step_frame.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_step_decay.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_step_growth.js?v=39.15.101" defer></script>
|
||||
<script src="js/update_step_pipeline_runner.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_lifecycle_pipeline.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_runtime.js?v=39.15.101" defer></script>
|
||||
<script src="js/structure_lifecycle.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_render_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_render_runtime.js?v=39.15.101" defer></script>
|
||||
<script src="js/burn_motion_util.js?v=39.15.101" defer></script>
|
||||
<script src="js/ants.js?v=39.15.101" defer></script>
|
||||
<script src="js/health.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_action_spec.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_behavior_state.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_identity_social.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_action_state.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_disease_nest.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_item_effects.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_needs_core.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_behavior_text.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_forced_behavior.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_nest_sleep_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_item_targeting.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_consumable_behavior.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_social_action_runtime.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_building_behavior.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_action_definitions.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_needs_items.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_need_planner_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_item_interaction_context.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_food_interaction_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_contact_item_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_item_interaction_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_sunbath_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_cursor_care_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_local_environment_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_social_move_life.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_step_frame.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_step_ai.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_step_environment.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_step_movement.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_step_health.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_pipeline.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_runtime.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_render.js?v=39.15.101" defer></script>
|
||||
<script src="js/world.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_view.js?v=39.15.101" defer></script>
|
||||
<script src="js/family_graph.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_reset_presets.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_family_social.js?v=39.15.101" defer></script>
|
||||
<script src="js/impact_core_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/impact_response_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/collision_response_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_combat_effects.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_environment.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_temperature_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_pathfinding_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_grass_placement_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_spatial_budget.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_ants_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/weather_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/item_update_scheduler.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_runtime_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/tarinai_update_policy.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_environment_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_item_ant_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_effects_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_creature_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_maintenance_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_ambient_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation_systems.js?v=39.15.101" defer></script>
|
||||
<script src="js/system_order.js?v=39.15.101" defer></script>
|
||||
<script src="js/simulation.js?v=39.15.101" defer></script>
|
||||
<script src="js/colony_situation_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_update.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_tool_actions.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_placement_log.js?v=39.15.101" defer></script>
|
||||
<script src="js/world_event_effects.js?v=39.15.101" defer></script>
|
||||
<script src="js/command_dispatcher.js?v=39.15.101" defer></script>
|
||||
<script src="js/text_catalog.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_log.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_helpers.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_selected.js?v=39.15.101" defer></script>
|
||||
<script src="js/save_schema.js?v=39.15.101" defer></script>
|
||||
<script src="js/snapshot_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/restore_coordinator.js?v=39.15.101" defer></script>
|
||||
<script src="js/history_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/save_codec.js?v=39.15.101" defer></script>
|
||||
<script src="js/save_storage.js?v=39.15.101" defer></script>
|
||||
<script src="js/save_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_tooltips.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_layout_dialogs.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_ground.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_tools.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_input_shared.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_pointer_action_system.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_input_touch.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_input_mouse.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_bind.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_charts.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_family_data.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_family_async.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_family_layout.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_family_paths.js?v=39.15.101" defer></script>
|
||||
<script src="js/ui_family_render.js?v=39.15.101" defer></script>
|
||||
<script src="js/main.js?v=39.15.101" defer></script>
|
||||
<script src="js/debug_tools.js?v=39.15.101" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
37
js/ants.js
37
js/ants.js
|
|
@ -23,6 +23,8 @@ function buildAntWorkerPool(count = ANT_NEST_START_COUNT) {
|
|||
return Array.from({ length: n }, () => ANT_WORKER_HP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sanitizeAntWorkerPool(list, fallbackCount = 0) {
|
||||
if (!Array.isArray(list)) return buildAntWorkerPool(fallbackCount);
|
||||
const out = list.map(v => clampAntHp(v)).filter(v => v > 0.01);
|
||||
|
|
@ -127,13 +129,16 @@ class AntActor {
|
|||
this.extinguishFire("natural");
|
||||
return true;
|
||||
}
|
||||
this.burnTurnTimer = Math.max(0, Number(this.burnTurnTimer || 0) - dt);
|
||||
if (this.burnTurnTimer <= 0) {
|
||||
this.burnWanderAngle = deterministicAngle(this.world, "ant-burn-angle", this, Math.floor((this.world?.time || 0) * 6));
|
||||
this.burnTurnTimer = deterministicRange(this.world, "ant-burn-turn", 0.08, 0.28, this);
|
||||
} else {
|
||||
this.burnWanderAngle += deterministicRange(this.world, "ant-burn-jitter", -9.0, 9.0, this) * dt;
|
||||
}
|
||||
globalThis.TarinaiBurnMotionUtil.updateBurnWanderAngle(this, dt, {
|
||||
angleKey: "ant-burn-angle",
|
||||
turnKey: "ant-burn-turn",
|
||||
jitterKey: "ant-burn-jitter",
|
||||
bucketScale: 6,
|
||||
turnMin: 0.08,
|
||||
turnMax: 0.28,
|
||||
jitterMin: -9.0,
|
||||
jitterMax: 9.0,
|
||||
});
|
||||
const speed = this.kind === "queen" ? ANT_QUEEN_SPEED * 3.0 : ANT_WORKER_RETURN_SPEED * 3.3;
|
||||
this.vx = Math.cos(this.burnWanderAngle || 0) * speed;
|
||||
this.vy = Math.sin(this.burnWanderAngle || 0) * speed;
|
||||
|
|
@ -153,7 +158,7 @@ class AntActor {
|
|||
if (distXY(this.x, this.y, it.x, it.y) <= contactRadius + Math.max(it.r || 16, 12)) globalThis.TarinaiItemDynamicToolSystem?.igniteGrassByFire?.(it, this, this.world, dt);
|
||||
}
|
||||
if (deterministicChance(this.world, "ant-fire-effect", dt * 2.0, this)) {
|
||||
this.world?.effects?.push(new Effect("fight", this.x, this.y - this.r, { vx: rand(-6, 6), vy: rand(-22, -8), size: Math.max(5, this.r * 1.8), life: 0.28, color: "rgba(255,118,35,0.72)" }));
|
||||
this.world?.effects?.push(new Effect("flame", this.x, this.y - this.r, { vx: rand(-2, 2), vy: rand(-14, -6), size: Math.max(4, this.r * 1.0), life: 0.22, color: "rgba(236,46,24,0.72)" }));
|
||||
}
|
||||
if (this.burnTimer <= 0.01) this.extinguishFire("timeout");
|
||||
return true;
|
||||
|
|
@ -413,7 +418,6 @@ class AntActor {
|
|||
this.world?.emit?.("ant:died", { ant: this, home, reason: "hp" });
|
||||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
corpse.workerSprite = this.workerSpriteId();
|
||||
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);
|
||||
|
|
@ -523,15 +527,16 @@ class AntActor {
|
|||
ctx.fill();
|
||||
}
|
||||
if (this.burning || (this.burnTimer || 0) > 0.02) {
|
||||
ctx.fillStyle = "rgba(255,112,34,0.82)";
|
||||
ctx.strokeStyle = "rgba(255,226,92,0.70)";
|
||||
ctx.lineWidth = Math.max(0.8, this.r * 0.16);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = this.seed + this.age * 8 + i * 2.1;
|
||||
const drawFire = typeof drawSimpleFireShape === "function" ? drawSimpleFireShape : null;
|
||||
if (drawFire) {
|
||||
const phase = this.seed + this.age * 8.0;
|
||||
drawFire(ctx, -this.r * 0.10, -this.r * 0.86, Math.max(5, this.r * 1.05), { phase, alpha: 0.86, shadow: false });
|
||||
drawFire(ctx, this.r * 0.54, -this.r * 0.48, Math.max(4, this.r * 0.78), { phase: phase + 1.5, alpha: 0.72, shadow: false });
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(236,46,24,0.78)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(Math.cos(a) * this.r * 0.34, -this.r * (0.8 + i * 0.32), this.r * (0.45 - i * 0.05), this.r * (0.75 - i * 0.08), a * 0.25, 0, Math.PI * 2);
|
||||
ctx.ellipse(0, -this.r * 0.8, this.r * 0.42, this.r * 0.86, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
|
|
|||
20
js/burn_motion_util.js
Normal file
20
js/burn_motion_util.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: shared-runtime/motion
|
||||
// Shared deterministic panic wander used by burning creatures.
|
||||
(function (global) {
|
||||
function updateBurnWanderAngle(entity, dt, opts = {}) {
|
||||
const worldRef = entity?.world || null;
|
||||
entity.burnTurnTimer = Math.max(0, Number(entity.burnTurnTimer || 0) - dt);
|
||||
if (entity.burnTurnTimer <= 0) {
|
||||
const bucket = Math.floor((worldRef?.time || 0) * (opts.bucketScale || 5));
|
||||
entity.burnWanderAngle = deterministicAngle(worldRef, opts.angleKey || "burn-angle", entity, bucket);
|
||||
entity.burnTurnTimer = deterministicRange(worldRef, opts.turnKey || "burn-turn", opts.turnMin ?? 0.10, opts.turnMax ?? 0.34, entity);
|
||||
} else {
|
||||
entity.burnWanderAngle += deterministicRange(worldRef, opts.jitterKey || "burn-jitter", opts.jitterMin ?? -8.0, opts.jitterMax ?? 8.0, entity) * dt;
|
||||
}
|
||||
return entity.burnWanderAngle || 0;
|
||||
}
|
||||
|
||||
global.TarinaiBurnMotionUtil = Object.freeze({ updateBurnWanderAngle });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -5,129 +5,18 @@
|
|||
// and item footprint checks. This keeps placement, grabbing, and physics using
|
||||
// the same current-frame shape instead of separate radius approximations.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function rectAabb(rect) {
|
||||
if (!rect) return null;
|
||||
if (!rect.oriented) return rect;
|
||||
return {
|
||||
left: num(rect.left), right: num(rect.right), top: num(rect.top), bottom: num(rect.bottom),
|
||||
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH),
|
||||
angle: num(rect.angle), cos: Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle)),
|
||||
sin: Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle)), oriented: true,
|
||||
type: rect.type, item: rect.item,
|
||||
};
|
||||
}
|
||||
|
||||
function rectLocalPoint(rect, x, y) {
|
||||
const dx = num(x) - num(rect?.cx);
|
||||
const dy = num(y) - num(rect?.cy);
|
||||
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
|
||||
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
|
||||
return { x: dx * c + dy * s, y: -dx * s + dy * c };
|
||||
}
|
||||
|
||||
function rectWorldVector(rect, x, y) {
|
||||
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
|
||||
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
|
||||
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
|
||||
}
|
||||
|
||||
const Geometry = global.TarinaiGeometry;
|
||||
const num = Geometry.num;
|
||||
const rectLocalPoint = Geometry.rectLocalPoint;
|
||||
const rectWorldVector = Geometry.rectWorldVector;
|
||||
const rectCorners = Geometry.rectCorners;
|
||||
const rectAxes = Geometry.rectAxes;
|
||||
const projectPoints = Geometry.projectPoints;
|
||||
const aabbOverlap = Geometry.aabbOverlap;
|
||||
const rectOverlapInfo = Geometry.rectOverlapInfo;
|
||||
const rectsOverlap = Geometry.rectsOverlap;
|
||||
function pointInRect(x, y, rect, padding = 0) {
|
||||
if (!rect) return false;
|
||||
const pad = Math.max(0, num(padding));
|
||||
if (rect.oriented) {
|
||||
const local = rectLocalPoint(rect, x, y);
|
||||
return local.x >= -num(rect.halfW) - pad && local.x <= num(rect.halfW) + pad
|
||||
&& local.y >= -num(rect.halfH) - pad && local.y <= num(rect.halfH) + pad;
|
||||
}
|
||||
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
|
||||
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
|
||||
}
|
||||
|
||||
function rectCorners(rect, padding = 0) {
|
||||
const r = rectAabb(rect);
|
||||
const pad = Math.max(0, num(padding));
|
||||
if (!r) return [];
|
||||
if (!r.oriented) {
|
||||
return [
|
||||
{ x: num(r.left) - pad, y: num(r.top) - pad },
|
||||
{ x: num(r.right) + pad, y: num(r.top) - pad },
|
||||
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
|
||||
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
|
||||
];
|
||||
}
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
|
||||
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
|
||||
const hw = num(r.halfW) + pad;
|
||||
const hh = num(r.halfH) + pad;
|
||||
const out = [];
|
||||
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
|
||||
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rectAxes(rect) {
|
||||
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
|
||||
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
|
||||
return [{ x: c, y: s }, { x: -s, y: c }];
|
||||
}
|
||||
|
||||
function projectPoints(points, axis) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points || []) {
|
||||
const v = num(p.x) * axis.x + num(p.y) * axis.y;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function aabbOverlap(a, b, padding = 0) {
|
||||
const pad = Math.max(0, num(padding));
|
||||
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
|
||||
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
|
||||
}
|
||||
|
||||
function rectOverlapInfo(a, b, padding = 0) {
|
||||
if (!aabbOverlap(a, b, padding)) return null;
|
||||
const ptsA = rectCorners(a, padding);
|
||||
const ptsB = rectCorners(b, padding);
|
||||
if (!ptsA.length || !ptsB.length) return null;
|
||||
let bestOverlap = Infinity;
|
||||
let bestAxis = null;
|
||||
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
|
||||
const len = Math.hypot(axis.x, axis.y) || 1;
|
||||
const n = { x: axis.x / len, y: axis.y / len };
|
||||
const pa = projectPoints(ptsA, n);
|
||||
const pb = projectPoints(ptsB, n);
|
||||
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
|
||||
if (overlap <= 0) return null;
|
||||
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
|
||||
}
|
||||
if (!bestAxis) return null;
|
||||
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
|
||||
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
|
||||
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
|
||||
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
|
||||
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
|
||||
return {
|
||||
overlap: bestOverlap,
|
||||
nx: bestAxis.x,
|
||||
ny: bestAxis.y,
|
||||
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
|
||||
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function rectsOverlap(a, b, margin = 0) {
|
||||
return Boolean(rectOverlapInfo(a, b, margin));
|
||||
return Geometry.pointInRect(x, y, rect, padding);
|
||||
}
|
||||
|
||||
function rectCircleOverlap(rect, cx, cy, radius, margin = 0) {
|
||||
|
|
@ -240,11 +129,17 @@
|
|||
}
|
||||
|
||||
global.TarinaiCollisionFootprints = Object.freeze({
|
||||
rectCorners,
|
||||
rectAxes,
|
||||
projectPoints,
|
||||
aabbOverlap,
|
||||
rectOverlapInfo,
|
||||
rectLocalPoint,
|
||||
rectWorldVector,
|
||||
pointInRect,
|
||||
rectsOverlap,
|
||||
rectCircleOverlap,
|
||||
isSoftPlacementIgnoredType,
|
||||
itemFootprintRadius,
|
||||
placementRectsFor,
|
||||
hitTestItem,
|
||||
|
|
|
|||
|
|
@ -489,8 +489,7 @@
|
|||
let maxParticles = 24;
|
||||
let iterations = 6;
|
||||
let particleStride = 1;
|
||||
if (tier === "mid") { spacing = 42; maxParticles = 20; iterations = 5; }
|
||||
else if (tier === "low") { spacing = 56; maxParticles = 15; iterations = 4; particleStride = 2; }
|
||||
if (tier === "low") { spacing = 56; maxParticles = 15; iterations = 4; particleStride = 2; }
|
||||
if (ropeCount > 12) { spacing *= 1.18; maxParticles = Math.max(10, maxParticles - 4); iterations = Math.max(3, iterations - 1); }
|
||||
if (ropeCount > 24) { spacing *= 1.28; maxParticles = Math.max(8, maxParticles - 4); iterations = Math.max(3, iterations - 1); particleStride = 2; }
|
||||
const targetCount = Math.max(4, Math.min(maxParticles, Math.ceil(Math.max(24, length) / spacing) + 1));
|
||||
|
|
@ -536,13 +535,11 @@
|
|||
}
|
||||
|
||||
function rectLocalPointForConstraint(r, x, y) {
|
||||
return global.TarinaiCollisionFootprints.rectLocalPoint(r, x, y);
|
||||
return global.TarinaiGeometry.rectLocalPoint(r, x, y);
|
||||
}
|
||||
|
||||
function rectWorldNormalForConstraint(r, nx, ny) {
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
||||
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
||||
return { x: nx * c - ny * s, y: nx * s + ny * c };
|
||||
return global.TarinaiGeometry.rectWorldNormal(r, nx, ny);
|
||||
}
|
||||
|
||||
function pushRopeParticleOutOfRect(p, rect, radius) {
|
||||
|
|
@ -684,7 +681,7 @@
|
|||
const getRopeObstacleRects = () => {
|
||||
if (ropeObstacleRects) return ropeObstacleRects;
|
||||
if (!worldRef?.nearbySolidObstacleRects) return (ropeObstacleRects = []);
|
||||
const maxChecks = lod.tier === "low" ? 18 : (lod.tier === "mid" ? 28 : 38);
|
||||
const maxChecks = lod.tier === "low" ? 18 : 38;
|
||||
ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks }) || [];
|
||||
return ropeObstacleRects;
|
||||
};
|
||||
|
|
@ -717,7 +714,7 @@
|
|||
for (let iter = 0; iter < lod.iterations; iter += 1) {
|
||||
ps[0].x = a.x; ps[0].y = a.y;
|
||||
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
|
||||
const shouldCollide = lod.tier === "low" ? iter === 1 : (lod.tier === "mid" ? iter === 2 : (iter === 2 || iter === lod.iterations - 1));
|
||||
const shouldCollide = lod.tier === "low" ? iter === 1 : (iter === 2 || iter === lod.iterations - 1);
|
||||
if (shouldCollide) changed = collideParticles() || changed;
|
||||
for (let i = 0; i < ps.length - 1; i += 1) {
|
||||
const p1 = ps[i], p2 = ps[i + 1];
|
||||
|
|
@ -760,8 +757,8 @@
|
|||
return changed;
|
||||
}
|
||||
|
||||
function updateFlexibleLink(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "rope") return false;
|
||||
function beginLinkUpdate(item, worldRef, expectedType) {
|
||||
if (!item || item.dead || item.type !== expectedType) return null;
|
||||
item.amount = 999;
|
||||
item.world = worldRef || item.world || null;
|
||||
const a = endpointWorld(ep(item, 0), worldRef);
|
||||
|
|
@ -769,11 +766,26 @@
|
|||
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
|
||||
item.amount = 0;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return true;
|
||||
return { detached: true };
|
||||
}
|
||||
const beforeSpatialX = Number(item.x || 0) || 0;
|
||||
const beforeSpatialY = Number(item.y || 0) || 0;
|
||||
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
|
||||
return { a, b, x: Number(item.x || 0) || 0, y: Number(item.y || 0) || 0, r: Number(item.r || item.radius || 0) || 0 };
|
||||
}
|
||||
|
||||
function finishLinkSpatialUpdate(item, worldRef, before, reason, threshold, minInterval) {
|
||||
if (!worldRef || !before) return false;
|
||||
worldRef.drawListDirty = true;
|
||||
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - before.x, (Number(item.y || 0) || 0) - before.y) > threshold
|
||||
|| Math.abs((Number(item.r || item.radius || 0) || 0) - before.r) > threshold;
|
||||
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval);
|
||||
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
||||
return movedForSpatial;
|
||||
}
|
||||
|
||||
function updateFlexibleLink(item, dt, worldRef) {
|
||||
const before = beginLinkUpdate(item, worldRef, "rope");
|
||||
if (!before) return false;
|
||||
if (before.detached) return true;
|
||||
const { a, b } = before;
|
||||
let length = primeLinkState(item, a, b);
|
||||
let changed = applyDistanceConstraint(item, a, b, dt, length, "rope");
|
||||
const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a;
|
||||
|
|
@ -785,30 +797,15 @@
|
|||
}
|
||||
length = primeLinkState(item, aa, bb);
|
||||
changed = solveRopeParticleChain(item, aa, bb, dt, length, worldRef) || changed;
|
||||
if (changed && worldRef) {
|
||||
worldRef.drawListDirty = true;
|
||||
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.38
|
||||
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.38;
|
||||
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rope-tension", 0.38, 0.085);
|
||||
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
||||
}
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rope-tension", 0.38, 0.085);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateRigidLink(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "rod") return false;
|
||||
item.amount = 999;
|
||||
item.world = worldRef || item.world || null;
|
||||
const a = endpointWorld(ep(item, 0), worldRef);
|
||||
const b = endpointWorld(ep(item, 1), worldRef);
|
||||
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
|
||||
item.amount = 0;
|
||||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return true;
|
||||
}
|
||||
const beforeSpatialX = Number(item.x || 0) || 0;
|
||||
const beforeSpatialY = Number(item.y || 0) || 0;
|
||||
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
|
||||
const before = beginLinkUpdate(item, worldRef, "rod");
|
||||
if (!before) return false;
|
||||
if (before.detached) return true;
|
||||
const { a, b } = before;
|
||||
const length = primeLinkState(item, a, b);
|
||||
let changed = applyDistanceConstraint(item, a, b, dt, length, "rod");
|
||||
const aa = endpointWorld(ep(item, 0), worldRef) || a;
|
||||
|
|
@ -833,13 +830,7 @@
|
|||
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
|
||||
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
|
||||
}
|
||||
if (changed && worldRef) {
|
||||
worldRef.drawListDirty = true;
|
||||
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.30
|
||||
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.30;
|
||||
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rod-constraint", 0.30, 0.070);
|
||||
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
||||
}
|
||||
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rod-constraint", 0.30, 0.070);
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
|
@ -992,11 +983,7 @@
|
|||
}
|
||||
|
||||
function pointSegmentDistance(px, py, ax, ay, bx, by) {
|
||||
const vx = bx - ax;
|
||||
const vy = by - ay;
|
||||
const len2 = vx * vx + vy * vy || 1;
|
||||
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
|
||||
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
|
||||
return global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
|
||||
}
|
||||
|
||||
const api = Object.freeze({
|
||||
|
|
|
|||
|
|
@ -45,5 +45,83 @@
|
|||
return comps;
|
||||
}
|
||||
|
||||
global.TarinaiFamilyGraph = { connectedComponents };
|
||||
|
||||
|
||||
function addParentEdge(childId, parentId, known, parentsByChild, childrenByParent) {
|
||||
if (!childId || !parentId || childId === parentId || !known?.has?.(childId) || !known.has(parentId)) return false;
|
||||
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
|
||||
parentsByChild.get(childId).add(parentId);
|
||||
childrenByParent.get(parentId).add(childId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRelationIndex(family = {}) {
|
||||
const known = new Set(Object.keys(family || {}));
|
||||
const parentsByChild = new Map();
|
||||
const childrenByParent = new Map();
|
||||
const addParent = (childId, parentId) => addParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addParent(n.id, parentId);
|
||||
for (const childId of n.children || []) addParent(childId, n.id);
|
||||
}
|
||||
return { parentsByChild, childrenByParent };
|
||||
}
|
||||
|
||||
function relationAllowsParentChild(family = {}, childId, parentId) {
|
||||
const child = family?.[childId];
|
||||
const parent = family?.[parentId];
|
||||
if (!child || !parent) return false;
|
||||
const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId);
|
||||
const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId);
|
||||
return childListsParent || parentListsChild;
|
||||
}
|
||||
|
||||
function relatedNodesFromIndex(family = {}, relationIndex = buildRelationIndex(family)) {
|
||||
const relatedIds = new Set();
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
const parents = relationIndex.parentsByChild.get(n.id) || new Set();
|
||||
const children = relationIndex.childrenByParent.get(n.id) || new Set();
|
||||
if (parents.size || children.size) relatedIds.add(n.id);
|
||||
for (const id of parents) relatedIds.add(id);
|
||||
for (const id of children) relatedIds.add(id);
|
||||
}
|
||||
return Array.from(relatedIds).map(id => family[id]).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildLineageGraph(family = {}, nodes = relatedNodesFromIndex(family), relationIndex = buildRelationIndex(family)) {
|
||||
const graph = new Map();
|
||||
for (const n of nodes || []) if (n?.id) graph.set(n.id, new Set());
|
||||
const addGraphEdge = (a, b) => {
|
||||
if (!a || !b || !graph.has(a) || !graph.has(b)) return;
|
||||
graph.get(a).add(b);
|
||||
graph.get(b).add(a);
|
||||
};
|
||||
for (const n of nodes || []) {
|
||||
if (!n?.id) continue;
|
||||
const parentCandidates = new Set((n.parents || []).filter(Boolean));
|
||||
for (const parentId of relationIndex.parentsByChild.get(n.id) || []) parentCandidates.add(parentId);
|
||||
for (const parentId of parentCandidates) {
|
||||
if (parentId !== n.id && relationAllowsParentChild(family, n.id, parentId)) addGraphEdge(n.id, parentId);
|
||||
}
|
||||
const childCandidates = new Set((n.children || []).filter(Boolean));
|
||||
for (const childId of relationIndex.childrenByParent.get(n.id) || []) childCandidates.add(childId);
|
||||
for (const childId of childCandidates) {
|
||||
if (childId !== n.id && relationAllowsParentChild(family, childId, n.id)) addGraphEdge(n.id, childId);
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
|
||||
global.TarinaiFamilyGraph = {
|
||||
connectedComponents,
|
||||
addParentEdge,
|
||||
buildRelationIndex,
|
||||
buildLineageGraph,
|
||||
relatedNodesFromIndex,
|
||||
relationAllowsParentChild,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
469
js/fire_runtime_system.js
Normal file
469
js/fire_runtime_system.js
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"use strict";
|
||||
|
||||
// Runtime behavior for fire, firecracker, flame spread, and fire contact effects.
|
||||
(function (global) {
|
||||
|
||||
function updateFirecracker(item, dt, worldRef) {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
if (item.fuseTimer <= 0) {
|
||||
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
|
||||
else item.amount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const FIRE_BURNING_TEXT = "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
|
||||
const FIRE_SEEN_TEXT = "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
|
||||
const FIRE_CONTACT_INTERVAL = 0.35;
|
||||
const FIRE_PANIC_INTERVAL = 1.20;
|
||||
const FIRE_WATER_INTERVAL = 0.16;
|
||||
const FIRE_CLUSTER_SCAN_INTERVAL = 0.45;
|
||||
const FIRE_INTERACTION_BUDGET_DESKTOP = 18;
|
||||
const FIRE_INTERACTION_BUDGET_MOBILE = 10;
|
||||
const FIRE_MAX_ACTIVE_DESKTOP = 72;
|
||||
const FIRE_MAX_ACTIVE_MOBILE = 44;
|
||||
const GRASS_FIRE_EXTINGUISH_RATE = 0.56;
|
||||
|
||||
function fireTempMultiplier(worldRef, x, y, entity = null) {
|
||||
const weather = String(worldRef?.weather || "");
|
||||
const temp = Number(worldRef?.temperatureAt?.(x, y, entity) ?? worldRef?.feltTemperatureFor?.(entity) ?? worldRef?.temperature ?? global.CONFIG?.standardTemperature ?? 15);
|
||||
let mul = 1;
|
||||
if (weather === "light_rain") mul *= 2;
|
||||
if (Number.isFinite(temp) && temp <= 10) mul *= 2;
|
||||
return mul;
|
||||
}
|
||||
|
||||
function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
|
||||
if (!worldRef?.effects || typeof Effect !== "function") return;
|
||||
if ((worldRef.effects.length || 0) > 180) return;
|
||||
const a = global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0);
|
||||
const speed = global.deterministicRange(worldRef, "fire-effect-speed", 3, 15, seed || x, y);
|
||||
worldRef.effects.push(new Effect("flame", x, y, {
|
||||
vx: Math.cos(a) * speed * 0.35,
|
||||
vy: -Math.max(4, speed * 0.65),
|
||||
size: Math.max(4, size * 0.58),
|
||||
life: global.deterministicRange(worldRef, "fire-effect-life", 0.18, 0.34, seed || x, y),
|
||||
color: "rgba(236,46,24,0.78)",
|
||||
}));
|
||||
}
|
||||
|
||||
function fireInteractionBudget(worldRef) {
|
||||
const frame = Math.floor((Number(worldRef?.time || 0) || 0) * 60);
|
||||
if (!worldRef._fireInteractionBudget || worldRef._fireInteractionBudget.frame !== frame) {
|
||||
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
|
||||
worldRef._fireInteractionBudget = {
|
||||
frame,
|
||||
remaining: mobile ? FIRE_INTERACTION_BUDGET_MOBILE : FIRE_INTERACTION_BUDGET_DESKTOP,
|
||||
};
|
||||
}
|
||||
return worldRef._fireInteractionBudget;
|
||||
}
|
||||
|
||||
function useFireInteractionBudget(worldRef, cost = 1) {
|
||||
const budget = fireInteractionBudget(worldRef);
|
||||
if (!budget || budget.remaining < cost) return false;
|
||||
budget.remaining -= cost;
|
||||
return true;
|
||||
}
|
||||
|
||||
function contactWaterDrop(worldRef, x, y, radius = 16) {
|
||||
if (!worldRef) return null;
|
||||
const list = typeof worldRef.nearbyItems === "function" ? [...(worldRef.nearbyItems(x, y, radius + 28, true) || [])] : (worldRef.items || []);
|
||||
for (const it of list) {
|
||||
if (!it || it.dead || it.type !== "water") continue;
|
||||
const rr = Math.max(radius, 10) + Math.max(it.r || 10, 10);
|
||||
if (distXY(x, y, it.x, it.y) <= rr) return it;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function consumeWaterForFire(worldRef, water, amount = 8) {
|
||||
if (!water) return false;
|
||||
water.amount = Math.max(0, Number(water.amount || 0) - amount);
|
||||
worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }));
|
||||
worldRef?.markItemBucketsDirty?.("fire-water");
|
||||
worldRef?.markSpatialDirty?.("fire-water");
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
|
||||
if (!t || t.dead) return false;
|
||||
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
|
||||
t.burning = true;
|
||||
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
||||
t.thought = FIRE_BURNING_TEXT;
|
||||
t.lastPanicCause = "burning";
|
||||
t.lastPanicDetail = FIRE_BURNING_TEXT;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
||||
t.sleeping = false;
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
|
||||
worldRef?.emit?.("fire:ignite", { tarinai: t, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
function panicTarinaiFromFire(t, source = null, worldRef = null) {
|
||||
if (!t || t.dead || t.burning || (t.burnTimer || 0) > 0.02) return false;
|
||||
const now = Number(worldRef?.time || t.world?.time || 0) || 0;
|
||||
if (now < Number(t.firePanicCooldownUntil || 0)) return false;
|
||||
t.firePanicCooldownUntil = now + 0.85;
|
||||
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
|
||||
t.lastPanicCause = "fire_seen";
|
||||
t.lastPanicDetail = FIRE_SEEN_TEXT;
|
||||
t.thought = FIRE_SEEN_TEXT;
|
||||
if (typeof t.enterPanic === "function") t.enterPanic({ threat: source, target: source, reason: FIRE_SEEN_TEXT, fear: 1.25, wake: true, cause: "fire_seen", surpriseTimer: 0.55, awakeLockTimer: 0.35 });
|
||||
else { t.fearTimer = Math.max(t.fearTimer || 0, 1.25); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_SEEN_TEXT, wake: true }); }
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteAntByFire(ant, source = null, worldRef = null) {
|
||||
if (!ant || ant.dead) return false;
|
||||
if (typeof ant.igniteFire === "function") return ant.igniteFire(source);
|
||||
ant.burning = true;
|
||||
ant.burnTimer = Math.max(ant.burnTimer || 0, 5.0);
|
||||
ant.state = "panic";
|
||||
ant.targetId = "";
|
||||
ant.targetRef = null;
|
||||
worldRef?.emit?.("fire:ignite-ant", { ant, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function grassFireExtinguishChance(worldRef, grass, dt = 0.016) {
|
||||
const baseDt = Math.max(Number(dt || 0) || 0, FIRE_CONTACT_INTERVAL);
|
||||
const weather = String(worldRef?.weather || "");
|
||||
const fresh = clamp(Number(grass?.growth ?? grass?.health ?? 0.65) || 0, 0, 1);
|
||||
const rainMul = weather === "light_rain" ? 1.75 : 1;
|
||||
return clamp(baseDt * GRASS_FIRE_EXTINGUISH_RATE * rainMul * (1 + fresh * 0.45), 0, 0.58);
|
||||
}
|
||||
|
||||
function tryGrassExtinguishFire(grass, fire, worldRef = null, dt = 0.016) {
|
||||
if (!grass || grass.dead || grass.type !== "grass" || !fire || fire.dead || fire.type !== "fire") return false;
|
||||
const chance = grassFireExtinguishChance(worldRef, grass, dt);
|
||||
if (!global.deterministicChance(worldRef, "grass-fire-extinguish", chance, grass, fire)) return false;
|
||||
const reduction = Math.max(18, Math.max(grass.r || 16, 12) * 0.72 + Math.max(fire.r || 14, 12) * 0.52);
|
||||
fire.amount = Math.max(0, Number(fire.amount || 0) - reduction);
|
||||
fire.flameLife = Math.max(0, Number(fire.flameLife || 0) - 0.85);
|
||||
grass.health = clamp((grass.health ?? 1) - 0.035, 0, 1);
|
||||
if ((fire.amount || 0) <= 24 || (fire.flameLife || 0) <= 0.35) fire.amount = 0;
|
||||
worldRef?.effects?.push(new Effect("ring", fire.x, fire.y, { size: Math.max(18, (fire.r || 14) * 1.8), life: 0.18, color: "rgba(118,188,94,0.46)" }));
|
||||
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 28), "grass-extinguish-fire");
|
||||
worldRef?.markItemBucketsDirty?.("grass-extinguish-fire");
|
||||
worldRef?.markSpatialDirty?.("grass-extinguish-fire");
|
||||
worldRef.drawListDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteGrassByFire(grass, source = null, worldRef = null, dt = 0.016) {
|
||||
if (!grass || grass.dead || grass.type !== "grass") return false;
|
||||
grass.burning = true;
|
||||
grass.burnTimer = Math.max(grass.burnTimer || 0, 2.8);
|
||||
grass.health = clamp((grass.health ?? 1) - dt * 0.30, 0, 1);
|
||||
grass.amount = Math.max(0, Number(grass.amount || 0) - dt * 18);
|
||||
if (grass.health <= 0.02 || grass.amount <= 0.5) grass.amount = 0;
|
||||
if (worldRef && (worldRef.time || 0) > Number(grass.nextFireSpreadAt || -Infinity)) {
|
||||
grass.nextFireSpreadAt = (worldRef.time || 0) + 1.6;
|
||||
if (typeof worldRef.spawnFireAt === "function") {
|
||||
const a = global.deterministicAngle(worldRef, "grass-fire-spread", grass, source);
|
||||
worldRef.spawnFireAt(grass.x + Math.cos(a) * (grass.r || 14) * 0.55, grass.y + Math.sin(a) * (grass.r || 14) * 0.35, { amount: 60, radius: 12, life: 4.2, source: grass });
|
||||
}
|
||||
}
|
||||
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 26), "grass-fire");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function igniteZunchiByFire(zunchi, source = null, worldRef = null, dt = 0.016) {
|
||||
if (!zunchi || zunchi.dead || zunchi.type !== "zunchi") return false;
|
||||
const wasBurning = Boolean(zunchi.burning || (zunchi.burnTimer || 0) > 0.02);
|
||||
zunchi.burning = true;
|
||||
zunchi.burnTimer = Math.max(Number(zunchi.burnTimer || 0) || 0, 4.6);
|
||||
zunchi.spawnGrace = 0;
|
||||
zunchi.freshness = clamp((zunchi.freshness ?? 1) - dt * 0.55, 0, 1);
|
||||
zunchi.amount = Math.max(0, Number(zunchi.amount || 0) - dt * 34);
|
||||
zunchi.stageTimer = Math.max(0, Number(zunchi.stageTimer || 0) + dt * 2.4);
|
||||
if (!wasBurning) {
|
||||
pushFireEffect(worldRef, zunchi.x, zunchi.y - Math.max(5, (zunchi.r || 14) * 0.2), Math.max(10, zunchi.r || 14), zunchi);
|
||||
worldRef?.emit?.("fire:ignite-zunchi", { item: zunchi, source });
|
||||
}
|
||||
if ((zunchi.amount || 0) <= 0.5) zunchi.amount = 0;
|
||||
worldRef?.markTerrainDirtyAt?.(zunchi.x, zunchi.y, Math.max(zunchi.r || 15, 28), "zunchi-fire");
|
||||
worldRef?.markItemBucketsDirty?.("zunchi-fire");
|
||||
worldRef?.markSpatialDirty?.("zunchi-fire");
|
||||
worldRef.drawListDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFireContacts(item, dt, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const r = Math.max(14, Number(item.r || 0) || 14);
|
||||
const spreadRadius = r + 16;
|
||||
let changed = false;
|
||||
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, spreadRadius + 32, true) || []) {
|
||||
if (!t || t.dead) continue;
|
||||
const d = distXY(item.x, item.y, t.x, t.y);
|
||||
if (d <= spreadRadius + Math.max(t.radius || 18, 12) * 0.72) changed = igniteTarinaiByFire(t, item, worldRef, false) || changed;
|
||||
}
|
||||
for (const ant of worldRef.nearbyAnts?.(item.x, item.y, spreadRadius + 20, true) || []) {
|
||||
if (!ant || ant.dead) continue;
|
||||
const d = distXY(item.x, item.y, ant.x, ant.y);
|
||||
if (d <= spreadRadius + Math.max(ant.r || 5, 4)) changed = igniteAntByFire(ant, item, worldRef) || changed;
|
||||
}
|
||||
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(item.x, item.y, spreadRadius + 38, true) || []) : (worldRef.items || []);
|
||||
for (const it of nearby) {
|
||||
if (!it || it.dead || it === item) continue;
|
||||
if (it.type === "grass" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 16, 12)) {
|
||||
changed = tryGrassExtinguishFire(it, item, worldRef, dt) || changed;
|
||||
if ((item.amount || 0) > 0) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
|
||||
}
|
||||
if (it.type === "zunchi" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 15, 12)) changed = igniteZunchiByFire(it, item, worldRef, dt) || changed;
|
||||
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
|
||||
consumeWaterForFire(worldRef, it, Math.max(6, r * 0.45));
|
||||
item.amount = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function panicFromNearbyFire(item, worldRef) {
|
||||
if (!item || !worldRef) return;
|
||||
const r = Math.max(170, (item.r || 14) + 170);
|
||||
for (const t of worldRef.nearbyTarinai?.(item.x, item.y, r, true) || []) {
|
||||
if (!t || t.dead) continue;
|
||||
panicTarinaiFromFire(t, item, worldRef);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFire(item, dt, worldRef) {
|
||||
if (!item || item.dead || !worldRef) return false;
|
||||
dt = Math.max(0, Math.min(0.08, Number(dt || 0) || 0));
|
||||
item.amount = Math.max(0, Number(item.amount || 0) - dt * 4.2);
|
||||
item.flameLife = Math.max(0, Number(item.flameLife ?? 8.0) - dt);
|
||||
item.flameSpreadCooldown = Math.max(0, Number(item.flameSpreadCooldown || 0) - dt);
|
||||
item.flamePanicCooldown = Math.max(0, Number(item.flamePanicCooldown || 0) - dt);
|
||||
item.flameWaterCooldown = Math.max(0, Number(item.flameWaterCooldown || 0) - dt);
|
||||
item.flameClusterCooldown = Math.max(0, Number(item.flameClusterCooldown || 0) - dt);
|
||||
item.vx = 0;
|
||||
item.vy = 0;
|
||||
const lowFire = (item.amount || 0) < 24 || item.flameLife < 1.2;
|
||||
if (item.flameWaterCooldown <= 0) {
|
||||
item.flameWaterCooldown = FIRE_WATER_INTERVAL;
|
||||
const water = contactWaterDrop(worldRef, item.x, item.y, Math.max(item.r || 14, 12));
|
||||
if (water) {
|
||||
consumeWaterForFire(worldRef, water, Math.max(8, (item.r || 14) * 0.6));
|
||||
item.amount = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const chance = dt * 0.012 * fireTempMultiplier(worldRef, item.x, item.y, item);
|
||||
if (global.deterministicChance(worldRef, "fire-random-extinguish", chance, item)) {
|
||||
item.amount = 0;
|
||||
return true;
|
||||
}
|
||||
if (item.flameLife <= 0) item.amount = Math.min(item.amount, 1);
|
||||
if (item.flameClusterCooldown <= 0) {
|
||||
mergeNearbyFires(worldRef, item, Math.max(24, (item.r || 14) * 2.4), 4);
|
||||
item.flameClusterCooldown = FIRE_CLUSTER_SCAN_INTERVAL;
|
||||
}
|
||||
if (!lowFire && item.flameSpreadCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
|
||||
updateFireContacts(item, dt, worldRef);
|
||||
item.flameSpreadCooldown = FIRE_CONTACT_INTERVAL;
|
||||
}
|
||||
if (!lowFire && item.flamePanicCooldown <= 0 && useFireInteractionBudget(worldRef, 1)) {
|
||||
panicFromNearbyFire(item, worldRef);
|
||||
item.flamePanicCooldown = FIRE_PANIC_INTERVAL;
|
||||
}
|
||||
if (!lowFire && global.deterministicChance(worldRef, "fire-effect", dt * 0.72, item)) pushFireEffect(worldRef, item.x, item.y - (item.r || 14) * 0.2, Math.max(8, (item.r || 14) * 0.9), item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeNearbyFires(worldRef, fire, radius = 36, maxMerges = 4) {
|
||||
if (!worldRef || !fire || fire.dead) return 0;
|
||||
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(fire.x, fire.y, radius, true) || []) : (worldRef.items || []);
|
||||
let merged = 0;
|
||||
for (const other of nearby) {
|
||||
if (!other || other === fire || other.dead || other.type !== "fire") continue;
|
||||
if (distXY(fire.x, fire.y, other.x, other.y) > radius + Math.max(other.r || 14, 12)) continue;
|
||||
fire.amount = Math.min(260, Math.max(fire.amount || 0, other.amount || 0) + Math.min(34, Math.max(0, Number(other.amount || 0) * 0.35)));
|
||||
fire.flameLife = Math.max(fire.flameLife || 0, other.flameLife || 0);
|
||||
fire.r = Math.min(42, Math.max(fire.r || 14, other.r || 14));
|
||||
other.amount = 0;
|
||||
merged += 1;
|
||||
if (merged >= maxMerges) break;
|
||||
}
|
||||
if (merged) {
|
||||
worldRef.markItemBucketsDirty?.("fire-merge");
|
||||
worldRef.markSpatialDirty?.("fire-merge");
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function activeFireLimit() {
|
||||
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
|
||||
return mobile ? FIRE_MAX_ACTIVE_MOBILE : FIRE_MAX_ACTIVE_DESKTOP;
|
||||
}
|
||||
|
||||
function activeFires(worldRef) {
|
||||
return worldRef?.itemsOfType?.("fire") || (worldRef?.items || []).filter(it => it && !it.dead && it.type === "fire");
|
||||
}
|
||||
|
||||
function foldIntoExistingFire(worldRef, x, y, opts = {}) {
|
||||
const fires = activeFires(worldRef);
|
||||
if (!fires.length) return null;
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
const exclude = opts.exclude || null;
|
||||
for (const fire of fires) {
|
||||
if (!fire || fire.dead) continue;
|
||||
if (exclude && fire === exclude) continue;
|
||||
const d = distXY(x, y, fire.x, fire.y);
|
||||
if (d < bestD) { best = fire; bestD = d; }
|
||||
}
|
||||
if (!best) return null;
|
||||
best.amount = Math.min(280, Math.max(Number(best.amount || 0), Number(opts.amount || 0) * 0.45) + 14);
|
||||
best.flameLife = Math.max(Number(best.flameLife || 0), Math.max(1.0, Number(opts.life || 3.5) * 0.75));
|
||||
best.r = Math.min(44, Math.max(best.r || 14, Number(opts.radius || 14)));
|
||||
best.vx = 0;
|
||||
best.vy = 0;
|
||||
worldRef.drawListDirty = true;
|
||||
return best;
|
||||
}
|
||||
|
||||
function compactWorldFires(worldRef, opts = {}) {
|
||||
const fires = activeFires(worldRef).filter(fire => fire && !fire.dead);
|
||||
const limit = Math.max(8, Number(opts.limit || activeFireLimit()) || activeFireLimit());
|
||||
if (fires.length <= limit) return 0;
|
||||
const cellSize = Math.max(46, Number(opts.cellSize || 72) || 72);
|
||||
const cells = new Map();
|
||||
for (const fire of fires) {
|
||||
const key = `${Math.floor((fire.x || 0) / cellSize)}:${Math.floor((fire.y || 0) / cellSize)}`;
|
||||
let group = cells.get(key);
|
||||
if (!group) {
|
||||
group = [];
|
||||
cells.set(key, group);
|
||||
}
|
||||
group.push(fire);
|
||||
}
|
||||
let removed = 0;
|
||||
for (const group of cells.values()) {
|
||||
if (group.length <= 1) continue;
|
||||
group.sort((a, b) => ((b.amount || 0) + (b.flameLife || 0) * 8 + (b.r || 0)) - ((a.amount || 0) + (a.flameLife || 0) * 8 + (a.r || 0)));
|
||||
const keep = group[0];
|
||||
for (let i = 1; i < group.length; i += 1) {
|
||||
const fire = group[i];
|
||||
keep.amount = Math.min(300, Math.max(keep.amount || 0, fire.amount || 0) + Math.min(18, Math.max(0, Number(fire.amount || 0) * 0.20)));
|
||||
keep.flameLife = Math.max(keep.flameLife || 0, fire.flameLife || 0);
|
||||
keep.r = Math.min(46, Math.max(keep.r || 14, fire.r || 14));
|
||||
fire.amount = 0;
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if (fires.length - removed > limit) {
|
||||
const survivors = fires.filter(fire => !fire.dead).sort((a, b) => ((a.amount || 0) + (a.flameLife || 0) * 8) - ((b.amount || 0) + (b.flameLife || 0) * 8));
|
||||
let remaining = survivors.length;
|
||||
for (const fire of survivors) {
|
||||
if (remaining <= limit) break;
|
||||
if (fire.amount <= 0) continue;
|
||||
foldIntoExistingFire(worldRef, fire.x, fire.y, { ...fire, exclude: fire });
|
||||
fire.amount = 0;
|
||||
removed += 1;
|
||||
remaining -= 1;
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
worldRef.compactItems?.();
|
||||
worldRef.markItemBucketsDirty?.("fire-compact");
|
||||
worldRef.markSpatialDirty?.("fire-compact");
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function spawnFireAt(worldRef, x, y, opts = {}) {
|
||||
if (!worldRef || typeof Item !== "function") return null;
|
||||
const currentFires = activeFires(worldRef);
|
||||
if (currentFires.length >= activeFireLimit()) return foldIntoExistingFire(worldRef, x, y, opts);
|
||||
const mergeRadius = Math.max(70, Number(opts.mergeRadius || opts.radius || 16) * 4.5);
|
||||
const nearby = typeof worldRef.nearbyItems === "function" ? (worldRef.nearbyItems(x, y, mergeRadius, true) || []) : (worldRef.items || []);
|
||||
for (const existing of nearby) {
|
||||
if (!existing || existing.dead || existing.type !== "fire") continue;
|
||||
if (distXY(x, y, existing.x, existing.y) > mergeRadius + Math.max(existing.r || 14, 12)) continue;
|
||||
existing.amount = Math.min(260, Math.max(Number(existing.amount || 0), Number(opts.amount || 0) * 0.55) + 18);
|
||||
existing.flameLife = Math.max(Number(existing.flameLife || 0), Math.max(1.2, Number(opts.life || 4.0) * 0.85));
|
||||
existing.r = Math.min(42, Math.max(existing.r || 14, Number(opts.radius || 14)));
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
worldRef.drawListDirty = true;
|
||||
return existing;
|
||||
}
|
||||
const fire = new Item("fire", x, y);
|
||||
const scale = Math.max(0.45, Math.min(2.0, Number(opts.scale || 1) || 1));
|
||||
fire.r = Math.max(9, Number(opts.radius || fire.r || 14) * scale);
|
||||
fire.amount = Math.max(12, Number(opts.amount || fire.amount || 100) * scale);
|
||||
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
|
||||
fire.vx = 0;
|
||||
fire.vy = 0;
|
||||
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
|
||||
worldRef.drawListDirty = true;
|
||||
return fire;
|
||||
}
|
||||
|
||||
function scatterFireFrom(item, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const existing = activeFires(worldRef).length;
|
||||
const limit = activeFireLimit();
|
||||
const room = Math.max(0, limit - existing);
|
||||
const desired = Math.max(5, Math.round(8 * Math.max(0.75, Math.min(1.4, (item.r || 15) / 15))));
|
||||
const count = Math.max(3, Math.min(desired, room > 0 ? room : 3));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = (Math.PI * 2 * i / count) + global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i);
|
||||
const d = global.deterministicRange(worldRef, "flame-cracker-radius", 28, 118, item, i);
|
||||
const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, {
|
||||
radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 10, 18, item, i),
|
||||
amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i),
|
||||
life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, item, i),
|
||||
source: item,
|
||||
});
|
||||
if (fire) {
|
||||
fire.vx = 0;
|
||||
fire.vy = 0;
|
||||
}
|
||||
pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item);
|
||||
}
|
||||
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * 3.0), life: 0.36, color: "rgba(255,126,54,0.58)" }));
|
||||
item.amount = 0;
|
||||
worldRef.markItemBucketsDirty?.("flame-firecracker");
|
||||
worldRef.markSpatialDirty?.("flame-firecracker");
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFlameFirecracker(item, dt, worldRef) {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
if (item.fuseTimer <= 0) {
|
||||
if (worldRef) scatterFireFrom(item, worldRef);
|
||||
else item.amount = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiFireRuntimeSystem = Object.freeze({
|
||||
updateFirecracker,
|
||||
updateFlameFirecracker,
|
||||
updateFire,
|
||||
fireBurningText: FIRE_BURNING_TEXT,
|
||||
fireSeenText: FIRE_SEEN_TEXT,
|
||||
fireTempMultiplier,
|
||||
contactWaterDrop,
|
||||
consumeWaterForFire,
|
||||
igniteTarinaiByFire,
|
||||
panicTarinaiFromFire,
|
||||
igniteAntByFire,
|
||||
igniteGrassByFire,
|
||||
igniteZunchiByFire,
|
||||
tryGrassExtinguishFire,
|
||||
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
|
||||
scatterFireFrom,
|
||||
compactWorldFires,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
201
js/geometry_helpers.js
Normal file
201
js/geometry_helpers.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: math/geometry
|
||||
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
|
||||
// editor hit-testing, and world obstacle queries.
|
||||
(function (global) {
|
||||
function num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function clampValue(v, min, max) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
||||
const c = Math.cos(num(angle));
|
||||
const s = Math.sin(num(angle));
|
||||
const ex = Math.abs(c) * num(halfW) + Math.abs(s) * num(halfH);
|
||||
const ey = Math.abs(s) * num(halfW) + Math.abs(c) * num(halfH);
|
||||
return { left: num(cx) - ex, right: num(cx) + ex, top: num(cy) - ey, bottom: num(cy) + ey, cos: c, sin: s };
|
||||
}
|
||||
|
||||
function rectAabb(rect) {
|
||||
if (!rect) return null;
|
||||
if (!rect.oriented) return rect;
|
||||
const aabb = orientedRectAabb(rect.cx, rect.cy, rect.halfW, rect.halfH, rect.angle);
|
||||
return {
|
||||
left: num(rect.left, aabb.left), right: num(rect.right, aabb.right), top: num(rect.top, aabb.top), bottom: num(rect.bottom, aabb.bottom),
|
||||
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH), angle: num(rect.angle),
|
||||
cos: Number.isFinite(rect.cos) ? rect.cos : aabb.cos,
|
||||
sin: Number.isFinite(rect.sin) ? rect.sin : aabb.sin,
|
||||
oriented: true, type: rect.type, item: rect.item,
|
||||
};
|
||||
}
|
||||
|
||||
function rectLocalPoint(rect, x, y) {
|
||||
const dx = num(x) - num(rect?.cx);
|
||||
const dy = num(y) - num(rect?.cy);
|
||||
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
|
||||
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
|
||||
return { x: dx * c + dy * s, y: -dx * s + dy * c };
|
||||
}
|
||||
|
||||
function rectWorldVector(rect, x, y) {
|
||||
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
|
||||
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
|
||||
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
|
||||
}
|
||||
|
||||
function rectWorldNormal(rect, nx, ny) {
|
||||
return rectWorldVector(rect, nx, ny);
|
||||
}
|
||||
|
||||
function rectCorners(rect, padding = 0) {
|
||||
const r = rectAabb(rect);
|
||||
const pad = Math.max(0, num(padding));
|
||||
if (!r) return [];
|
||||
if (!r.oriented) {
|
||||
return [
|
||||
{ x: num(r.left) - pad, y: num(r.top) - pad },
|
||||
{ x: num(r.right) + pad, y: num(r.top) - pad },
|
||||
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
|
||||
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
|
||||
];
|
||||
}
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
|
||||
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
|
||||
const hw = num(r.halfW) + pad;
|
||||
const hh = num(r.halfH) + pad;
|
||||
const out = [];
|
||||
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
|
||||
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rectAxes(rect) {
|
||||
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
|
||||
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
|
||||
return [{ x: c, y: s }, { x: -s, y: c }];
|
||||
}
|
||||
|
||||
function projectPoints(points, axis) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points || []) {
|
||||
const v = num(p.x) * axis.x + num(p.y) * axis.y;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function aabbOverlap(a, b, padding = 0) {
|
||||
const pad = Math.max(0, num(padding));
|
||||
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
|
||||
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
|
||||
}
|
||||
|
||||
function rectOverlapInfo(a, b, padding = 0) {
|
||||
if (!aabbOverlap(a, b, padding)) return null;
|
||||
const ptsA = rectCorners(a, padding);
|
||||
const ptsB = rectCorners(b, padding);
|
||||
if (!ptsA.length || !ptsB.length) return null;
|
||||
let bestOverlap = Infinity;
|
||||
let bestAxis = null;
|
||||
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
|
||||
const len = Math.hypot(axis.x, axis.y) || 1;
|
||||
const n = { x: axis.x / len, y: axis.y / len };
|
||||
const pa = projectPoints(ptsA, n);
|
||||
const pb = projectPoints(ptsB, n);
|
||||
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
|
||||
if (overlap <= 0) return null;
|
||||
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
|
||||
}
|
||||
if (!bestAxis) return null;
|
||||
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
|
||||
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
|
||||
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
|
||||
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
|
||||
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
|
||||
return {
|
||||
overlap: bestOverlap,
|
||||
nx: bestAxis.x,
|
||||
ny: bestAxis.y,
|
||||
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
|
||||
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function rectsOverlap(a, b, margin = 0) {
|
||||
return Boolean(rectOverlapInfo(a, b, margin));
|
||||
}
|
||||
|
||||
function pointInRect(x, y, rect, padding = 0) {
|
||||
if (!rect) return false;
|
||||
const pad = Math.max(0, num(padding));
|
||||
if (rect.oriented) {
|
||||
const p = rectLocalPoint(rect, x, y);
|
||||
return Math.abs(p.x) <= num(rect.halfW) + pad && Math.abs(p.y) <= num(rect.halfH) + pad;
|
||||
}
|
||||
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
|
||||
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
|
||||
}
|
||||
|
||||
function pointSegmentDistance(px, py, ax, ay, bx, by) {
|
||||
const dx = num(bx) - num(ax);
|
||||
const dy = num(by) - num(ay);
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq <= 0.0001) return Math.hypot(num(px) - num(ax), num(py) - num(ay));
|
||||
const u = clampValue(((num(px) - num(ax)) * dx + (num(py) - num(ay)) * dy) / lenSq, 0, 1);
|
||||
return Math.hypot(num(px) - (num(ax) + dx * u), num(py) - (num(ay) + dy * u));
|
||||
}
|
||||
|
||||
function segmentAabbHit(x1, y1, x2, y2, left, top, right, bottom) {
|
||||
if ((x1 >= left && x1 <= right && y1 >= top && y1 <= bottom) || (x2 >= left && x2 <= right && y2 >= top && y2 <= bottom)) return true;
|
||||
const minX = Math.min(left, right), maxX = Math.max(left, right);
|
||||
const minY = Math.min(top, bottom), maxY = Math.max(top, bottom);
|
||||
let t0 = 0, t1 = 1;
|
||||
const clip = (p, q) => {
|
||||
if (Math.abs(p) < 1e-9) return q >= 0;
|
||||
const t = q / p;
|
||||
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
|
||||
else { if (t < t0) return false; if (t < t1) t1 = t; }
|
||||
return true;
|
||||
};
|
||||
const dx = num(x2) - num(x1);
|
||||
const dy = num(y2) - num(y1);
|
||||
return clip(-dx, num(x1) - minX) && clip(dx, maxX - num(x1)) && clip(-dy, num(y1) - minY) && clip(dy, maxY - num(y1));
|
||||
}
|
||||
|
||||
function segmentIntersectsOrientedRect(x1, y1, x2, y2, r, padding = 0) {
|
||||
if (!r) return false;
|
||||
if (!r.oriented) return segmentAabbHit(x1, y1, x2, y2, r.left - padding, r.top - padding, r.right + padding, r.bottom + padding);
|
||||
const a = rectLocalPoint(r, x1, y1);
|
||||
const b = rectLocalPoint(r, x2, y2);
|
||||
return segmentAabbHit(a.x, a.y, b.x, b.y, -(r.halfW || 0) - padding, -(r.halfH || 0) - padding, (r.halfW || 0) + padding, (r.halfH || 0) + padding);
|
||||
}
|
||||
|
||||
global.TarinaiGeometry = Object.freeze({
|
||||
num,
|
||||
clampValue,
|
||||
orientedRectAabb,
|
||||
rectAabb,
|
||||
rectLocalPoint,
|
||||
rectWorldVector,
|
||||
rectWorldNormal,
|
||||
rectCorners,
|
||||
rectAxes,
|
||||
projectPoints,
|
||||
aabbOverlap,
|
||||
rectOverlapInfo,
|
||||
rectsOverlap,
|
||||
pointInRect,
|
||||
pointSegmentDistance,
|
||||
segmentAabbHit,
|
||||
segmentIntersectsOrientedRect,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -34,7 +34,7 @@ function normalizedGroundGrassMultiplier(id = "soil") {
|
|||
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
|
||||
}
|
||||
|
||||
function normalizedGroundGrassGrowthMultiplier(id = "soil") {
|
||||
function grassGrowthMulForGround(id = "soil") {
|
||||
const def = groundDefinition(id);
|
||||
const mult = Number(def.grassGrowthMultiplier ?? def.grassMultiplier);
|
||||
return Number.isFinite(mult) && mult >= 0 ? mult : 1;
|
||||
|
|
@ -73,7 +73,7 @@ window.TarinaiGround = Object.freeze({
|
|||
label: groundLabel,
|
||||
stressMultiplier: normalizedGroundStressMultiplier,
|
||||
grassMultiplier: normalizedGroundGrassMultiplier,
|
||||
grassGrowthMultiplier: normalizedGroundGrassGrowthMultiplier,
|
||||
grassGrowthMultiplier: grassGrowthMulForGround,
|
||||
grassLimit: groundGrassLimit,
|
||||
nextId: nextGroundTypeId,
|
||||
tooltipText: groundTooltipText,
|
||||
|
|
|
|||
|
|
@ -260,7 +260,6 @@ const HEALTH = (() => {
|
|||
t.world.spawnZunchi?.(x, y, t);
|
||||
t.poopCount = (t.poopCount || 0) + 1;
|
||||
t.digest = Math.max(0, Number(t.digest || 0) - 1.0);
|
||||
t.nextPoopBubbleAt = now + 2.0;
|
||||
// If this zunchi release was caused by a major-damage event, keep the pain
|
||||
// voice bubble visible and suppress the incidental \u300c\u3076\u308a\u3085\u3063\u300d bubble.
|
||||
if (now >= Number(t._damageBubblePriorityUntil || -999)) {
|
||||
|
|
|
|||
|
|
@ -130,105 +130,21 @@
|
|||
|
||||
function resolveRectBounce(item, rect, worldRef, source = null) {
|
||||
if (!rect) return;
|
||||
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
|
||||
if (typeof bounceCircleOffRect !== "function") return;
|
||||
const hitRadius = (item.r || 18) + 2;
|
||||
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
|
||||
const velocityDamp = rect.bounce ? 1.0 : 0.94;
|
||||
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
|
||||
if (rect.oriented && global.TarinaiPhysics.bounceCircleOffRect) {
|
||||
const bounced = global.TarinaiPhysics.bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed });
|
||||
if (!bounced) return;
|
||||
item.vx *= velocityDamp;
|
||||
item.vy *= velocityDamp;
|
||||
emitBounce(item, worldRef, source || rect.item || rect);
|
||||
return;
|
||||
}
|
||||
const ensureMinNormalSpeed = (nx, ny) => {
|
||||
if (!(rect.bounce && minBounceSpeed > 0)) return;
|
||||
const normalSpeed = (item.vx || 0) * nx + (item.vy || 0) * ny;
|
||||
if (normalSpeed >= minBounceSpeed) return;
|
||||
const add = minBounceSpeed - normalSpeed;
|
||||
item.vx = (item.vx || 0) + nx * add;
|
||||
item.vy = (item.vy || 0) + ny * add;
|
||||
};
|
||||
const cx = clamp(item.x, rect.left, rect.right);
|
||||
const cy = clamp(item.y, rect.top, rect.bottom);
|
||||
let dx = item.x - cx;
|
||||
let dy = item.y - cy;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) {
|
||||
const px = item.prevX;
|
||||
const py = item.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, item.x, item.y, expanded)) return;
|
||||
let nx = 0;
|
||||
let ny = 0;
|
||||
const vx = item.vx || 0;
|
||||
const vy = item.vy || 0;
|
||||
const candidates = [];
|
||||
const sx = item.x - px;
|
||||
const sy = item.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) item.x = expanded.left - 0.5;
|
||||
else if (nx > 0) item.x = expanded.right + 0.5;
|
||||
if (ny < 0) item.y = expanded.top - 0.5;
|
||||
else if (ny > 0) item.y = expanded.bottom + 0.5;
|
||||
if (nx) item.y = clamp(item.y, expanded.top, expanded.bottom);
|
||||
if (ny) item.x = clamp(item.x, expanded.left, expanded.right);
|
||||
if (toward < 0) {
|
||||
item.vx = vx - (1 + restitution) * toward * nx;
|
||||
item.vy = vy - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
item.vx = vx + nx * 18;
|
||||
item.vy = vy + ny * 18;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
item.vx *= velocityDamp;
|
||||
item.vy *= velocityDamp;
|
||||
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
emitBounce(item, worldRef, source || rect.item || rect);
|
||||
return;
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const left = Math.abs(item.x - rect.left);
|
||||
const right = Math.abs(rect.right - item.x);
|
||||
const top = Math.abs(item.y - rect.top);
|
||||
const bottom = Math.abs(rect.bottom - item.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 = (item.vx || 0) * nx + (item.vy || 0) * ny;
|
||||
item.x = cx + nx * (hitRadius + 0.5);
|
||||
item.y = cy + ny * (hitRadius + 0.5);
|
||||
if (toward < 0) {
|
||||
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
|
||||
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
item.vx = (item.vx || 0) + nx * 18;
|
||||
item.vy = (item.vy || 0) + ny * 18;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
|
||||
minBounceSpeed: rect.bounce ? minBounceSpeed : 0,
|
||||
fallbackImpulse: 18,
|
||||
spinKick: 6.2,
|
||||
spinLimit: 38,
|
||||
});
|
||||
if (!bounced) return;
|
||||
item.vx *= velocityDamp;
|
||||
item.vy *= velocityDamp;
|
||||
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
emitBounce(item, worldRef, source || rect.item || rect);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,235 +5,16 @@
|
|||
// links, and static gate-fence ticking. Item.prototype methods delegate here for
|
||||
// direct Item.prototype calls; lifecycle dispatch also calls this boundary directly.
|
||||
(function (global) {
|
||||
|
||||
function updateFirecracker(item, dt, worldRef) {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
if (item.fuseTimer <= 0) {
|
||||
if (worldRef?.explodeFirecracker) worldRef.explodeFirecracker(item);
|
||||
else item.amount = 0;
|
||||
}
|
||||
function fireApi() {
|
||||
const api = global.TarinaiFireRuntimeSystem;
|
||||
if (!api) throw new Error("TarinaiFireRuntimeSystem must be loaded before fire item behavior is used");
|
||||
return api;
|
||||
}
|
||||
|
||||
|
||||
const FIRE_BURNING_TEXT = "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002";
|
||||
const FIRE_SEEN_TEXT = "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002";
|
||||
|
||||
function fireTempMultiplier(worldRef, x, y, entity = null) {
|
||||
const weather = String(worldRef?.weather || "");
|
||||
const temp = Number(worldRef?.temperatureAt?.(x, y, entity) ?? worldRef?.feltTemperatureFor?.(entity) ?? worldRef?.temperature ?? global.CONFIG?.standardTemperature ?? 15);
|
||||
let mul = 1;
|
||||
if (weather === "light_rain") mul *= 2;
|
||||
if (Number.isFinite(temp) && temp <= 10) mul *= 2;
|
||||
return mul;
|
||||
}
|
||||
|
||||
function pushFireEffect(worldRef, x, y, size = 12, seed = null) {
|
||||
if (!worldRef?.effects || typeof Effect !== "function") return;
|
||||
const a = global.deterministicAngle ? global.deterministicAngle(worldRef, "fire-effect-angle", seed || x, y, worldRef.time || 0) : Math.random() * Math.PI * 2;
|
||||
const speed = global.deterministicRange ? global.deterministicRange(worldRef, "fire-effect-speed", 4, 28, seed || x, y) : (4 + Math.random() * 24);
|
||||
worldRef.effects.push(new Effect("fight", x, y, {
|
||||
vx: Math.cos(a) * speed,
|
||||
vy: Math.sin(a) * speed - Math.max(5, speed * 0.45),
|
||||
size,
|
||||
life: global.deterministicRange ? global.deterministicRange(worldRef, "fire-effect-life", 0.22, 0.62, seed || x, y) : 0.38,
|
||||
color: "rgba(255,118,35,0.76)",
|
||||
}));
|
||||
}
|
||||
|
||||
function contactWaterDrop(worldRef, x, y, radius = 16) {
|
||||
if (!worldRef) return null;
|
||||
const list = typeof worldRef.nearbyItems === "function" ? [...(worldRef.nearbyItems(x, y, radius + 28, true) || [])] : (worldRef.items || []);
|
||||
for (const it of list) {
|
||||
if (!it || it.dead || it.type !== "water") continue;
|
||||
const rr = Math.max(radius, 10) + Math.max(it.r || 10, 10);
|
||||
if (distXY(x, y, it.x, it.y) <= rr) return it;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function consumeWaterForFire(worldRef, water, amount = 8) {
|
||||
if (!water) return false;
|
||||
water.amount = Math.max(0, Number(water.amount || 0) - amount);
|
||||
worldRef?.effects?.push(new Effect("ring", water.x, water.y, { size: Math.max(10, (water.r || 10) * 1.4), life: 0.18, color: "rgba(108,192,236,0.58)" }));
|
||||
worldRef?.markItemBucketsDirty?.("fire-water");
|
||||
worldRef?.markSpatialDirty?.("fire-water");
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteTarinaiByFire(t, source = null, worldRef = null, force = false) {
|
||||
if (!t || t.dead) return false;
|
||||
if (typeof t.igniteFire === "function") return t.igniteFire(source, force);
|
||||
t.burning = true;
|
||||
t.burnTimer = Math.max(t.burnTimer || 0, 5.0);
|
||||
t.thought = FIRE_BURNING_TEXT;
|
||||
t.lastPanicCause = "burning";
|
||||
t.lastPanicDetail = FIRE_BURNING_TEXT;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 1.5);
|
||||
t.sleeping = false;
|
||||
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_BURNING_TEXT, wake: true });
|
||||
worldRef?.emit?.("fire:ignite", { tarinai: t, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
function panicTarinaiFromFire(t, source = null, worldRef = null) {
|
||||
if (!t || t.dead || t.burning || (t.burnTimer || 0) > 0.02) return false;
|
||||
const now = Number(worldRef?.time || t.world?.time || 0) || 0;
|
||||
if (now < Number(t.firePanicCooldownUntil || 0)) return false;
|
||||
t.firePanicCooldownUntil = now + 0.85;
|
||||
t.firePanicTimer = Math.max(t.firePanicTimer || 0, 1.4);
|
||||
t.lastPanicCause = "fire_seen";
|
||||
t.lastPanicDetail = FIRE_SEEN_TEXT;
|
||||
t.thought = FIRE_SEEN_TEXT;
|
||||
if (typeof t.enterPanic === "function") t.enterPanic({ threat: source, target: source, reason: FIRE_SEEN_TEXT, fear: 1.25, wake: true, cause: "fire_seen", surpriseTimer: 0.55, awakeLockTimer: 0.35 });
|
||||
else { t.fearTimer = Math.max(t.fearTimer || 0, 1.25); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(source || null, true) : null, reason: FIRE_SEEN_TEXT, wake: true }); }
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteAntByFire(ant, source = null, worldRef = null) {
|
||||
if (!ant || ant.dead) return false;
|
||||
if (typeof ant.igniteFire === "function") return ant.igniteFire(source);
|
||||
ant.burning = true;
|
||||
ant.burnTimer = Math.max(ant.burnTimer || 0, 5.0);
|
||||
ant.state = "panic";
|
||||
ant.targetId = "";
|
||||
ant.targetRef = null;
|
||||
worldRef?.emit?.("fire:ignite-ant", { ant, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
function igniteGrassByFire(grass, source = null, worldRef = null, dt = 0.016) {
|
||||
if (!grass || grass.dead || grass.type !== "grass") return false;
|
||||
grass.burning = true;
|
||||
grass.burnTimer = Math.max(grass.burnTimer || 0, 2.8);
|
||||
grass.health = clamp((grass.health ?? 1) - dt * 0.30, 0, 1);
|
||||
grass.amount = Math.max(0, Number(grass.amount || 0) - dt * 18);
|
||||
if (grass.health <= 0.02 || grass.amount <= 0.5) grass.amount = 0;
|
||||
if (worldRef && (worldRef.time || 0) > Number(grass.nextFireSpreadAt || -Infinity)) {
|
||||
grass.nextFireSpreadAt = (worldRef.time || 0) + 1.6;
|
||||
if (typeof worldRef.spawnFireAt === "function") {
|
||||
const a = global.deterministicAngle ? global.deterministicAngle(worldRef, "grass-fire-spread", grass, source) : Math.random() * Math.PI * 2;
|
||||
worldRef.spawnFireAt(grass.x + Math.cos(a) * (grass.r || 14) * 0.55, grass.y + Math.sin(a) * (grass.r || 14) * 0.35, { amount: 60, radius: 12, life: 4.2, source: grass });
|
||||
}
|
||||
}
|
||||
worldRef?.markTerrainDirtyAt?.(grass.x, grass.y, Math.max(grass.r || 17, 26), "grass-fire");
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFireContacts(item, dt, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const r = Math.max(14, Number(item.r || 0) || 14);
|
||||
const spreadRadius = r + 16;
|
||||
let changed = false;
|
||||
for (const t of [...(worldRef.nearbyTarinai?.(item.x, item.y, spreadRadius + 32, true) || [])]) {
|
||||
if (!t || t.dead) continue;
|
||||
const d = distXY(item.x, item.y, t.x, t.y);
|
||||
if (d <= spreadRadius + Math.max(t.radius || 18, 12) * 0.72) changed = igniteTarinaiByFire(t, item, worldRef, false) || changed;
|
||||
}
|
||||
for (const ant of [...(worldRef.nearbyAnts?.(item.x, item.y, spreadRadius + 20, true) || [])]) {
|
||||
if (!ant || ant.dead) continue;
|
||||
const d = distXY(item.x, item.y, ant.x, ant.y);
|
||||
if (d <= spreadRadius + Math.max(ant.r || 5, 4)) changed = igniteAntByFire(ant, item, worldRef) || changed;
|
||||
}
|
||||
const nearby = typeof worldRef.nearbyItems === "function" ? [...(worldRef.nearbyItems(item.x, item.y, spreadRadius + 38, true) || [])] : (worldRef.items || []);
|
||||
for (const it of nearby) {
|
||||
if (!it || it.dead || it === item) continue;
|
||||
if (it.type === "grass" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 16, 12)) changed = igniteGrassByFire(it, item, worldRef, dt) || changed;
|
||||
if (it.type === "water" && distXY(item.x, item.y, it.x, it.y) <= spreadRadius + Math.max(it.r || 10, 8)) {
|
||||
consumeWaterForFire(worldRef, it, Math.max(6, r * 0.45));
|
||||
item.amount = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function panicFromNearbyFire(item, worldRef) {
|
||||
if (!item || !worldRef) return;
|
||||
const r = Math.max(170, (item.r || 14) + 170);
|
||||
for (const t of [...(worldRef.nearbyTarinai?.(item.x, item.y, r, true) || [])]) {
|
||||
if (!t || t.dead) continue;
|
||||
panicTarinaiFromFire(t, item, worldRef);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFire(item, dt, worldRef) {
|
||||
if (!item || item.dead || !worldRef) return false;
|
||||
dt = Math.max(0, Math.min(0.08, Number(dt || 0) || 0));
|
||||
item.amount = Math.max(0, Number(item.amount || 0) - dt * 4.2);
|
||||
item.flameLife = Math.max(0, Number(item.flameLife ?? 8.0) - dt);
|
||||
item.flameSpreadCooldown = Math.max(0, Number(item.flameSpreadCooldown || 0) - dt);
|
||||
item.flamePanicCooldown = Math.max(0, Number(item.flamePanicCooldown || 0) - dt);
|
||||
const water = contactWaterDrop(worldRef, item.x, item.y, Math.max(item.r || 14, 12));
|
||||
if (water) {
|
||||
consumeWaterForFire(worldRef, water, Math.max(8, (item.r || 14) * 0.6));
|
||||
item.amount = 0;
|
||||
return true;
|
||||
}
|
||||
const chance = dt * 0.012 * fireTempMultiplier(worldRef, item.x, item.y, item);
|
||||
if (global.deterministicChance ? global.deterministicChance(worldRef, "fire-random-extinguish", chance, item) : Math.random() < chance) {
|
||||
item.amount = 0;
|
||||
return true;
|
||||
}
|
||||
if (item.flameLife <= 0) item.amount = Math.min(item.amount, 1);
|
||||
if (item.flameSpreadCooldown <= 0) {
|
||||
updateFireContacts(item, dt, worldRef);
|
||||
item.flameSpreadCooldown = 0.12;
|
||||
}
|
||||
if (item.flamePanicCooldown <= 0) {
|
||||
panicFromNearbyFire(item, worldRef);
|
||||
item.flamePanicCooldown = 0.32;
|
||||
}
|
||||
if (global.deterministicChance ? global.deterministicChance(worldRef, "fire-effect", dt * 2.4, item) : Math.random() < dt * 2.4) pushFireEffect(worldRef, item.x, item.y - (item.r || 14) * 0.2, Math.max(8, (item.r || 14) * 0.9), item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function spawnFireAt(worldRef, x, y, opts = {}) {
|
||||
if (!worldRef || typeof Item !== "function") return null;
|
||||
const fire = new Item("fire", x, y);
|
||||
const scale = Math.max(0.45, Math.min(2.0, Number(opts.scale || 1) || 1));
|
||||
fire.r = Math.max(9, Number(opts.radius || fire.r || 14) * scale);
|
||||
fire.amount = Math.max(12, Number(opts.amount || fire.amount || 100) * scale);
|
||||
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
|
||||
fire.sourceFireId = opts.source?.id || "";
|
||||
fire.vx = Number(opts.vx || 0) || 0;
|
||||
fire.vy = Number(opts.vy || 0) || 0;
|
||||
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
|
||||
worldRef.drawListDirty = true;
|
||||
return fire;
|
||||
}
|
||||
|
||||
function scatterFireFrom(item, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const count = Math.max(7, Math.round(10 * Math.max(0.75, Math.min(1.7, (item.r || 15) / 15))));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = (Math.PI * 2 * i / count) + (global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i) : (Math.random() - 0.5) * 0.4);
|
||||
const d = global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-radius", 28, 118, item, i) : (28 + Math.random() * 90);
|
||||
const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, {
|
||||
radius: global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-fire-radius", 10, 18, item, i) : (10 + Math.random() * 8),
|
||||
amount: global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i) : (55 + Math.random() * 65),
|
||||
life: global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, item, i) : (4 + Math.random() * 4.5),
|
||||
source: item,
|
||||
});
|
||||
if (fire) {
|
||||
fire.vx = Math.cos(a) * (global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-vx", 10, 42, item, i) : 24);
|
||||
fire.vy = Math.sin(a) * (global.deterministicRange ? global.deterministicRange(worldRef, "flame-cracker-vy", 10, 42, item, i) : 24);
|
||||
}
|
||||
pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item);
|
||||
}
|
||||
worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * 3.0), life: 0.36, color: "rgba(255,126,54,0.58)" }));
|
||||
item.amount = 0;
|
||||
worldRef.markItemBucketsDirty?.("flame-firecracker");
|
||||
worldRef.markSpatialDirty?.("flame-firecracker");
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateFlameFirecracker(item, dt, worldRef) {
|
||||
item.fuseTimer = Math.max(0, (item.fuseTimer ?? 5) - dt);
|
||||
if (item.fuseTimer <= 0) {
|
||||
if (worldRef) scatterFireFrom(item, worldRef);
|
||||
else item.amount = 0;
|
||||
}
|
||||
return true;
|
||||
function robotApi() {
|
||||
const api = global.TarinaiRobotCleanerSystem;
|
||||
if (!api) throw new Error("TarinaiRobotCleanerSystem must be loaded before robot cleaner behavior is used");
|
||||
return api;
|
||||
}
|
||||
|
||||
function updateGenkotsu(item, dt) {
|
||||
|
|
@ -270,330 +51,6 @@
|
|||
|
||||
|
||||
|
||||
const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
||||
const ROBOT_MOVE_SPEED = 60;
|
||||
const ROBOT_HIGH_SPEED = 120;
|
||||
const ROBOT_RESCAN_INTERVAL = 2.0;
|
||||
const ROBOT_TARGET_BITS = Object.freeze({
|
||||
zunchi: 1 << 0,
|
||||
ant_corpse: 1 << 1,
|
||||
tarinai: 1 << 2,
|
||||
grass: 1 << 3,
|
||||
grass_bed: 1 << 4,
|
||||
water: 1 << 5,
|
||||
});
|
||||
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]);
|
||||
|
||||
function robotCleanerDefaultMask() {
|
||||
return ROBOT_DEFAULT_MASK;
|
||||
}
|
||||
|
||||
function robotCleanerMask(item) {
|
||||
const raw = Number(item?.robotCleanerMask);
|
||||
return Number.isFinite(raw) ? (raw | 0) : ROBOT_DEFAULT_MASK;
|
||||
}
|
||||
|
||||
function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) {
|
||||
if (!item || item.type !== "robot_cleaner") return false;
|
||||
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0));
|
||||
return true;
|
||||
}
|
||||
|
||||
function robotCleanerHighSpeed(item) {
|
||||
return Boolean(item?.robotCleanerHighSpeed);
|
||||
}
|
||||
|
||||
function setRobotCleanerHighSpeed(item, enabled = false) {
|
||||
if (!item || item.type !== "robot_cleaner") return false;
|
||||
item.robotCleanerHighSpeed = Boolean(enabled);
|
||||
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function robotCleanerEffectiveSpeed(item) {
|
||||
return robotCleanerHighSpeed(item) ? ROBOT_HIGH_SPEED : ROBOT_MOVE_SPEED;
|
||||
}
|
||||
|
||||
function robotCleanerHasTarget(item, key = "") {
|
||||
const bit = ROBOT_TARGET_BITS[String(key || "")];
|
||||
return Boolean(bit && (robotCleanerMask(item) & bit));
|
||||
}
|
||||
|
||||
function robotTargetLabel(key = "") {
|
||||
switch (String(key || "")) {
|
||||
case "zunchi": return "\u305a\u3093\u3061";
|
||||
case "ant_corpse": return "\u6b7b\u9ab8";
|
||||
case "trace": return "\u6b7b\u9ab8";
|
||||
case "splat": return "\u8840\u75d5";
|
||||
case "tarinai": return "\u305f\u308a\u306a\u3044";
|
||||
case "grass": return "\u8349";
|
||||
case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9";
|
||||
case "water": return "\u6c34\u6ef4";
|
||||
default: return "\u5bfe\u8c61";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRobotCleanerState(item) {
|
||||
if (!item || item.type !== "robot_cleaner") return null;
|
||||
if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK;
|
||||
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0));
|
||||
item.amount = Math.max(Number(item.amount || 0) || 0, 999);
|
||||
if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0;
|
||||
item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed);
|
||||
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function robotTargetAlive(target) {
|
||||
if (!target) return false;
|
||||
return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y));
|
||||
}
|
||||
|
||||
function robotTargetBlocked(item, target, worldRef) {
|
||||
if (!item || !target || !worldRef) return false;
|
||||
return Boolean(worldRef.targetBlockedByObstacle?.(item, target, Math.max(18, (item.r || 22) * 0.65)));
|
||||
}
|
||||
|
||||
function currentRobotTarget(item, worldRef) {
|
||||
const key = String(item?.robotCleanerTargetType || "");
|
||||
const id = String(item?.robotCleanerTargetId || "");
|
||||
if (!key || !id || !worldRef) return null;
|
||||
if (key === "tarinai") {
|
||||
const t = (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead);
|
||||
return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null;
|
||||
}
|
||||
const found = (worldRef.items || []).find(it => it && it.id === id && !it.dead && (
|
||||
it.type === key || (key === "ant_corpse" && (it.type === "trace" || it.type === "splat"))
|
||||
));
|
||||
return robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null;
|
||||
}
|
||||
|
||||
function robotTargetCandidates(item, worldRef) {
|
||||
const out = [];
|
||||
if (!worldRef || !item) return out;
|
||||
const mask = robotCleanerMask(item);
|
||||
worldRef.ensureItemBuckets?.("robot-cleaner-scan");
|
||||
for (const key of ROBOT_TARGET_ORDER) {
|
||||
const bit = ROBOT_TARGET_BITS[key];
|
||||
if (!(mask & bit)) continue;
|
||||
if (key === "tarinai") {
|
||||
for (const t of worldRef.tarinai || []) {
|
||||
if (!t || t.dead) continue;
|
||||
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
if (robotTargetBlocked(item, t, worldRef)) continue;
|
||||
out.push({ target: t, type: key });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const buckets = key === "ant_corpse"
|
||||
? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type))
|
||||
: (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key));
|
||||
for (const it of buckets || []) {
|
||||
if (!it || it.dead || it === item) continue;
|
||||
if (robotTargetBlocked(item, it, worldRef)) continue;
|
||||
out.push({ target: it, type: key });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function retargetRobotCleaner(item, worldRef, force = false) {
|
||||
const current = currentRobotTarget(item, worldRef);
|
||||
if (current && !force) return current;
|
||||
item.robotCleanerTargetId = "";
|
||||
item.robotCleanerTargetType = "";
|
||||
let best = null;
|
||||
let bestType = "";
|
||||
let bestD = Infinity;
|
||||
for (const rec of robotTargetCandidates(item, worldRef)) {
|
||||
const target = rec.target;
|
||||
if (!robotTargetAlive(target)) continue;
|
||||
const d = distXY(item.x, item.y, target.x, target.y);
|
||||
if (d < bestD) { best = target; bestType = rec.type; bestD = d; }
|
||||
}
|
||||
if (best) {
|
||||
item.robotCleanerTargetId = best.id || "";
|
||||
item.robotCleanerTargetType = bestType || best.type || "";
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function triggerRobotPanic(item, target, worldRef, force = false) {
|
||||
if (!item || !target || target.dead) return false;
|
||||
const now = Number(worldRef?.time || target.world?.time || 0) || 0;
|
||||
if (!force && now < Number(target.robotCleanerPanicCooldownUntil || 0)) return false;
|
||||
target.robotCleanerPanicCooldownUntil = now + 0.65;
|
||||
const reason = "\u30ed\u30dc\u6383\u9664\u6a5f\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
|
||||
if (typeof target.enterPanic === "function") {
|
||||
target.enterPanic({ threat: item, reason, fear: 0.86, wake: true, cause: "robot_cleaner", surpriseTimer: 0.18, awakeLockTimer: 0.45 });
|
||||
} else {
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.86);
|
||||
target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(item, true) : null, reason, wake: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRobotTarinaiTarget(target) {
|
||||
return Boolean(target && (target.constructor?.name === "Tarinai" || (target.thought !== undefined && target.name && target.die)));
|
||||
}
|
||||
|
||||
function robotTargetTypeForContact(item, target) {
|
||||
if (!item || !target || target.dead) return "";
|
||||
if (isRobotTarinaiTarget(target)) return robotCleanerHasTarget(item, "tarinai") ? "tarinai" : "";
|
||||
const type = String(target.type || "");
|
||||
if ((type === "ant_corpse" || type === "trace" || type === "splat") && robotCleanerHasTarget(item, "ant_corpse")) return type;
|
||||
return robotCleanerHasTarget(item, type) ? type : "";
|
||||
}
|
||||
|
||||
function robotCleanerCaptureRadius(item, target) {
|
||||
return Math.max(18, (item?.r || 22) * 0.72 + (target?.r || target?.radius || 10) * 0.72);
|
||||
}
|
||||
|
||||
function clearRobotTrackedTargetIfMatched(item, target) {
|
||||
if (!item || !target) return false;
|
||||
const id = String(target.id || "");
|
||||
if (!id || String(item.robotCleanerTargetId || "") !== id) return false;
|
||||
item.robotCleanerTargetId = "";
|
||||
item.robotCleanerTargetType = "";
|
||||
item.robotCleanerScanTimer = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanRobotTarget(item, target, worldRef, typeOverride = "") {
|
||||
if (!item || !target || !worldRef || target.dead) return false;
|
||||
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
|
||||
const label = robotTargetLabel(type);
|
||||
let removed = false;
|
||||
const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f";
|
||||
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
|
||||
triggerRobotPanic(item, target, worldRef, true);
|
||||
if (typeof target.die === "function") {
|
||||
target.die(cleanReason, { lowHealth: true });
|
||||
removed = true;
|
||||
} else {
|
||||
target.dead = true;
|
||||
target.deathReason = cleanReason;
|
||||
worldRef.markDead?.(target, cleanReason);
|
||||
removed = true;
|
||||
}
|
||||
} else {
|
||||
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
|
||||
reason: "robot-cleaner",
|
||||
userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason,
|
||||
wake: true,
|
||||
breaker: item,
|
||||
}));
|
||||
if (!removed) {
|
||||
target.amount = 0;
|
||||
target.hp = 0;
|
||||
target.dead = true;
|
||||
target.removedReason = "robot-cleaner";
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (!removed) return false;
|
||||
clearRobotTrackedTargetIfMatched(item, target);
|
||||
item.robotCleanerScanTimer = 0;
|
||||
item.robotCleanerCleanFlash = 0.42;
|
||||
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
|
||||
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
|
||||
worldRef.markItemBucketsDirty?.("robot-cleaner");
|
||||
worldRef.markSpatialDirty?.("robot-cleaner");
|
||||
if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner");
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.drawListDirty = true;
|
||||
if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) {
|
||||
worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe");
|
||||
item.robotCleanerLastLogAt = worldRef.time || 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanRobotContacts(item, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const seen = new Set();
|
||||
let changed = false;
|
||||
for (const rec of robotTargetCandidates(item, worldRef)) {
|
||||
const target = rec?.target;
|
||||
if (!robotTargetAlive(target)) continue;
|
||||
const key = String(target.id || `${rec.type}:${target.x}:${target.y}`);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const type = robotTargetTypeForContact(item, target) || rec.type || "";
|
||||
if (!type) continue;
|
||||
const capture = robotCleanerCaptureRadius(item, target);
|
||||
if (distXY(item.x, item.y, target.x, target.y) <= capture) {
|
||||
if (cleanRobotTarget(item, target, worldRef, type)) changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveRobotCleaner(item, dt, target, worldRef) {
|
||||
const r = Math.max(14, Number(item.r || 22) || 22);
|
||||
const speed = robotCleanerEffectiveSpeed(item);
|
||||
let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0;
|
||||
let desiredSpeed = speed;
|
||||
if (target && robotTargetAlive(target)) {
|
||||
desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0));
|
||||
desiredSpeed = speed;
|
||||
} else if ((item.robotCleanerWanderTimer || 0) <= 0) {
|
||||
desiredAngle = global.deterministicAngle ? global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2)) : Math.random() * Math.PI * 2;
|
||||
item.robotCleanerWanderTimer = global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item) : (1.2 + Math.random() * 2.0);
|
||||
}
|
||||
item.robotCleanerWanderTimer = Math.max(0, Number(item.robotCleanerWanderTimer || 0) - dt);
|
||||
item.robotCleanerWanderAngle = desiredAngle;
|
||||
const vx = Math.cos(desiredAngle) * desiredSpeed;
|
||||
const vy = Math.sin(desiredAngle) * desiredSpeed;
|
||||
const blend = Math.min(1, dt * 6.2);
|
||||
item.vx = (Number(item.vx || 0) || 0) + (vx - (Number(item.vx || 0) || 0)) * blend;
|
||||
item.vy = (Number(item.vy || 0) || 0) + (vy - (Number(item.vy || 0) || 0)) * blend;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.x += item.vx * dt;
|
||||
item.y += item.vy * dt;
|
||||
const minX = CONFIG.worldPadding + r * 0.5;
|
||||
const minY = CONFIG.worldPadding + r * 0.5;
|
||||
const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5);
|
||||
const maxY = Math.max(minY, (worldRef.h || 0) - CONFIG.worldPadding - r * 0.5);
|
||||
let bounced = false;
|
||||
if (item.x < minX) { item.x = minX; item.vx = Math.abs(item.vx) * 0.35; bounced = true; }
|
||||
if (item.x > maxX) { item.x = maxX; item.vx = -Math.abs(item.vx) * 0.35; bounced = true; }
|
||||
if (item.y < minY) { item.y = minY; item.vy = Math.abs(item.vy) * 0.35; bounced = true; }
|
||||
if (item.y > maxY) { item.y = maxY; item.vy = -Math.abs(item.vy) * 0.35; bounced = true; }
|
||||
if (bounced) {
|
||||
item.robotCleanerWanderAngle = Math.atan2(item.vy || 0, item.vx || 0) + (global.deterministicRange ? global.deterministicRange(worldRef, "robot-cleaner-bounce", -0.8, 0.8, item) : (Math.random() - 0.5) * 1.6);
|
||||
item.robotCleanerWanderTimer = 0.4;
|
||||
}
|
||||
if (Math.hypot(item.vx || 0, item.vy || 0) > 2) item.robotCleanerAngle = Math.atan2(item.vy || 0, item.vx || 0);
|
||||
worldRef.markSpatialDirty?.("robot-cleaner-move");
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
|
||||
function updateRobotCleaner(item, dt, worldRef) {
|
||||
if (!item || item.dead || !worldRef) return false;
|
||||
normalizeRobotCleanerState(item);
|
||||
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
|
||||
item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
|
||||
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
|
||||
let target = currentRobotTarget(item, worldRef);
|
||||
if (!target || item.robotCleanerScanTimer <= 0) {
|
||||
target = retargetRobotCleaner(item, worldRef, true);
|
||||
item.robotCleanerScanTimer = ROBOT_RESCAN_INTERVAL;
|
||||
}
|
||||
moveRobotCleaner(item, dt, target, worldRef);
|
||||
if (target && robotTargetAlive(target)) {
|
||||
const isTarinaiTarget = String(item.robotCleanerTargetType || "") === "tarinai" || isRobotTarinaiTarget(target);
|
||||
const d = distXY(item.x, item.y, target.x, target.y);
|
||||
if (isTarinaiTarget && d <= Math.max(96, (item.r || 22) + (target.radius || target.r || 12) + 70)) triggerRobotPanic(item, target, worldRef);
|
||||
}
|
||||
cleanRobotContacts(item, worldRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateGateFence(item) {
|
||||
if (item) item.amount = 999;
|
||||
return Boolean(item);
|
||||
|
|
@ -601,15 +58,15 @@
|
|||
|
||||
function update(item, dt, worldRef) {
|
||||
if (!item || item.dead) return false;
|
||||
if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef);
|
||||
if (item.type === "flame_firecracker") return updateFlameFirecracker(item, dt, worldRef);
|
||||
if (item.type === "fire") return updateFire(item, dt, worldRef);
|
||||
if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef);
|
||||
if (item.type === "flame_firecracker") return fireApi().updateFlameFirecracker(item, dt, worldRef);
|
||||
if (item.type === "fire") return fireApi().updateFire(item, dt, worldRef);
|
||||
if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef);
|
||||
if (item.type === "rotator") return updateRotator(item, dt, worldRef);
|
||||
if (item.type === "poison_block") return updatePoisonBlock(item, dt, worldRef);
|
||||
if (item.type === "reciprocator") return updateReciprocator(item, dt, worldRef);
|
||||
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
|
||||
if (item.type === "robot_cleaner") return updateRobotCleaner(item, dt, worldRef);
|
||||
if (item.type === "robot_cleaner") return robotApi().updateRobotCleaner(item, dt, worldRef);
|
||||
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
|
||||
if (item.type === "rod") return updateRigidLink(item, dt, worldRef);
|
||||
return false;
|
||||
|
|
@ -617,29 +74,30 @@
|
|||
|
||||
global.TarinaiItemDynamicToolSystem = Object.freeze({
|
||||
update,
|
||||
robotCleanerDefaultMask,
|
||||
robotCleanerMask,
|
||||
setRobotCleanerMask,
|
||||
robotCleanerHasTarget,
|
||||
robotCleanerHighSpeed,
|
||||
setRobotCleanerHighSpeed,
|
||||
robotCleanerEffectiveSpeed,
|
||||
robotTargetLabel,
|
||||
robotCleanerTargetBits: ROBOT_TARGET_BITS,
|
||||
robotCleanerTargetOrder: ROBOT_TARGET_ORDER,
|
||||
robotCleanerMoveSpeed: ROBOT_MOVE_SPEED,
|
||||
robotCleanerHighMoveSpeed: ROBOT_HIGH_SPEED,
|
||||
fireBurningText: FIRE_BURNING_TEXT,
|
||||
fireSeenText: FIRE_SEEN_TEXT,
|
||||
fireTempMultiplier,
|
||||
contactWaterDrop,
|
||||
consumeWaterForFire,
|
||||
igniteTarinaiByFire,
|
||||
panicTarinaiFromFire,
|
||||
igniteAntByFire,
|
||||
igniteGrassByFire,
|
||||
spawnFireAt: (worldRef, x, y, opts = {}) => spawnFireAt(worldRef, x, y, opts),
|
||||
scatterFireFrom,
|
||||
updateFire,
|
||||
robotCleanerDefaultMask: (...args) => robotApi().robotCleanerDefaultMask(...args),
|
||||
robotCleanerMask: (...args) => robotApi().robotCleanerMask(...args),
|
||||
setRobotCleanerMask: (...args) => robotApi().setRobotCleanerMask(...args),
|
||||
robotCleanerHasTarget: (...args) => robotApi().robotCleanerHasTarget(...args),
|
||||
robotCleanerHighSpeed: (...args) => robotApi().robotCleanerHighSpeed(...args),
|
||||
setRobotCleanerHighSpeed: (...args) => robotApi().setRobotCleanerHighSpeed(...args),
|
||||
robotCleanerEffectiveSpeed: (...args) => robotApi().robotCleanerEffectiveSpeed(...args),
|
||||
robotTargetLabel: (...args) => robotApi().robotTargetLabel(...args),
|
||||
get robotCleanerTargetBits() { return robotApi().robotCleanerTargetBits; },
|
||||
get robotCleanerTargetOrder() { return robotApi().robotCleanerTargetOrder; },
|
||||
get robotCleanerMoveSpeed() { return robotApi().robotCleanerMoveSpeed; },
|
||||
get robotCleanerHighMoveSpeed() { return robotApi().robotCleanerHighMoveSpeed; },
|
||||
get fireBurningText() { return fireApi().fireBurningText; },
|
||||
get fireSeenText() { return fireApi().fireSeenText; },
|
||||
fireTempMultiplier: (...args) => fireApi().fireTempMultiplier(...args),
|
||||
contactWaterDrop: (...args) => fireApi().contactWaterDrop(...args),
|
||||
consumeWaterForFire: (...args) => fireApi().consumeWaterForFire(...args),
|
||||
igniteTarinaiByFire: (...args) => fireApi().igniteTarinaiByFire(...args),
|
||||
panicTarinaiFromFire: (...args) => fireApi().panicTarinaiFromFire(...args),
|
||||
igniteAntByFire: (...args) => fireApi().igniteAntByFire(...args),
|
||||
igniteGrassByFire: (...args) => fireApi().igniteGrassByFire(...args),
|
||||
spawnFireAt: (...args) => fireApi().spawnFireAt(...args),
|
||||
scatterFireFrom: (...args) => fireApi().scatterFireFrom(...args),
|
||||
compactWorldFires: (...args) => fireApi().compactWorldFires(...args),
|
||||
updateFire: (...args) => fireApi().updateFire(...args),
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -73,8 +73,36 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function updateBurningLifecycle(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "zunchi" || !(item.burning || (item.burnTimer || 0) > 0.02)) return false;
|
||||
item.burning = true;
|
||||
item.burnTimer = Math.max(0, Number(item.burnTimer || 0) - dt);
|
||||
item.spawnGrace = 0;
|
||||
item.freshness = clamp((item.freshness ?? 1) - dt * 0.62, 0, 1);
|
||||
item.amount = Math.max(0, Number(item.amount || 0) - dt * 30);
|
||||
item.stageTimer = Math.max(0, Number(item.stageTimer || 0) + dt * 1.8);
|
||||
if (item.stage === "fresh" && item.freshness < 0.45) { item.stage = "dry"; item.stageTimer = 0; }
|
||||
if (item.stage === "dry" && item.freshness < 0.18) { item.stage = "decomposing"; item.stageTimer = 0; item.fertility = Math.max(item.fertility || 0, 0.18); }
|
||||
if (worldRef?.effects && typeof Effect === "function" && global.deterministicChance(worldRef, "zunchi-fire-effect", dt * 1.15, item)) {
|
||||
worldRef.effects.push(new Effect("flame", item.x + deterministicRange(worldRef, "zunchi-fire-x", -5, 5, item), item.y - Math.max(4, (item.r || 14) * 0.25), {
|
||||
vx: deterministicRange(worldRef, "zunchi-fire-vx", -3, 3, item),
|
||||
vy: deterministicRange(worldRef, "zunchi-fire-vy", -10, -4, item),
|
||||
size: deterministicRange(worldRef, "zunchi-fire-size", 6, 12, item),
|
||||
life: deterministicRange(worldRef, "zunchi-fire-life", 0.18, 0.34, item),
|
||||
color: "rgba(236,46,24,0.72)",
|
||||
}));
|
||||
}
|
||||
if (item.burnTimer <= 0.01) item.burning = false;
|
||||
if ((item.amount || 0) <= 0.5) item.amount = 0;
|
||||
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(item.r || 15, 28), "zunchi-burning");
|
||||
worldRef.drawListDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateLifecycle(item, dt, worldRef) {
|
||||
if (!item || item.dead || item.type !== "zunchi") return false;
|
||||
updateBurningLifecycle(item, dt, worldRef);
|
||||
const rainy = worldRef.weather === "light_rain";
|
||||
const rainBoost = rainy ? 1.28 : 1;
|
||||
const groundId = worldRef?.groundType || "soil";
|
||||
|
|
@ -108,5 +136,5 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle });
|
||||
global.TarinaiItemDynamicZunchiSystem = Object.freeze({ updateMotion, updateLifecycle, updateBurningLifecycle });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -11,23 +11,14 @@
|
|||
"TarinaiItemGrowthLifecycleStep",
|
||||
]);
|
||||
|
||||
let cachedSteps = null;
|
||||
function concreteSteps() {
|
||||
if (!cachedSteps) cachedSteps = CONCRETE_STEP_NAMES.map(name => global[name]);
|
||||
return cachedSteps;
|
||||
}
|
||||
|
||||
const runner = global.TarinaiUpdateStepPipelineRunner.create(
|
||||
CONCRETE_STEP_NAMES,
|
||||
(step, item, dt, worldRef, context) => step.update(item, dt, worldRef, context)
|
||||
);
|
||||
|
||||
function updateOne(item, dt, worldRef, options = {}) {
|
||||
if (!item || item.dead) return false;
|
||||
const steps = concreteSteps();
|
||||
if (steps.length !== CONCRETE_STEP_NAMES.length) return false;
|
||||
const context = options.context || {};
|
||||
for (const step of steps) {
|
||||
const result = step.update(item, dt, worldRef, context) || {};
|
||||
if (result.done) return result.ok !== false;
|
||||
}
|
||||
return true;
|
||||
return runner.run(item, dt, worldRef, options.context || {});
|
||||
}
|
||||
|
||||
global.TarinaiItemLifecyclePipeline = Object.freeze({ updateOne });
|
||||
|
|
|
|||
87
js/item_render_helpers.js
Normal file
87
js/item_render_helpers.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use strict";
|
||||
|
||||
// Shared item rendering helpers. Field rendering stays in item_render_runtime.js.
|
||||
|
||||
function segmentPathCacheKey(item, segments, extra = "") {
|
||||
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
|
||||
const len = Array.isArray(segments) ? segments.length : 0;
|
||||
let h = len * 2654435761;
|
||||
if (Array.isArray(segments)) {
|
||||
const step = Math.max(1, Math.floor(len / 24));
|
||||
for (let i = 0; i < len; i += step) {
|
||||
const seg = segments[i];
|
||||
if (!Array.isArray(seg)) continue;
|
||||
for (let j = 0; j < 4; j += 1) {
|
||||
h ^= Math.round((Number(seg[j]) || 0) * 10) & 0xffff;
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${item?.type || ""}|${version}|${len}|${h >>> 0}|${extra}`;
|
||||
}
|
||||
|
||||
function getSegmentPath(item, segments, extra = "") {
|
||||
if (typeof Path2D === "undefined" || !Array.isArray(segments)) return null;
|
||||
const key = segmentPathCacheKey(item, segments, extra);
|
||||
const cache = item._segmentRenderPathCache || (item._segmentRenderPathCache = Object.create(null));
|
||||
if (cache.key === key && cache.path) return cache.path;
|
||||
const path = new Path2D();
|
||||
for (const seg of segments) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
path.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
|
||||
path.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
|
||||
}
|
||||
cache.key = key;
|
||||
cache.path = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
function strokeSegments(ctx, item, segments, extra = "") {
|
||||
const path = getSegmentPath(item, segments, extra);
|
||||
if (path) {
|
||||
ctx.stroke(path);
|
||||
return;
|
||||
}
|
||||
ctx.beginPath();
|
||||
for (const seg of segments || []) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
|
||||
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function mechanicalRenderTier() {
|
||||
return window.TarinaiPerf.renderQualityTier();
|
||||
}
|
||||
|
||||
function itemShadowQualityTier() {
|
||||
return window.TarinaiPerf?.shadowQualityTier?.() || "high";
|
||||
}
|
||||
|
||||
function itemShadowsEnabled() {
|
||||
return itemShadowQualityTier() !== "off";
|
||||
}
|
||||
|
||||
function drawRopeParticlePath(ctx, particles, ox, oy) {
|
||||
if (!Array.isArray(particles) || particles.length < 2) return false;
|
||||
const tier = mechanicalRenderTier();
|
||||
const stride = tier === "low" && particles.length > 10 ? 2 : 1;
|
||||
ctx.moveTo((Number(particles[0].x) || 0) - ox, (Number(particles[0].y) || 0) - oy);
|
||||
for (let i = stride; i < particles.length - 1; i += stride) {
|
||||
ctx.lineTo((Number(particles[i].x) || 0) - ox, (Number(particles[i].y) || 0) - oy);
|
||||
}
|
||||
const last = particles[particles.length - 1];
|
||||
ctx.lineTo((Number(last.x) || 0) - ox, (Number(last.y) || 0) - oy);
|
||||
return true;
|
||||
}
|
||||
function drawRopeRuntimePath(ctx, particles, item, pb, ax, ay, bx, by) {
|
||||
if (particles && drawRopeParticlePath(ctx, particles, item.x, item.y)) return true;
|
||||
const midX = Number(pb?.linkScalar?.(item, "midX", NaN));
|
||||
const midY = Number(pb?.linkScalar?.(item, "midY", NaN));
|
||||
const mx = Number.isFinite(midX) ? midX - item.x : (ax + bx) * 0.5;
|
||||
const my = Number.isFinite(midY) ? midY - item.y : (ay + by) * 0.5;
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.quadraticCurveTo(mx, my, bx, by);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,82 +1,6 @@
|
|||
"use strict";
|
||||
|
||||
// Item field rendering. Kept separate from construction and lifecycle logic.
|
||||
|
||||
function segmentPathCacheKey(item, segments, extra = "") {
|
||||
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
|
||||
const len = Array.isArray(segments) ? segments.length : 0;
|
||||
let h = len * 2654435761;
|
||||
if (Array.isArray(segments)) {
|
||||
const step = Math.max(1, Math.floor(len / 24));
|
||||
for (let i = 0; i < len; i += step) {
|
||||
const seg = segments[i];
|
||||
if (!Array.isArray(seg)) continue;
|
||||
for (let j = 0; j < 4; j += 1) {
|
||||
h ^= Math.round((Number(seg[j]) || 0) * 10) & 0xffff;
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${item?.type || ""}|${version}|${len}|${h >>> 0}|${extra}`;
|
||||
}
|
||||
|
||||
function getSegmentPath(item, segments, extra = "") {
|
||||
if (typeof Path2D === "undefined" || !Array.isArray(segments)) return null;
|
||||
const key = segmentPathCacheKey(item, segments, extra);
|
||||
const cache = item._segmentRenderPathCache || (item._segmentRenderPathCache = Object.create(null));
|
||||
if (cache.key === key && cache.path) return cache.path;
|
||||
const path = new Path2D();
|
||||
for (const seg of segments) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
path.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
|
||||
path.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
|
||||
}
|
||||
cache.key = key;
|
||||
cache.path = path;
|
||||
return path;
|
||||
}
|
||||
|
||||
function strokeSegments(ctx, item, segments, extra = "") {
|
||||
const path = getSegmentPath(item, segments, extra);
|
||||
if (path) {
|
||||
ctx.stroke(path);
|
||||
return;
|
||||
}
|
||||
ctx.beginPath();
|
||||
for (const seg of segments || []) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
ctx.moveTo(Number(seg[0]) || 0, Number(seg[1]) || 0);
|
||||
ctx.lineTo(Number(seg[2]) || 0, Number(seg[3]) || 0);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function mechanicalRenderTier() {
|
||||
return window.TarinaiPerf.renderQualityTier();
|
||||
}
|
||||
|
||||
function itemShadowQualityTier() {
|
||||
return window.TarinaiPerf?.shadowQualityTier?.() || "high";
|
||||
}
|
||||
|
||||
function itemShadowsEnabled() {
|
||||
return itemShadowQualityTier() !== "off";
|
||||
}
|
||||
|
||||
function drawRopeParticlePath(ctx, particles, ox, oy) {
|
||||
if (!Array.isArray(particles) || particles.length < 2) return false;
|
||||
const tier = mechanicalRenderTier();
|
||||
const stride = tier === "low" && particles.length > 10 ? 2 : 1;
|
||||
ctx.moveTo((Number(particles[0].x) || 0) - ox, (Number(particles[0].y) || 0) - oy);
|
||||
for (let i = stride; i < particles.length - 1; i += stride) {
|
||||
ctx.lineTo((Number(particles[i].x) || 0) - ox, (Number(particles[i].y) || 0) - oy);
|
||||
}
|
||||
const last = particles[particles.length - 1];
|
||||
ctx.lineTo((Number(last.x) || 0) - ox, (Number(last.y) || 0) - oy);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Object.assign(Item.prototype, {
|
||||
draw(ctx, t, lighting = null) {
|
||||
const lightState = lighting || getLightingState(world);
|
||||
|
|
@ -710,67 +634,51 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.restore();
|
||||
} else if (this.type === "fire") {
|
||||
const r = this.r || 14;
|
||||
const pulse = 0.82 + Math.sin(t * 10 + this.seed) * 0.12;
|
||||
const phase = t * 7.0 + this.seed;
|
||||
ctx.save();
|
||||
if (itemShadowsEnabled()) {
|
||||
ctx.shadowColor = "rgba(255,104,28,0.42)";
|
||||
ctx.shadowBlur = 12;
|
||||
}
|
||||
ctx.globalAlpha *= 0.88;
|
||||
const grad = ctx.createRadialGradient(0, r * 0.22, r * 0.18, 0, 0, r * 1.55);
|
||||
grad.addColorStop(0, "rgba(255,238,112,0.94)");
|
||||
grad.addColorStop(0.36, "rgba(255,116,36,0.86)");
|
||||
grad.addColorStop(1, "rgba(198,32,16,0.08)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, r * 0.18, r * 1.25 * pulse, r * 0.72, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(255,96,24,0.90)";
|
||||
ctx.strokeStyle = "rgba(255,224,86,0.76)";
|
||||
ctx.lineWidth = Math.max(1.2, r * 0.10);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = -Math.PI / 2 + (i - 1.5) * 0.36 + Math.sin(t * 6 + this.seed + i) * 0.10;
|
||||
ctx.globalAlpha *= 0.90;
|
||||
if (typeof drawSimpleFireShape === "function") {
|
||||
drawSimpleFireShape(ctx, 0, 0, r * 1.12, { phase, shadow: itemShadowsEnabled() });
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(236,46,24,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.cos(a + 1.7) * r * 0.38, r * 0.48);
|
||||
ctx.quadraticCurveTo(Math.cos(a) * r * (0.45 + i * 0.08), -r * 0.10, Math.cos(a) * r * 0.38, -r * (1.18 + i * 0.13) * pulse);
|
||||
ctx.quadraticCurveTo(Math.cos(a - 0.7) * r * 0.20, -r * 0.20, Math.cos(a - 1.4) * r * 0.36, r * 0.48);
|
||||
ctx.closePath();
|
||||
ctx.ellipse(0, -r * 0.18, r * 0.48, r * 0.96, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = "rgba(255,236,106,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -r * 0.30, r * 0.42, r * 0.82 * pulse, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
} else if (this.type === "firecracker" || this.type === "flame_firecracker") {
|
||||
const flameMode = this.type === "flame_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;
|
||||
const timerText = String(Math.max(0, Math.ceil(this.fuseTimer ?? 5)));
|
||||
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
|
||||
ctx.fillStyle = flash > 0.65 ? "#ffed77" : (flameMode ? "#e76d29" : "#d94b43");
|
||||
ctx.strokeStyle = "rgba(96,48,34,0.78)";
|
||||
ctx.fillStyle = flash > 0.65 ? "#ffed77" : (flameMode ? "#d8241f" : "#5a4744");
|
||||
ctx.strokeStyle = flameMode ? "rgba(96,18,18,0.88)" : "rgba(35,30,29,0.88)";
|
||||
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 = flameMode ? "#ffbb43" : "#f4d15b";
|
||||
ctx.fillStyle = flameMode ? "#ff6b32" : "#897065";
|
||||
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.strokeStyle = "rgba(48,38,31,0.86)";
|
||||
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.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : (flameMode ? "rgba(255,80,40,0.78)" : "rgba(188,148,88,0.62)");
|
||||
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.fillStyle = "rgba(255,246,214,0.92)";
|
||||
ctx.strokeStyle = flameMode ? "rgba(84,18,14,0.92)" : "rgba(24,22,21,0.92)";
|
||||
ctx.lineWidth = 2.6;
|
||||
ctx.font = `bold ${Math.max(10, this.r * 0.72)}px ui-rounded, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(flameMode ? "F" : String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.strokeText(timerText, 0, this.r * 0.02);
|
||||
ctx.fillText(timerText, 0, this.r * 0.02);
|
||||
} else if (this.type === "fence_v" || this.type === "fence_h" || this.type === "glass_wall" || this.type === "bounce_fence" || this.type === "bounce_fence_v" || this.type === "gate_fence") {
|
||||
const bounce = this.type === "bounce_fence" || this.type === "bounce_fence_v";
|
||||
const gate = this.type === "gate_fence";
|
||||
|
|
@ -1020,26 +928,12 @@ draw(ctx, t, lighting = null) {
|
|||
ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)";
|
||||
ctx.lineWidth = 4.2;
|
||||
ctx.beginPath();
|
||||
if (particles) {
|
||||
drawRopeParticlePath(ctx, particles, this.x, this.y);
|
||||
} else {
|
||||
const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5;
|
||||
const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5;
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.quadraticCurveTo(mx, my, bx, by);
|
||||
}
|
||||
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
if (particles) {
|
||||
drawRopeParticlePath(ctx, particles, this.x, this.y);
|
||||
} else {
|
||||
const mx = Number.isFinite(Number(pb?.linkScalar?.(this, "midX", NaN))) ? Number(pb.linkScalar(this, "midX", 0)) - this.x : (ax + bx) * 0.5;
|
||||
const my = Number.isFinite(Number(pb?.linkScalar?.(this, "midY", NaN))) ? Number(pb.linkScalar(this, "midY", 0)) - this.y : (ay + by) * 0.5;
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.quadraticCurveTo(mx, my, bx, by);
|
||||
}
|
||||
drawRopeRuntimePath(ctx, particles, this, pb, ax, ay, bx, by);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = this.type === "rod" ? (styleNight ? "rgba(120,112,98,0.96)" : "rgba(114,96,72,0.96)") : (styleNight ? "rgba(112,84,54,0.95)" : "rgba(162,112,63,0.95)");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use strict";
|
||||
|
||||
// Tool definitions, item traits, categories, and basic type helpers.
|
||||
// Tool definitions and basic type helpers.
|
||||
(function (global) {
|
||||
const TOOL_DEFINITIONS = Object.freeze({
|
||||
observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", 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" },
|
||||
|
|
@ -47,7 +47,7 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
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\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: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\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" },
|
||||
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: true, scalable: true, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u89e6\u308c\u308b\u3068\u71c3\u3048\u79fb\u308a\u3001\u8349\u3084\u500b\u4f53\u306b\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002\u6c34\u6ef4\u3084\u96e8\u3001\u4f4e\u6e29\u3067\u6d88\u3048\u3084\u3059\u3044\u3002" },
|
||||
fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true },
|
||||
flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u30bf\u30a4\u30de\u30fc\u5f8c\u306b\u7206\u767a\u305b\u305a\u3001\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\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" },
|
||||
|
|
@ -68,21 +68,21 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
});
|
||||
|
||||
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
|
||||
hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "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", "grass_bed"]),
|
||||
draggable: new Set(["ball", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
});
|
||||
const TOOL_METADATA = global.TarinaiItemToolMetadata;
|
||||
if (!TOOL_METADATA) throw new Error("TarinaiItemToolMetadata must be loaded before item_tool_definitions.js");
|
||||
const {
|
||||
ITEM_TRAITS,
|
||||
FENCE_ITEM_TYPES,
|
||||
MECHANICAL_ITEM_TYPES,
|
||||
PHYSICS_TOOL_IDS,
|
||||
ROTATABLE_ITEM_TYPES,
|
||||
PIN_BEHAVIORS,
|
||||
TOOL_CATEGORIES,
|
||||
TOOL_SIZE_ORDER,
|
||||
TOOL_SIZE_LABELS,
|
||||
TOOL_SIZE_SCALES,
|
||||
} = TOOL_METADATA;
|
||||
|
||||
|
||||
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
|
||||
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
|
||||
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
|
||||
function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); }
|
||||
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
|
||||
function defaultItemAngle(type = "") {
|
||||
|
|
@ -116,24 +116,12 @@ function lodgedPinFor(tarinai = null, worldRef = null) {
|
|||
function lodgedPinBehaviorFor(tarinai = null, worldRef = null) { return pinBehaviorFor(lodgedPinFor(tarinai, worldRef)?.type); }
|
||||
function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lodgedPinBehaviorFor(tarinai)?.[effectKey]); }
|
||||
|
||||
const PIN_BEHAVIORS = Object.freeze({
|
||||
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
|
||||
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
|
||||
});
|
||||
|
||||
function pinBehaviorFor(type = "") {
|
||||
return PIN_BEHAVIORS[String(type || "")] || null;
|
||||
}
|
||||
|
||||
|
||||
const TOOL_CATEGORIES = Object.freeze([
|
||||
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] },
|
||||
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "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", "slave_chain", "golden_crown"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "dry_ice", "stove", "ball", "signboard"] },
|
||||
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
|
||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo", "shoot", "ant_nest"] },
|
||||
]);
|
||||
|
||||
function toolCategories() {
|
||||
return [...TOOL_CATEGORIES].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
|
@ -165,9 +153,6 @@ 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";
|
||||
|
|
|
|||
56
js/item_tool_metadata.js
Normal file
56
js/item_tool_metadata.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use strict";
|
||||
|
||||
// Static item/tool metadata shared by tool definitions and UI helpers.
|
||||
(function (global) {
|
||||
const ITEM_TRAITS = Object.freeze({
|
||||
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
|
||||
hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "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", "grass_bed"]),
|
||||
draggable: new Set(["ball", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
|
||||
});
|
||||
|
||||
|
||||
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
|
||||
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
|
||||
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
|
||||
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
const TOOL_CATEGORIES = Object.freeze([
|
||||
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] },
|
||||
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "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", "slave_chain", "golden_crown"] },
|
||||
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "dry_ice", "stove", "ball", "signboard"] },
|
||||
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
|
||||
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] },
|
||||
]);
|
||||
|
||||
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 });
|
||||
|
||||
global.TarinaiItemToolMetadata = Object.freeze({
|
||||
ITEM_TRAITS,
|
||||
FENCE_ITEM_TYPES,
|
||||
MECHANICAL_ITEM_TYPES,
|
||||
PHYSICS_TOOL_IDS,
|
||||
ROTATABLE_ITEM_TYPES,
|
||||
PIN_BEHAVIORS,
|
||||
TOOL_CATEGORIES,
|
||||
TOOL_SIZE_ORDER,
|
||||
TOOL_SIZE_LABELS,
|
||||
TOOL_SIZE_SCALES,
|
||||
});
|
||||
|
||||
Object.assign(global, {
|
||||
TOOL_SIZE_ORDER,
|
||||
TOOL_SIZE_LABELS,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -28,7 +28,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
}
|
||||
if (type === "signboard") {
|
||||
item.text = "";
|
||||
item.textEditedAt = -999;
|
||||
}
|
||||
if (type === "duplicator") {
|
||||
item.storedFoodType = "";
|
||||
|
|
@ -84,7 +83,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
}
|
||||
if (type === "ant_corpse") {
|
||||
item.amount = 24;
|
||||
item.workerSprite = "ant_worker";
|
||||
item.decayTimer = 0;
|
||||
}
|
||||
if (type === "firecracker" || type === "flame_firecracker") {
|
||||
|
|
@ -115,14 +113,7 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
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;
|
||||
globalThis.TarinaiPinAttachmentSystem?.resetLooseState?.(item);
|
||||
item.deletable = true;
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +123,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
}
|
||||
if (type === "gate_fence") {
|
||||
item.gateOpen = false;
|
||||
item.gateLastToggleAt = -999;
|
||||
}
|
||||
|
||||
if (type === "poison_block") {
|
||||
|
|
@ -171,7 +161,6 @@ function initializeItemTypeState(item, type, x, y) {
|
|||
hazard: null,
|
||||
sleep: { awakeUntil: 0 },
|
||||
};
|
||||
item.rotatorEditorOpenAt = -999;
|
||||
item.amount = 999;
|
||||
}
|
||||
if (type === "rope" || type === "rod") {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// helpers and the item scheduler both read this single policy instead of
|
||||
// duplicating item-type timing rules.
|
||||
(function (global) {
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "flame_firecracker", "fire", "robot_cleaner"]);
|
||||
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]);
|
||||
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
|
||||
const SCHEDULED_ITEM_TYPES = Object.freeze([
|
||||
"ball", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo",
|
||||
|
|
@ -29,6 +29,12 @@
|
|||
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.type === "fire") {
|
||||
const fireCount = item.world?.itemsOfType?.("fire")?.length || 0;
|
||||
if (fireCount > 64) return mobile ? 0.55 : 0.35;
|
||||
if (fireCount > 36) return mobile ? 0.42 : 0.25;
|
||||
return mobile ? 0.30 : 0.18;
|
||||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -60,12 +60,10 @@
|
|||
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;
|
||||
|
|
@ -104,8 +102,7 @@
|
|||
if (!item || item.dead) continue;
|
||||
const interval = itemUpdateInterval(item);
|
||||
if (!Number.isFinite(interval)) {
|
||||
item._schedulerRealtime = false;
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
if (interval <= 0) {
|
||||
global.TarinaiItemRuntime.updateOne(item, dt, worldRef);
|
||||
|
|
@ -135,8 +132,7 @@
|
|||
if (!Number.isFinite(interval)) continue;
|
||||
if (interval <= 0) {
|
||||
state.realtime.push(item);
|
||||
item._schedulerRealtime = true;
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
const elapsed = Math.min(10, Math.max(0.001, now - (item._schedulerLastAt ?? entry.time - interval)));
|
||||
global.TarinaiItemRuntime.updateOne(item, elapsed, worldRef);
|
||||
|
|
@ -156,12 +152,6 @@
|
|||
if (mobile && tier !== "high") return { maxPairs: 112, maxLinks: 72, postMaxPairs: 28, maxSubsteps: 2 };
|
||||
if (mobile) return { maxPairs: 150, maxLinks: 96, postMaxPairs: 40, maxSubsteps: 2 };
|
||||
if (tier === "low") return { maxPairs: 140, maxLinks: 90, postMaxPairs: 36, maxSubsteps: 2 };
|
||||
if (tier === "medium" || tier === "mid") {
|
||||
const crowded = itemCount > 64 && activeHint <= 2;
|
||||
return crowded
|
||||
? { maxPairs: 150, maxLinks: 105, postMaxPairs: 44, maxSubsteps: 2 }
|
||||
: { maxPairs: 190, maxLinks: 130, postMaxPairs: 60, maxSubsteps: 3 };
|
||||
}
|
||||
return itemCount > 80
|
||||
? { maxPairs: 240, maxLinks: 170, postMaxPairs: 78, maxSubsteps: 4 }
|
||||
: { maxPairs: 300, maxLinks: 210, postMaxPairs: 100, maxSubsteps: 5 };
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ loadImages((done, total) => {
|
|||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=39.15.92";
|
||||
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js";
|
||||
navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,85 +3,27 @@
|
|||
// Layer: physics/mechanical-body
|
||||
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
|
||||
(function (global) {
|
||||
const footprints = global.TarinaiCollisionFootprints;
|
||||
function num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function rectCorners(rect, padding = 0) {
|
||||
const pad = Math.max(0, num(padding));
|
||||
if (!rect) return [];
|
||||
if (!rect.oriented) {
|
||||
return [
|
||||
{ x: num(rect.left) - pad, y: num(rect.top) - pad },
|
||||
{ x: num(rect.right) + pad, y: num(rect.top) - pad },
|
||||
{ x: num(rect.right) + pad, y: num(rect.bottom) + pad },
|
||||
{ x: num(rect.left) - pad, y: num(rect.bottom) + pad },
|
||||
];
|
||||
}
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
|
||||
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
|
||||
const hw = num(rect.halfW) + pad;
|
||||
const hh = num(rect.halfH) + pad;
|
||||
return [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]].map(([lx, ly]) => ({
|
||||
x: num(rect.cx) + lx * c - ly * ss,
|
||||
y: num(rect.cy) + lx * ss + ly * c,
|
||||
}));
|
||||
return footprints?.rectCorners?.(rect, padding) || [];
|
||||
}
|
||||
|
||||
function rectAxes(rect) {
|
||||
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
|
||||
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
|
||||
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
|
||||
return [{ x: c, y: ss }, { x: -ss, y: c }];
|
||||
return footprints?.rectAxes?.(rect) || [{ x: 1, y: 0 }, { x: 0, y: 1 }];
|
||||
}
|
||||
|
||||
function projectPoints(points, axis) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points || []) {
|
||||
const v = num(p.x) * axis.x + num(p.y) * axis.y;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return { min, max };
|
||||
return footprints?.projectPoints?.(points, axis) || { min: Infinity, max: -Infinity };
|
||||
}
|
||||
|
||||
function aabbOverlap(a, b, padding = 0) {
|
||||
const pad = Math.max(0, num(padding));
|
||||
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
|
||||
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
|
||||
}
|
||||
|
||||
function rectOverlapInfo(a, b, padding = 0) {
|
||||
if (!aabbOverlap(a, b, padding)) return null;
|
||||
const ptsA = rectCorners(a, padding);
|
||||
const ptsB = rectCorners(b, padding);
|
||||
if (!ptsA.length || !ptsB.length) return null;
|
||||
let bestOverlap = Infinity;
|
||||
let bestAxis = null;
|
||||
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
|
||||
const len = Math.hypot(axis.x, axis.y) || 1;
|
||||
const n = { x: axis.x / len, y: axis.y / len };
|
||||
const pa = projectPoints(ptsA, n);
|
||||
const pb = projectPoints(ptsB, n);
|
||||
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
|
||||
if (overlap <= 0) return null;
|
||||
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
|
||||
}
|
||||
if (!bestAxis) return null;
|
||||
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
|
||||
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
|
||||
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
|
||||
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
|
||||
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
|
||||
return {
|
||||
overlap: bestOverlap,
|
||||
nx: bestAxis.x,
|
||||
ny: bestAxis.y,
|
||||
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
|
||||
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
|
||||
};
|
||||
return footprints?.rectOverlapInfo?.(a, b, padding) || null;
|
||||
}
|
||||
function typeOf(itemOrType) { return typeof itemOrType === "string" ? itemOrType : String(itemOrType?.type || ""); }
|
||||
function isMechanicalType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" || t === "poison_block" || t === "reciprocator"; }
|
||||
|
|
@ -98,6 +40,16 @@
|
|||
}
|
||||
function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); }
|
||||
function usingStepScratch(item) { return item?._physicsStepScratch === true; }
|
||||
function runMechanicalStep(item, commitReason, fn) {
|
||||
const prevScratch = item._physicsStepScratch === true;
|
||||
item._physicsStepScratch = true;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
item._physicsStepScratch = prevScratch;
|
||||
commitBody(item, commitReason);
|
||||
}
|
||||
}
|
||||
function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); }
|
||||
function bodyVelocity(item) { return bodyOf(item)?.velocity || null; }
|
||||
function bodyMotor(item) { return bodyOf(item)?.motor || null; }
|
||||
|
|
@ -311,10 +263,7 @@
|
|||
}
|
||||
|
||||
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
||||
const c = Math.cos(angle), s = Math.sin(angle);
|
||||
const ex = Math.abs(c) * halfW + Math.abs(s) * halfH;
|
||||
const ey = Math.abs(s) * halfW + Math.abs(c) * halfH;
|
||||
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
|
||||
return global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
|
||||
}
|
||||
|
||||
function worldSegments(item) {
|
||||
|
|
@ -831,7 +780,7 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function reverseReciprocatorOnMechanicalContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
|
||||
function reverseReciprocatorOnContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
|
||||
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
|
||||
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
|
||||
// Only physical body contacts should interrupt the rail. Static fences and
|
||||
|
|
@ -865,7 +814,7 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function brakePoweredRotatorOnMechanicalContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
|
||||
function brakeRotatorOnContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
|
||||
if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false;
|
||||
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
|
||||
if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false;
|
||||
|
|
@ -958,10 +907,6 @@
|
|||
return resolveMechanicalCircleContacts(item, dt, worldRef, reach(item) + 96);
|
||||
}
|
||||
|
||||
function reverseReciprocatorOnCircleContact(item, obj, nx, ny, dt = 0.016, worldRef = null, reason = "circle-contact") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function resolveMechanicalCircleContacts(item, dt = 0.016, worldRef = null, radius = null) {
|
||||
if (!item || item.dead || !isMechanicalType(item) || !worldRef?.items) return false;
|
||||
|
|
@ -1004,7 +949,7 @@
|
|||
applyRailCorrection(item, -nx, -ny, best.overlap, 0.18);
|
||||
} else {
|
||||
applyImpulse(item, best.x, best.y, -nx * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), -ny * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), item.type === "poison_block" ? 0.58 : 0.34);
|
||||
if (item.type === "rotator") brakePoweredRotatorOnMechanicalContact(item, dt, worldRef, "physical-circle");
|
||||
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "physical-circle");
|
||||
}
|
||||
contacts += 1;
|
||||
changed = true;
|
||||
|
|
@ -1072,10 +1017,10 @@
|
|||
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0);
|
||||
applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72);
|
||||
applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72);
|
||||
if (a.type === "reciprocator") reverseReciprocatorOnMechanicalContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
|
||||
if (b.type === "reciprocator") reverseReciprocatorOnMechanicalContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
|
||||
if (a.type === "rotator") brakePoweredRotatorOnMechanicalContact(a, dt, worldRef, "mechanical");
|
||||
if (b.type === "rotator") brakePoweredRotatorOnMechanicalContact(b, dt, worldRef, "mechanical");
|
||||
if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
|
||||
if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
|
||||
if (a.type === "rotator") brakeRotatorOnContact(a, dt, worldRef, "mechanical");
|
||||
if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical");
|
||||
|
||||
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); }
|
||||
return true;
|
||||
|
|
@ -1124,8 +1069,8 @@
|
|||
const impulse = clamp(closing * 0.18 + info.overlap * 6.6 + 4.5, 3.5, separated ? 58 : 92);
|
||||
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10);
|
||||
applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82);
|
||||
if (item.type === "rotator") brakePoweredRotatorOnMechanicalContact(item, dt, worldRef, "fence");
|
||||
if (item.type === "reciprocator") reverseReciprocatorOnMechanicalContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
|
||||
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "fence");
|
||||
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1162,9 +1107,7 @@
|
|||
function updateRotator(item, dt, worldRef, opts = {}) {
|
||||
if (!item || item.dead || item.type !== "rotator") return false;
|
||||
applyBodyState(item);
|
||||
const prevScratch = item._physicsStepScratch === true;
|
||||
item._physicsStepScratch = true;
|
||||
try {
|
||||
return runMechanicalStep(item, "rotator-step", () => {
|
||||
item.amount = 999;
|
||||
if (item.playerHeld || item._heldByPlayer) return false;
|
||||
const powered = isPowered(item);
|
||||
|
|
@ -1196,10 +1139,7 @@
|
|||
item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt;
|
||||
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); }
|
||||
return true;
|
||||
} finally {
|
||||
item._physicsStepScratch = prevScratch;
|
||||
commitBody(item, "rotator-step");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function integrateReciprocatorPhase(item, total, stepDt, powered, reason = "rail-integrate") {
|
||||
|
|
@ -1272,9 +1212,7 @@
|
|||
function updateReciprocator(item, dt, worldRef, opts = {}) {
|
||||
if (!item || item.dead || item.type !== "reciprocator") return false;
|
||||
applyBodyState(item);
|
||||
const prevScratch = item._physicsStepScratch === true;
|
||||
item._physicsStepScratch = true;
|
||||
try {
|
||||
return runMechanicalStep(item, "reciprocator-step", () => {
|
||||
item.amount = 999;
|
||||
resetAnchor(item);
|
||||
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
|
||||
|
|
@ -1315,10 +1253,7 @@
|
|||
return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
item._physicsStepScratch = prevScratch;
|
||||
commitBody(item, "reciprocator-step");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function constrainPassiveBlockInsideWorld(item, worldRef) {
|
||||
|
|
@ -1389,9 +1324,7 @@
|
|||
function updatePoisonBlock(item, dt, worldRef, opts = {}) {
|
||||
if (!item || item.dead || item.type !== "poison_block") return false;
|
||||
applyBodyState(item);
|
||||
const prevScratch = item._physicsStepScratch === true;
|
||||
item._physicsStepScratch = true;
|
||||
try {
|
||||
return runMechanicalStep(item, "poison-block-step", () => {
|
||||
item.amount = 999;
|
||||
item.r = Math.max(item.r || 64, Math.min(460, extent(item)));
|
||||
if (item.playerHeld || item._heldByPlayer) {
|
||||
|
|
@ -1437,10 +1370,7 @@
|
|||
}
|
||||
if (changed && worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("poison-block-motion"); }
|
||||
return changed;
|
||||
} finally {
|
||||
item._physicsStepScratch = prevScratch;
|
||||
commitBody(item, "poison-block-step");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1506,8 +1436,8 @@
|
|||
};
|
||||
}
|
||||
|
||||
function aabbOverlap(a, b, pad = 0) {
|
||||
return Boolean(a && b && a.left <= b.right + pad && a.right >= b.left - pad && a.top <= b.bottom + pad && a.bottom >= b.top - pad);
|
||||
function bodyAabbOverlap(a, b, pad = 0) {
|
||||
return footprints?.aabbOverlap?.(a, b, pad) ?? false;
|
||||
}
|
||||
|
||||
function pairKey(a, b) {
|
||||
|
|
@ -1689,7 +1619,7 @@
|
|||
const key = pairKey(a.item, b.item);
|
||||
if (seen[key]) continue;
|
||||
seen[key] = true;
|
||||
if (!aabbOverlap(a.aabb, b.aabb, 8)) continue;
|
||||
if (!bodyAabbOverlap(a.aabb, b.aabb, 8)) continue;
|
||||
const score = contactCandidateScore(a, b);
|
||||
if (!Number.isFinite(score)) continue;
|
||||
pairs += 1;
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@
|
|||
function renderQualityTier() { return visualSettings.details; }
|
||||
function shadowQualityTier() { return visualSettings.shadows; }
|
||||
function effectsQualityTier() { return visualSettings.effects; }
|
||||
function simulationQualityTier() { return "high"; }
|
||||
function simulationQualityTier() { return visualSettings.details; }
|
||||
function dprScaleValue() { return dprScaleForDetails(visualSettings.details); }
|
||||
function consumeResizeRequest() {
|
||||
const v = resizeRequested;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,27 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit) {
|
||||
const toward = vx * nx + vy * ny;
|
||||
if (toward < 0) {
|
||||
obj.vx = vx - (1 + restitution) * toward * nx;
|
||||
obj.vy = vy - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
obj.vx = vx + nx * fallbackImpulse;
|
||||
obj.vy = vy + ny * fallbackImpulse;
|
||||
}
|
||||
if (minBounceSpeed > 0) {
|
||||
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
|
||||
if (normalSpeed < minBounceSpeed) {
|
||||
const add = minBounceSpeed - normalSpeed;
|
||||
obj.vx = (obj.vx || 0) + nx * add;
|
||||
obj.vy = (obj.vy || 0) + ny * add;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(obj.spinVelocity) && spinKick) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * spinKick, -spinLimit, spinLimit);
|
||||
return true;
|
||||
}
|
||||
|
||||
function bounceInsideWorld(obj, worldRef, opts = {}) {
|
||||
if (!obj || !worldRef) return false;
|
||||
const p = Math.max(opts.padding ?? 28, CONFIG.worldPadding || 30);
|
||||
|
|
@ -112,14 +133,9 @@
|
|||
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
|
||||
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
|
||||
const minBounceSpeed = Math.max(0, Number(opts.minBounceSpeed ?? rect.minBounceSpeed ?? 0) || 0);
|
||||
const ensureMinNormalSpeed = (nx, ny) => {
|
||||
if (!(minBounceSpeed > 0)) return;
|
||||
const normalSpeed = (obj.vx || 0) * nx + (obj.vy || 0) * ny;
|
||||
if (normalSpeed >= minBounceSpeed) return;
|
||||
const add = minBounceSpeed - normalSpeed;
|
||||
obj.vx = (obj.vx || 0) + nx * add;
|
||||
obj.vy = (obj.vy || 0) + ny * add;
|
||||
};
|
||||
const fallbackImpulse = Math.max(0, Number(opts.fallbackImpulse ?? 36) || 0);
|
||||
const spinKick = Number(opts.spinKick ?? 7.0);
|
||||
const spinLimit = Math.max(0, Number(opts.spinLimit ?? 46) || 46);
|
||||
const cx = clamp(obj.x, rect.left, rect.right);
|
||||
const cy = clamp(obj.y, rect.top, rect.bottom);
|
||||
let dx = obj.x - cx;
|
||||
|
|
@ -147,23 +163,13 @@
|
|||
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) obj.x = expanded.left - 0.5;
|
||||
else if (nx > 0) obj.x = expanded.right + 0.5;
|
||||
if (ny < 0) obj.y = expanded.top - 0.5;
|
||||
else if (ny > 0) obj.y = expanded.bottom + 0.5;
|
||||
if (nx) obj.y = clamp(obj.y, expanded.top, expanded.bottom);
|
||||
if (ny) obj.x = clamp(obj.x, expanded.left, expanded.right);
|
||||
if (toward < 0) {
|
||||
obj.vx = vx - (1 + restitution) * toward * nx;
|
||||
obj.vy = vy - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
obj.vx = vx + nx * 36;
|
||||
obj.vy = vy + ny * 36;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
|
||||
return true;
|
||||
return applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit);
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const left = Math.abs(obj.x - rect.left);
|
||||
|
|
@ -181,22 +187,12 @@
|
|||
const ny = dy / d;
|
||||
const vx = obj.vx || 0;
|
||||
const vy = obj.vy || 0;
|
||||
const toward = vx * nx + vy * ny;
|
||||
obj.x = cx + nx * (hitRadius + 0.5);
|
||||
obj.y = cy + ny * (hitRadius + 0.5);
|
||||
if (toward < 0) {
|
||||
obj.vx = vx - (1 + restitution) * toward * nx;
|
||||
obj.vy = vy - (1 + restitution) * toward * ny;
|
||||
} else {
|
||||
obj.vx = vx + nx * 36;
|
||||
obj.vy = vy + ny * 36;
|
||||
}
|
||||
ensureMinNormalSpeed(nx, ny);
|
||||
if (Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp((obj.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 7.0, -46, 46);
|
||||
return true;
|
||||
return applyNormalBounce(obj, nx, ny, vx, vy, restitution, fallbackImpulse, minBounceSpeed, spinKick, spinLimit);
|
||||
}
|
||||
|
||||
function resolveKinematicBounceFenceCollision(item, worldRef, dt = 0.016, opts = {}) {
|
||||
function resolveFenceBounce(item, worldRef, dt = 0.016, opts = {}) {
|
||||
if (!item || !worldRef?.nearbySolidObstacleRects) return false;
|
||||
const speed = Math.hypot(item.vx || 0, item.vy || 0);
|
||||
const radius = Math.max(4, (item.r || item.radius || 12) + (opts.obstaclePadding ?? 2));
|
||||
|
|
@ -238,7 +234,7 @@
|
|||
item.x += (item.vx || 0) * dt;
|
||||
item.y += (item.vy || 0) * dt;
|
||||
bounceInsideWorld(item, worldRef, opts);
|
||||
resolveKinematicBounceFenceCollision(item, worldRef, dt, opts);
|
||||
resolveFenceBounce(item, worldRef, dt, opts);
|
||||
const ice = (worldRef?.groundType || "soil") === "ice";
|
||||
const baseFriction = ice ? Math.max(Number(opts.frictionBase ?? 0.68), 0.93) : (opts.frictionBase ?? 0.68);
|
||||
const frictionRate = ice ? Math.min(Number(opts.frictionRate ?? 1), 0.55) : (opts.frictionRate ?? 1);
|
||||
|
|
|
|||
|
|
@ -51,17 +51,252 @@
|
|||
}
|
||||
function pointSegmentDistance(p, seg) {
|
||||
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
|
||||
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
|
||||
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
|
||||
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
|
||||
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
|
||||
return global.TarinaiGeometry.pointSegmentDistance(p.x, p.y, seg[0], seg[1], seg[2], seg[3]);
|
||||
}
|
||||
|
||||
function canvasLocalPoint(canvas, e, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
|
||||
if (!canvas || !e) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = canvas.width / Math.max(1, rect.width);
|
||||
const sy = canvas.height / Math.max(1, rect.height);
|
||||
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
|
||||
}
|
||||
|
||||
function cleanSegments(segments = [], opts = {}) {
|
||||
const limit = Math.max(1, Number(opts.limit || 96) || 96);
|
||||
const bound = Math.max(1, Number(opts.bound || 420) || 420);
|
||||
const minLength = Math.max(0, Number(opts.minLength || 4) || 4);
|
||||
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
|
||||
const out = [];
|
||||
for (const seg of segments || []) {
|
||||
if (!Array.isArray(seg) || seg.length < 4) continue;
|
||||
const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0));
|
||||
const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0));
|
||||
const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0));
|
||||
const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0));
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
if (!out.length) out.push(fallback.slice(0, 4));
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
|
||||
if (!ctx || !canvas) return false;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#f7f3ea";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.translate(cx, cy);
|
||||
ctx.strokeStyle = "rgba(100,80,60,0.16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
|
||||
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
|
||||
ctx.strokeStyle = "rgba(70,60,50,0.38)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawSegments(ctx, segments = [], opts = {}) {
|
||||
if (!ctx) return false;
|
||||
const lineWidth = Math.max(1, Number(opts.lineWidth || 12) || 12);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = opts.strokeStyle || "rgba(80,150,205,0.92)";
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
if (opts.highlight !== false) {
|
||||
ctx.strokeStyle = opts.highlightStyle || "rgba(255,255,255,0.58)";
|
||||
ctx.lineWidth = Math.max(1.3, lineWidth * 0.20);
|
||||
ctx.beginPath();
|
||||
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function eraseSegmentsAt(segments = [], point, radius) {
|
||||
const before = segments.length;
|
||||
const next = (segments || []).filter(seg => pointSegmentDistance(point, seg) > radius);
|
||||
return { segments: next, changed: next.length !== before };
|
||||
}
|
||||
|
||||
function syncInputPair(a, b, onChange = null) {
|
||||
if (!a || !b) return false;
|
||||
const sync = (from, to) => {
|
||||
to.value = from.value;
|
||||
if (typeof onChange === "function") onChange();
|
||||
};
|
||||
a.oninput = () => sync(a, b);
|
||||
b.oninput = () => sync(b, a);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function drawDraftSegment(ctx, draft, lineWidth, opts = {}) {
|
||||
if (!ctx || !draft) return false;
|
||||
ctx.setLineDash(opts.dash || [7, 5]);
|
||||
ctx.strokeStyle = opts.strokeStyle || "rgba(60,130,210,0.85)";
|
||||
ctx.lineWidth = Math.max(2, Number(lineWidth || 12) * 0.45);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(draft[0], draft[1]);
|
||||
ctx.lineTo(draft[2], draft[3]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function drawErasePreview(ctx, cx, cy, point, radius, opts = {}) {
|
||||
if (!ctx || !point) return false;
|
||||
ctx.save();
|
||||
ctx.translate(cx || 0, cy || 0);
|
||||
ctx.globalAlpha = opts.alpha ?? 0.42;
|
||||
ctx.strokeStyle = opts.strokeStyle || "rgba(210,70,60,0.86)";
|
||||
ctx.lineWidth = opts.lineWidth || 1.6;
|
||||
ctx.setLineDash(opts.dash || [4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
|
||||
function createSegmentEditorController(opts = {}) {
|
||||
const dialog = opts.dialog;
|
||||
const canvas = opts.canvas;
|
||||
const ctx = opts.ctx || canvas?.getContext?.("2d");
|
||||
if (!dialog || !canvas || !ctx) return null;
|
||||
const prefix = opts.prefix || "rotator";
|
||||
const cx = opts.cx ?? canvas.width / 2;
|
||||
const cy = opts.cy ?? canvas.height / 2;
|
||||
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
|
||||
let mode = "line";
|
||||
let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4);
|
||||
let draft = null;
|
||||
let drawing = false;
|
||||
|
||||
const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12);
|
||||
const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle;
|
||||
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); };
|
||||
const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy);
|
||||
|
||||
const draw = () => {
|
||||
drawEditorBase(ctx, canvas, cx, cy);
|
||||
if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() });
|
||||
const lw = lineWidth();
|
||||
drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" });
|
||||
drawDraftSegment(ctx, draft, lw, opts.draft || {});
|
||||
if (typeof opts.drawCenter === "function") opts.drawCenter(ctx, { cx, cy, lineWidth: lw, segments: segments.slice() });
|
||||
ctx.restore();
|
||||
return true;
|
||||
};
|
||||
|
||||
const setMode = (next) => {
|
||||
mode = next === "free" || next === "erase" ? next : "line";
|
||||
for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) btn.classList.toggle("active", btn.dataset[`${prefix}Mode`] === mode);
|
||||
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
|
||||
return mode;
|
||||
};
|
||||
|
||||
const eraseAt = (p) => {
|
||||
const radius = Math.max(1, typeof opts.eraseRadius === "function" ? opts.eraseRadius(lineWidth()) : Number(opts.eraseRadius || 24) || 24);
|
||||
const erased = eraseSegmentsAt(segments, p, radius);
|
||||
segments = erased.segments;
|
||||
if (erased.changed) { draft = null; draw(); return true; }
|
||||
draw();
|
||||
drawErasePreview(ctx, cx, cy, p, radius, opts.erasePreview || {});
|
||||
return false;
|
||||
};
|
||||
|
||||
const setTemplate = (name) => {
|
||||
const shared = templateSegments(name);
|
||||
if (shared) segments = shared.map(seg => seg.slice(0, 4));
|
||||
clean();
|
||||
draw();
|
||||
};
|
||||
|
||||
dialog.querySelectorAll(`[data-${prefix}-mode]`).forEach(btn => { btn.onclick = () => setMode(btn.dataset[`${prefix}Mode`]); });
|
||||
dialog.querySelectorAll(`[data-${prefix}-template]`).forEach(btn => { btn.onclick = () => setTemplate(btn.dataset[`${prefix}Template`]); });
|
||||
|
||||
canvas.onpointerdown = (e) => {
|
||||
canvas.setPointerCapture?.(e.pointerId);
|
||||
const p = toLocal(e);
|
||||
drawing = true;
|
||||
if (mode === "erase") { draft = null; eraseAt(p); return; }
|
||||
draft = [p.x, p.y, p.x, p.y];
|
||||
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
|
||||
draw();
|
||||
};
|
||||
canvas.onpointermove = (e) => {
|
||||
if (!drawing) return;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { eraseAt(p); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "free") {
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) return;
|
||||
const lx = last[2], ly = last[3];
|
||||
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
|
||||
last[2] = p.x; last[3] = p.y;
|
||||
segments.push([p.x, p.y, p.x, p.y]);
|
||||
}
|
||||
} else {
|
||||
draft[2] = p.x; draft[3] = p.y;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
canvas.onpointerup = (e) => {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { draft = null; draw(); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "line") {
|
||||
const seg = [draft[0], draft[1], p.x, p.y];
|
||||
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
|
||||
}
|
||||
draft = null;
|
||||
clean();
|
||||
draw();
|
||||
};
|
||||
|
||||
const undo = dialog.querySelector(`#${prefix}Undo`);
|
||||
const clear = dialog.querySelector(`#${prefix}Clear`);
|
||||
if (undo) undo.onclick = () => { segments.pop(); clean(); draw(); };
|
||||
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
|
||||
|
||||
const close = () => {
|
||||
canvas.style.cursor = "";
|
||||
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
|
||||
};
|
||||
|
||||
setMode(opts.initialMode || "line");
|
||||
return {
|
||||
cleanSegments: clean,
|
||||
close,
|
||||
draw,
|
||||
getSegments: () => segments.map(seg => seg.slice(0, 4)),
|
||||
setMode,
|
||||
setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); },
|
||||
};
|
||||
}
|
||||
|
||||
global.TarinaiPhysicsShapeEditorSystem = Object.freeze({
|
||||
decorateToolbar,
|
||||
templateSegments,
|
||||
pointSegmentDistance,
|
||||
canvasLocalPoint,
|
||||
cleanSegments,
|
||||
drawEditorBase,
|
||||
drawSegments,
|
||||
eraseSegmentsAt,
|
||||
syncInputPair,
|
||||
drawDraftSegment,
|
||||
drawErasePreview,
|
||||
createSegmentEditorController,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,19 @@
|
|||
if (worldRef) worldRef.drawListDirty = true;
|
||||
return anchor;
|
||||
}
|
||||
function resetLooseState(item) {
|
||||
if (!item) return item;
|
||||
item.pinState = "loose";
|
||||
item.pinTargetId = "";
|
||||
item.pinAttachAngle = 0;
|
||||
item.pinAttachDistance = 0;
|
||||
item.pinOffsetY = 0;
|
||||
item.pinDamageTick = 0;
|
||||
item.pinFallCheckTimer = 0;
|
||||
item.pinLogAt = -999;
|
||||
return item;
|
||||
}
|
||||
|
||||
function isAttachedToEntity(pin, entity) {
|
||||
if (!pin || !entity || !isPin(pin)) return false;
|
||||
if (entity.type === "ball") return pin.pinState === "ball_lodged" && pin.pinBallId === entity.id;
|
||||
|
|
@ -120,6 +133,7 @@
|
|||
tarinaiAnchor,
|
||||
applyTarinaiPose,
|
||||
applyBallPose,
|
||||
resetLooseState,
|
||||
drawAttachedForEntity,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
55
js/render.js
55
js/render.js
|
|
@ -12,6 +12,15 @@ function toolCursorIconDefinition(world) {
|
|||
return { tool, def, type: def.itemType || (def.placeable ? tool : "") };
|
||||
}
|
||||
|
||||
|
||||
function canvasEllipsizeText(ctx, text, maxW) {
|
||||
let s = String(text || "");
|
||||
if (!ctx || ctx.measureText(s).width <= maxW) return s;
|
||||
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}\u2026`;
|
||||
}
|
||||
TARINAI_RENDER_GLOBAL.canvasEllipsizeText = TARINAI_RENDER_GLOBAL.canvasEllipsizeText || canvasEllipsizeText;
|
||||
|
||||
function staticVersionedAssetPath(path) {
|
||||
const raw = String(path || "");
|
||||
if (!raw) return "";
|
||||
|
|
@ -46,6 +55,45 @@ function drawToolCursorIconFallback(ctx, icon, size) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function drawSimpleFireShape(ctx, x = 0, y = 0, size = 12, options = {}) {
|
||||
if (!ctx) return false;
|
||||
const s = Math.max(2, Number(size || 0) || 2);
|
||||
const phase = Number.isFinite(options.phase) ? options.phase : 0;
|
||||
const alpha = clamp(Number(options.alpha ?? 1) || 0, 0, 1);
|
||||
if (alpha <= 0.01) return false;
|
||||
const wobble = Math.sin(phase) * 0.08;
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.globalAlpha *= alpha;
|
||||
if (options.shadow !== false) {
|
||||
ctx.shadowColor = options.shadowColor || "rgba(255,72,24,0.34)";
|
||||
ctx.shadowBlur = Math.max(0, s * 0.34);
|
||||
}
|
||||
ctx.fillStyle = options.outer || "rgba(236,46,24,0.88)";
|
||||
ctx.strokeStyle = options.edge || "rgba(255,198,72,0.70)";
|
||||
ctx.lineWidth = Math.max(0.8, s * 0.08);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -s * (1.05 + wobble));
|
||||
ctx.bezierCurveTo(s * (0.58 + wobble), -s * 0.55, s * 0.50, s * 0.28, s * 0.10, s * 0.62);
|
||||
ctx.bezierCurveTo(s * 0.05, s * 0.40, -s * 0.08, s * 0.22, -s * 0.27, s * 0.10);
|
||||
ctx.bezierCurveTo(-s * 0.30, s * 0.40, -s * 0.18, s * 0.56, 0, s * 0.66);
|
||||
ctx.bezierCurveTo(-s * 0.56, s * 0.36, -s * 0.54, -s * 0.44, 0, -s * (1.05 + wobble));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = options.inner || "rgba(255,224,72,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s * 0.04, -s * 0.58);
|
||||
ctx.bezierCurveTo(s * 0.32, -s * 0.18, s * 0.24, s * 0.32, s * 0.02, s * 0.48);
|
||||
ctx.bezierCurveTo(-s * 0.26, s * 0.22, -s * 0.18, -s * 0.24, s * 0.04, -s * 0.58);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
TARINAI_RENDER_GLOBAL.drawSimpleFireShape = drawSimpleFireShape;
|
||||
|
||||
function buildToolCursorIconCanvas(tool, def, type, size) {
|
||||
const key = `${tool}:${type || "-"}:${size}`;
|
||||
const cached = toolCursorIconCache.get(key);
|
||||
|
|
@ -1589,12 +1637,7 @@ function drawPointerItemTooltip(ctx, world) {
|
|||
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}\u2026`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}\u2026`;
|
||||
};
|
||||
const ellipsize = (text, maxW) => canvasEllipsizeText(ctx, text, maxW);
|
||||
const wrapLine = (text, maxW) => {
|
||||
const s = String(text || "");
|
||||
if (ctx.measureText(s).width <= maxW) return [s];
|
||||
|
|
|
|||
344
js/robot_cleaner_system.js
Normal file
344
js/robot_cleaner_system.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"use strict";
|
||||
|
||||
// Runtime behavior and configuration helpers for robot cleaner items.
|
||||
(function (global) {
|
||||
|
||||
const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
|
||||
const ROBOT_MOVE_SPEED = 60;
|
||||
const ROBOT_HIGH_SPEED = 120;
|
||||
const ROBOT_RESCAN_INTERVAL = 2.0;
|
||||
const ROBOT_TARGET_BITS = Object.freeze({
|
||||
zunchi: 1 << 0,
|
||||
ant_corpse: 1 << 1,
|
||||
tarinai: 1 << 2,
|
||||
grass: 1 << 3,
|
||||
grass_bed: 1 << 4,
|
||||
water: 1 << 5,
|
||||
});
|
||||
const ROBOT_TARGET_ORDER = Object.freeze(["zunchi", "ant_corpse", "tarinai", "grass", "grass_bed", "water"]);
|
||||
|
||||
function robotCleanerDefaultMask() {
|
||||
return ROBOT_DEFAULT_MASK;
|
||||
}
|
||||
|
||||
function robotCleanerMask(item) {
|
||||
const raw = Number(item?.robotCleanerMask);
|
||||
return Number.isFinite(raw) ? (raw | 0) : ROBOT_DEFAULT_MASK;
|
||||
}
|
||||
|
||||
function setRobotCleanerMask(item, mask = ROBOT_DEFAULT_MASK) {
|
||||
if (!item || item.type !== "robot_cleaner") return false;
|
||||
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(mask) | 0));
|
||||
return true;
|
||||
}
|
||||
|
||||
function robotCleanerHighSpeed(item) {
|
||||
return Boolean(item?.robotCleanerHighSpeed);
|
||||
}
|
||||
|
||||
function setRobotCleanerHighSpeed(item, enabled = false) {
|
||||
if (!item || item.type !== "robot_cleaner") return false;
|
||||
item.robotCleanerHighSpeed = Boolean(enabled);
|
||||
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function robotCleanerEffectiveSpeed(item) {
|
||||
return robotCleanerHighSpeed(item) ? ROBOT_HIGH_SPEED : ROBOT_MOVE_SPEED;
|
||||
}
|
||||
|
||||
function robotCleanerHasTarget(item, key = "") {
|
||||
const bit = ROBOT_TARGET_BITS[String(key || "")];
|
||||
return Boolean(bit && (robotCleanerMask(item) & bit));
|
||||
}
|
||||
|
||||
function robotTargetLabel(key = "") {
|
||||
switch (String(key || "")) {
|
||||
case "zunchi": return "\u305a\u3093\u3061";
|
||||
case "ant_corpse": return "\u6b7b\u9ab8";
|
||||
case "trace": return "\u6b7b\u9ab8";
|
||||
case "splat": return "\u8840\u75d5";
|
||||
case "tarinai": return "\u305f\u308a\u306a\u3044";
|
||||
case "grass": return "\u8349";
|
||||
case "grass_bed": return "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9";
|
||||
case "water": return "\u6c34\u6ef4";
|
||||
default: return "\u5bfe\u8c61";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRobotCleanerState(item) {
|
||||
if (!item || item.type !== "robot_cleaner") return null;
|
||||
if (!Number.isFinite(Number(item.robotCleanerMask))) item.robotCleanerMask = ROBOT_DEFAULT_MASK;
|
||||
item.robotCleanerMask = Math.max(0, Math.min(0x3f, Number(item.robotCleanerMask) | 0));
|
||||
item.amount = Math.max(Number(item.amount || 0) || 0, 999);
|
||||
if (!Number.isFinite(Number(item.robotCleanerScanTimer))) item.robotCleanerScanTimer = 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerCleanFlash))) item.robotCleanerCleanFlash = 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerAngle))) item.robotCleanerAngle = Number(item.angle || 0) || 0;
|
||||
if (!Number.isFinite(Number(item.robotCleanerWanderAngle))) item.robotCleanerWanderAngle = Number(item.robotCleanerAngle || 0) || 0;
|
||||
item.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed);
|
||||
item.robotCleanerSpeed = robotCleanerEffectiveSpeed(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function robotTargetAlive(target) {
|
||||
if (!target) return false;
|
||||
return !target.dead && Number.isFinite(Number(target.x)) && Number.isFinite(Number(target.y));
|
||||
}
|
||||
|
||||
function robotTargetBlocked(item, target, worldRef) {
|
||||
if (!item || !target || !worldRef) return false;
|
||||
return Boolean(worldRef.targetBlockedByObstacle?.(item, target, Math.max(18, (item.r || 22) * 0.65)));
|
||||
}
|
||||
|
||||
function currentRobotTarget(item, worldRef) {
|
||||
const key = String(item?.robotCleanerTargetType || "");
|
||||
const id = String(item?.robotCleanerTargetId || "");
|
||||
if (!key || !id || !worldRef) return null;
|
||||
if (key === "tarinai") {
|
||||
const t = (worldRef.tarinai || []).find(t => t && t.id === id && !t.dead);
|
||||
return robotTargetAlive(t) && !robotTargetBlocked(item, t, worldRef) ? t : null;
|
||||
}
|
||||
const found = (worldRef.items || []).find(it => it && it.id === id && !it.dead && (
|
||||
it.type === key || (key === "ant_corpse" && (it.type === "trace" || it.type === "splat"))
|
||||
));
|
||||
return robotTargetAlive(found) && !robotTargetBlocked(item, found, worldRef) ? found : null;
|
||||
}
|
||||
|
||||
function robotTargetCandidates(item, worldRef) {
|
||||
const out = [];
|
||||
if (!worldRef || !item) return out;
|
||||
const mask = robotCleanerMask(item);
|
||||
worldRef.ensureItemBuckets?.("robot-cleaner-scan");
|
||||
for (const key of ROBOT_TARGET_ORDER) {
|
||||
const bit = ROBOT_TARGET_BITS[key];
|
||||
if (!(mask & bit)) continue;
|
||||
if (key === "tarinai") {
|
||||
for (const t of worldRef.tarinai || []) {
|
||||
if (!t || t.dead) continue;
|
||||
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
|
||||
if (robotTargetBlocked(item, t, worldRef)) continue;
|
||||
out.push({ target: t, type: key });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const buckets = key === "ant_corpse"
|
||||
? ["ant_corpse", "trace", "splat"].flatMap(type => worldRef.itemsOfType?.(type) || (worldRef.items || []).filter(it => it && it.type === type))
|
||||
: (worldRef.itemsOfType?.(key) || (worldRef.items || []).filter(it => it && it.type === key));
|
||||
for (const it of buckets || []) {
|
||||
if (!it || it.dead || it === item) continue;
|
||||
if (robotTargetBlocked(item, it, worldRef)) continue;
|
||||
out.push({ target: it, type: key });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function retargetRobotCleaner(item, worldRef, force = false) {
|
||||
const current = currentRobotTarget(item, worldRef);
|
||||
if (current && !force) return current;
|
||||
item.robotCleanerTargetId = "";
|
||||
item.robotCleanerTargetType = "";
|
||||
let best = null;
|
||||
let bestType = "";
|
||||
let bestD = Infinity;
|
||||
for (const rec of robotTargetCandidates(item, worldRef)) {
|
||||
const target = rec.target;
|
||||
if (!robotTargetAlive(target)) continue;
|
||||
const d = distXY(item.x, item.y, target.x, target.y);
|
||||
if (d < bestD) { best = target; bestType = rec.type; bestD = d; }
|
||||
}
|
||||
if (best) {
|
||||
item.robotCleanerTargetId = best.id || "";
|
||||
item.robotCleanerTargetType = bestType || best.type || "";
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function triggerRobotPanic(item, target, worldRef, force = false) {
|
||||
if (!item || !target || target.dead) return false;
|
||||
const now = Number(worldRef?.time || target.world?.time || 0) || 0;
|
||||
if (!force && now < Number(target.robotCleanerPanicCooldownUntil || 0)) return false;
|
||||
target.robotCleanerPanicCooldownUntil = now + 0.65;
|
||||
const reason = "\u30ed\u30dc\u6383\u9664\u6a5f\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
|
||||
if (typeof target.enterPanic === "function") {
|
||||
target.enterPanic({ threat: item, reason, fear: 0.86, wake: true, cause: "robot_cleaner", surpriseTimer: 0.18, awakeLockTimer: 0.45 });
|
||||
} else {
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.86);
|
||||
target.setActionState?.("panic", { target: target.panicDestination ? target.panicDestination(item, true) : null, reason, wake: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRobotTarinaiTarget(target) {
|
||||
return Boolean(target && (target.constructor?.name === "Tarinai" || (target.thought !== undefined && target.name && target.die)));
|
||||
}
|
||||
|
||||
function robotTargetTypeForContact(item, target) {
|
||||
if (!item || !target || target.dead) return "";
|
||||
if (isRobotTarinaiTarget(target)) return robotCleanerHasTarget(item, "tarinai") ? "tarinai" : "";
|
||||
const type = String(target.type || "");
|
||||
if ((type === "ant_corpse" || type === "trace" || type === "splat") && robotCleanerHasTarget(item, "ant_corpse")) return type;
|
||||
return robotCleanerHasTarget(item, type) ? type : "";
|
||||
}
|
||||
|
||||
function robotCleanerCaptureRadius(item, target) {
|
||||
return Math.max(18, (item?.r || 22) * 0.72 + (target?.r || target?.radius || 10) * 0.72);
|
||||
}
|
||||
|
||||
function clearRobotTrackedTargetIfMatched(item, target) {
|
||||
if (!item || !target) return false;
|
||||
const id = String(target.id || "");
|
||||
if (!id || String(item.robotCleanerTargetId || "") !== id) return false;
|
||||
item.robotCleanerTargetId = "";
|
||||
item.robotCleanerTargetType = "";
|
||||
item.robotCleanerScanTimer = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanRobotTarget(item, target, worldRef, typeOverride = "") {
|
||||
if (!item || !target || !worldRef || target.dead) return false;
|
||||
const type = String(typeOverride || item.robotCleanerTargetType || target.type || "");
|
||||
const label = robotTargetLabel(type);
|
||||
let removed = false;
|
||||
const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f";
|
||||
if (type === "tarinai" || isRobotTarinaiTarget(target)) {
|
||||
triggerRobotPanic(item, target, worldRef, true);
|
||||
if (typeof target.die === "function") {
|
||||
target.die(cleanReason, { lowHealth: true });
|
||||
removed = true;
|
||||
} else {
|
||||
target.dead = true;
|
||||
target.deathReason = cleanReason;
|
||||
worldRef.markDead?.(target, cleanReason);
|
||||
removed = true;
|
||||
}
|
||||
} else {
|
||||
removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, {
|
||||
reason: "robot-cleaner",
|
||||
userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason,
|
||||
wake: true,
|
||||
breaker: item,
|
||||
}));
|
||||
if (!removed) {
|
||||
target.amount = 0;
|
||||
target.hp = 0;
|
||||
target.dead = true;
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (!removed) return false;
|
||||
clearRobotTrackedTargetIfMatched(item, target);
|
||||
item.robotCleanerScanTimer = 0;
|
||||
item.robotCleanerCleanFlash = 0.42;
|
||||
item.robotCleanerCleanCount = (Number(item.robotCleanerCleanCount || 0) || 0) + 1;
|
||||
worldRef.effects?.push(new Effect("ring", target.x, target.y, { size: Math.max(16, (target.r || target.radius || 14) * 1.8), life: 0.24, color: "rgba(146,206,220,0.36)" }));
|
||||
worldRef.markItemBucketsDirty?.("robot-cleaner");
|
||||
worldRef.markSpatialDirty?.("robot-cleaner");
|
||||
if (target.type === "trace" || target.type === "splat") worldRef.markTerrainDirtyAt?.(target.x, target.y, Math.max(target.r || target.radius || 24, 36), "robot-cleaner");
|
||||
worldRef.updateItemCounts?.();
|
||||
worldRef.drawListDirty = true;
|
||||
if ((worldRef.time || 0) - Number(item.robotCleanerLastLogAt || -999) > 1.2) {
|
||||
worldRef.log?.(`\u30ed\u30dc\u6383\u9664\u6a5f\u304c${label}\u3092\u7247\u4ed8\u3051\u305f\u3002`, "observe");
|
||||
item.robotCleanerLastLogAt = worldRef.time || 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanRobotContacts(item, worldRef) {
|
||||
if (!item || !worldRef) return false;
|
||||
const seen = new Set();
|
||||
let changed = false;
|
||||
for (const rec of robotTargetCandidates(item, worldRef)) {
|
||||
const target = rec?.target;
|
||||
if (!robotTargetAlive(target)) continue;
|
||||
const key = String(target.id || `${rec.type}:${target.x}:${target.y}`);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const type = robotTargetTypeForContact(item, target) || rec.type || "";
|
||||
if (!type) continue;
|
||||
const capture = robotCleanerCaptureRadius(item, target);
|
||||
if (distXY(item.x, item.y, target.x, target.y) <= capture) {
|
||||
if (cleanRobotTarget(item, target, worldRef, type)) changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function moveRobotCleaner(item, dt, target, worldRef) {
|
||||
const r = Math.max(14, Number(item.r || 22) || 22);
|
||||
const speed = robotCleanerEffectiveSpeed(item);
|
||||
let desiredAngle = Number(item.robotCleanerWanderAngle || 0) || 0;
|
||||
let desiredSpeed = speed;
|
||||
if (target && robotTargetAlive(target)) {
|
||||
desiredAngle = Math.atan2((target.y || 0) - (item.y || 0), (target.x || 0) - (item.x || 0));
|
||||
desiredSpeed = speed;
|
||||
} else if ((item.robotCleanerWanderTimer || 0) <= 0) {
|
||||
desiredAngle = global.deterministicAngle(worldRef, "robot-cleaner-wander", item, Math.floor((worldRef.time || 0) * 2));
|
||||
item.robotCleanerWanderTimer = global.deterministicRange(worldRef, "robot-cleaner-wander-timer", 1.2, 3.2, item);
|
||||
}
|
||||
item.robotCleanerWanderTimer = Math.max(0, Number(item.robotCleanerWanderTimer || 0) - dt);
|
||||
item.robotCleanerWanderAngle = desiredAngle;
|
||||
const vx = Math.cos(desiredAngle) * desiredSpeed;
|
||||
const vy = Math.sin(desiredAngle) * desiredSpeed;
|
||||
const blend = Math.min(1, dt * 6.2);
|
||||
item.vx = (Number(item.vx || 0) || 0) + (vx - (Number(item.vx || 0) || 0)) * blend;
|
||||
item.vy = (Number(item.vy || 0) || 0) + (vy - (Number(item.vy || 0) || 0)) * blend;
|
||||
item.prevX = item.x;
|
||||
item.prevY = item.y;
|
||||
item.x += item.vx * dt;
|
||||
item.y += item.vy * dt;
|
||||
const minX = CONFIG.worldPadding + r * 0.5;
|
||||
const minY = CONFIG.worldPadding + r * 0.5;
|
||||
const maxX = Math.max(minX, (worldRef.w || 0) - CONFIG.worldPadding - r * 0.5);
|
||||
const maxY = Math.max(minY, (worldRef.h || 0) - CONFIG.worldPadding - r * 0.5);
|
||||
let bounced = false;
|
||||
if (item.x < minX) { item.x = minX; item.vx = Math.abs(item.vx) * 0.35; bounced = true; }
|
||||
if (item.x > maxX) { item.x = maxX; item.vx = -Math.abs(item.vx) * 0.35; bounced = true; }
|
||||
if (item.y < minY) { item.y = minY; item.vy = Math.abs(item.vy) * 0.35; bounced = true; }
|
||||
if (item.y > maxY) { item.y = maxY; item.vy = -Math.abs(item.vy) * 0.35; bounced = true; }
|
||||
if (bounced) {
|
||||
item.robotCleanerWanderAngle = Math.atan2(item.vy || 0, item.vx || 0) + global.deterministicRange(worldRef, "robot-cleaner-bounce", -0.8, 0.8, item);
|
||||
item.robotCleanerWanderTimer = 0.4;
|
||||
}
|
||||
if (Math.hypot(item.vx || 0, item.vy || 0) > 2) item.robotCleanerAngle = Math.atan2(item.vy || 0, item.vx || 0);
|
||||
worldRef.markSpatialDirty?.("robot-cleaner-move");
|
||||
worldRef.drawListDirty = true;
|
||||
}
|
||||
|
||||
function updateRobotCleaner(item, dt, worldRef) {
|
||||
if (!item || item.dead || !worldRef) return false;
|
||||
normalizeRobotCleanerState(item);
|
||||
dt = Math.max(0, Math.min(0.05, Number(dt || 0) || 0));
|
||||
item.robotCleanerCleanFlash = Math.max(0, Number(item.robotCleanerCleanFlash || 0) - dt * 2.8);
|
||||
item.robotCleanerScanTimer = Math.max(0, Number(item.robotCleanerScanTimer || 0) - dt);
|
||||
let target = currentRobotTarget(item, worldRef);
|
||||
if (!target || item.robotCleanerScanTimer <= 0) {
|
||||
target = retargetRobotCleaner(item, worldRef, true);
|
||||
item.robotCleanerScanTimer = ROBOT_RESCAN_INTERVAL;
|
||||
}
|
||||
moveRobotCleaner(item, dt, target, worldRef);
|
||||
if (target && robotTargetAlive(target)) {
|
||||
const isTarinaiTarget = String(item.robotCleanerTargetType || "") === "tarinai" || isRobotTarinaiTarget(target);
|
||||
const d = distXY(item.x, item.y, target.x, target.y);
|
||||
if (isTarinaiTarget && d <= Math.max(96, (item.r || 22) + (target.radius || target.r || 12) + 70)) triggerRobotPanic(item, target, worldRef);
|
||||
}
|
||||
cleanRobotContacts(item, worldRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
global.TarinaiRobotCleanerSystem = Object.freeze({
|
||||
updateRobotCleaner,
|
||||
robotCleanerDefaultMask,
|
||||
robotCleanerMask,
|
||||
setRobotCleanerMask,
|
||||
robotCleanerHasTarget,
|
||||
robotCleanerHighSpeed,
|
||||
setRobotCleanerHighSpeed,
|
||||
robotCleanerEffectiveSpeed,
|
||||
robotTargetLabel,
|
||||
robotCleanerTargetBits: ROBOT_TARGET_BITS,
|
||||
robotCleanerTargetOrder: ROBOT_TARGET_ORDER,
|
||||
robotCleanerMoveSpeed: ROBOT_MOVE_SPEED,
|
||||
robotCleanerHighMoveSpeed: ROBOT_HIGH_SPEED,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
ITEM_TYPE_IDS,
|
||||
} = SaveSchema;
|
||||
const FIRE_TYPE_ID = ITEM_TYPE_IDS.indexOf("fire");
|
||||
const FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("firecracker");
|
||||
const FLAME_FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("flame_firecracker");
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
|
@ -614,7 +615,7 @@
|
|||
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
|
||||
} else if (typeId === 24) { // ant_corpse
|
||||
writer.s(extra[0] || 0);
|
||||
} else if (typeId === 25 || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) { // firecracker/flame/fire
|
||||
} else if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) { // firecracker/flame/fire
|
||||
writer.s(extra[0] || 0);
|
||||
} else if (PIN_TYPE_IDS.has(typeId)) {
|
||||
const target = extra[1] ?? -1;
|
||||
|
|
@ -680,7 +681,7 @@
|
|||
if (typeId === 21) return [reader.s(), reader.s()];
|
||||
if (typeId === 23) return [reader.s(), reader.s()];
|
||||
if (typeId === 24) return [reader.s()];
|
||||
if (typeId === 25 || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()];
|
||||
if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()];
|
||||
if (PIN_TYPE_IDS.has(typeId)) {
|
||||
const [state, mask] = readBitFields(reader, [2, 4]);
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -96,8 +96,7 @@ function normalizeGenetics(source = null, seed = "") {
|
|||
const fallback = randomGenetics(seed);
|
||||
const out = {};
|
||||
for (const key of GENETIC_KEYS) {
|
||||
const legacyKey = key === "attackMul" ? "fightMul" : key;
|
||||
out[key] = clampGeneticValue(key, source?.[key] ?? source?.[legacyKey] ?? fallback[key]);
|
||||
out[key] = clampGeneticValue(key, source?.[key] ?? fallback[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -455,6 +454,15 @@ class Effect {
|
|||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (this.type === "flame") {
|
||||
const fn = globalThis.drawSimpleFireShape;
|
||||
if (typeof fn === "function") fn(ctx, 0, 0, this.size * (0.74 + (1 - alpha) * 0.20), { phase: this.seed + (1 - alpha) * 4.8, alpha: alpha * 0.92, shadow: false });
|
||||
else {
|
||||
ctx.fillStyle = this.color || "rgba(236,46,24,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -this.size * 0.2, this.size * 0.34, this.size * 0.72, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (this.type === "shoot_impact") {
|
||||
const s = this.size * (0.65 + (1 - alpha) * 0.55);
|
||||
ctx.lineCap = "round";
|
||||
|
|
@ -565,12 +573,7 @@ class Effect {
|
|||
ctx.font = "bold 12px Yomogi, ui-rounded, sans-serif";
|
||||
const maxTextW = 118;
|
||||
const lineH = 14;
|
||||
const ellipsize = (text, maxW) => {
|
||||
let s = String(text || "");
|
||||
if (ctx.measureText(s).width <= maxW) return s;
|
||||
while (s.length > 1 && ctx.measureText(`${s}\u2026`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}\u2026`;
|
||||
};
|
||||
const ellipsize = (text, maxW) => globalThis.canvasEllipsizeText(ctx, text, maxW);
|
||||
const wrap = (text, maxW) => {
|
||||
const src = String(text || "").replace(/\s+/g, " ").trim() || " ";
|
||||
const units = src.includes(" ") ? src.split(/(\s+)/).filter(Boolean) : Array.from(src);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
if ((worldRef.time || 0) >= (worldRef.nextFireCompactAt || 0)) {
|
||||
worldRef.nextFireCompactAt = (worldRef.time || 0) + 0.8;
|
||||
const compactedFires = global.TarinaiItemDynamicToolSystem?.compactWorldFires?.(worldRef) || 0;
|
||||
if (compactedFires > 0) {
|
||||
itemsCompacted = true;
|
||||
worldRef.updateItemCounts();
|
||||
}
|
||||
}
|
||||
|
||||
if ((worldRef.time || 0) >= (worldRef.nextGrassBedExpiryCheckAt || 0)) {
|
||||
worldRef.nextGrassBedExpiryCheckAt = (worldRef.time || 0) + 5.0;
|
||||
|
|
|
|||
|
|
@ -13,29 +13,30 @@
|
|||
return global.TarinaiItemRuntime.updateScheduled(worldRef, dt);
|
||||
}
|
||||
|
||||
function trackSpatialEntityMoved(entity, thresholdSq) {
|
||||
if (!entity || entity.dead) return false;
|
||||
const x = Number.isFinite(entity.x) ? entity.x : 0;
|
||||
const y = Number.isFinite(entity.y) ? entity.y : 0;
|
||||
const oldX = entity._spatialTrackX;
|
||||
const oldY = entity._spatialTrackY;
|
||||
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
return true;
|
||||
}
|
||||
const dx = x - oldX;
|
||||
const dy = y - oldY;
|
||||
if (dx * dx + dy * dy <= thresholdSq) return false;
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
return true;
|
||||
}
|
||||
|
||||
function markSpatialDirtyIfMoved(worldRef, list, reason = "moved", threshold = 0.35) {
|
||||
if (!worldRef || !Array.isArray(list) || !list.length) return false;
|
||||
const thresholdSq = threshold * threshold;
|
||||
let moved = false;
|
||||
for (const entity of list) {
|
||||
if (!entity || entity.dead) continue;
|
||||
const x = Number.isFinite(entity.x) ? entity.x : 0;
|
||||
const y = Number.isFinite(entity.y) ? entity.y : 0;
|
||||
const oldX = entity._spatialTrackX;
|
||||
const oldY = entity._spatialTrackY;
|
||||
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
moved = true;
|
||||
continue;
|
||||
}
|
||||
const dx = x - oldX;
|
||||
const dy = y - oldY;
|
||||
if (dx * dx + dy * dy <= thresholdSq) continue;
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
moved = true;
|
||||
}
|
||||
for (const entity of list) moved = trackSpatialEntityMoved(entity, thresholdSq) || moved;
|
||||
if (moved) worldRef.markSpatialDirty?.(reason);
|
||||
return moved;
|
||||
}
|
||||
|
|
@ -46,29 +47,9 @@
|
|||
let moved = false;
|
||||
const tracked = global.TarinaiItemRuntime.trackedDynamicItems(worldRef);
|
||||
const source = tracked && tracked.length ? tracked : null;
|
||||
const visit = (entity) => {
|
||||
const x = Number.isFinite(entity.x) ? entity.x : 0;
|
||||
const y = Number.isFinite(entity.y) ? entity.y : 0;
|
||||
const oldX = entity._spatialTrackX;
|
||||
const oldY = entity._spatialTrackY;
|
||||
if (!Number.isFinite(oldX) || !Number.isFinite(oldY)) {
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
moved = true;
|
||||
return;
|
||||
}
|
||||
const dx = x - oldX;
|
||||
const dy = y - oldY;
|
||||
if (dx * dx + dy * dy <= thresholdSq) return;
|
||||
entity._spatialTrackX = x;
|
||||
entity._spatialTrackY = y;
|
||||
moved = true;
|
||||
};
|
||||
if (source) {
|
||||
for (const entity of source) if (entity && !entity.dead) visit(entity);
|
||||
} else {
|
||||
forScheduledItems(worldRef, visit);
|
||||
}
|
||||
const visit = (entity) => { moved = trackSpatialEntityMoved(entity, thresholdSq) || moved; };
|
||||
if (source) for (const entity of source) visit(entity);
|
||||
else forScheduledItems(worldRef, visit);
|
||||
if (moved) worldRef.markSpatialDirty?.(reason);
|
||||
return moved;
|
||||
}
|
||||
|
|
@ -90,6 +71,7 @@
|
|||
updateItemsScheduled,
|
||||
markSpatialDirtyIfMoved,
|
||||
markScheduledItemsMoved,
|
||||
trackSpatialEntityMoved,
|
||||
updateVisibleWorldRect,
|
||||
pointInRect,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -319,7 +319,6 @@
|
|||
item.singleUseConsumedById = tarinaiList[extra[5]]?.id || "";
|
||||
item.createdAt = Number.isFinite(Number(extra[6])) ? u(extra[6], 10, item.createdAt || NaN) : (Number.isFinite(Number(item.createdAt)) ? item.createdAt : NaN);
|
||||
if (type === "plushie") {
|
||||
item.plushieSpriteId = "plushie_bear";
|
||||
item.onHead = Boolean(item.carriedById);
|
||||
}
|
||||
const def = global.StructureRegistry?.get?.(type);
|
||||
|
|
@ -358,7 +357,7 @@
|
|||
} else if (type === "ant_nest") {
|
||||
item.antCount = u(extra[0], 1); item.antWorkers = []; item.queenSpawnAt = u(extra[1], 10);
|
||||
} else if (type === "ant_corpse") {
|
||||
item.workerSprite = "ant_worker"; item.decayTimer = u(extra[0], 10);
|
||||
item.decayTimer = u(extra[0], 10);
|
||||
} else if (type === "firecracker" || type === "flame_firecracker") {
|
||||
item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0);
|
||||
} else if (type === "fire") {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
item.grassStage = -1;
|
||||
item.seedTimer = Math.max(Number(item.seedTimer || 0), 2.5);
|
||||
}
|
||||
item.removedReason = String(reason || "item-removed");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +33,6 @@
|
|||
structure.dead = true;
|
||||
structure.hp = 0;
|
||||
structure.amount = 0;
|
||||
structure.removedReason = String(reason || "structure-removed");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +149,6 @@
|
|||
if (!hit) continue;
|
||||
link.amount = 0;
|
||||
link.hp = 0;
|
||||
link.removedReason = String(reason || "support-removed");
|
||||
removedIds.add(link.id);
|
||||
removed += 1;
|
||||
changed = true;
|
||||
|
|
@ -182,7 +179,6 @@
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@
|
|||
this.carriedById = definition.type === "plushie" ? this.ownerId : "";
|
||||
this.onHead = definition.type === "plushie";
|
||||
if (definition.type === "plushie") {
|
||||
this.plushieSpriteId = "plushie_bear";
|
||||
this.plushieFlingTimer = 0;
|
||||
this.plushieTrailTimer = 0;
|
||||
this.spin = 0;
|
||||
|
|
@ -261,7 +260,6 @@
|
|||
ctx.fill();
|
||||
} else if (this.type === "plushie") {
|
||||
const spriteId = "plushie_bear";
|
||||
this.plushieSpriteId = spriteId;
|
||||
const img = getRenderableImage(spriteId, spriteId);
|
||||
ctx.rotate((Number(this.spin || 0) || 0) + Math.sin(time * 2 + this.seed) * 0.05);
|
||||
if (img) {
|
||||
|
|
@ -360,7 +358,6 @@
|
|||
structure.unownedDecayTimer = 0;
|
||||
structure.singleUsePending = true;
|
||||
structure.singleUseConsumedById = user?.id || "";
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -114,7 +114,6 @@
|
|||
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");
|
||||
setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
|
||||
|
|
|
|||
|
|
@ -237,5 +237,6 @@
|
|||
clearTarinaiForcedBehavior: function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); },
|
||||
isTarinaiBehaviorForced: function isTarinaiBehaviorForced(tarinai) { return isBehaviorForcedValue(ensureBehavior(tarinai)); },
|
||||
isTarinaiBehaviorValueForced: isBehaviorForcedValue,
|
||||
isTarinaiEffectCauseText: isEffectCauseText,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ function actionTextForReason(action = null, tarinai = null) {
|
|||
}
|
||||
|
||||
function isEffectCauseText(text = "") {
|
||||
return /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
||||
return typeof globalThis.isTarinaiEffectCauseText === "function"
|
||||
? globalThis.isTarinaiEffectCauseText(text)
|
||||
: /\u306E\u52B9\u679C$/.test(String(text || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""));
|
||||
}
|
||||
|
||||
function forcedCausePhrase(source = "", fallback = "") {
|
||||
|
|
@ -186,4 +188,3 @@ function refreshBehaviorReasonText(tarinai, action = null) {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ function grassMaterialAvailableStages(material) {
|
|||
return Math.max(0, Math.floor(Number(stage) || 0) + 1);
|
||||
}
|
||||
|
||||
function reservedGrassStagesForBuildMaterial(world, material, type, self = null) {
|
||||
function reservedBuildGrassStages(world, material, type, self = null) {
|
||||
if (!world || !material) return 0;
|
||||
const id = material.id || "";
|
||||
if (!id) return 0;
|
||||
|
|
@ -140,7 +140,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
|
|||
if (world?.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))) continue;
|
||||
const available = grassMaterialAvailableStages(item);
|
||||
if (available < required) continue;
|
||||
const reserved = reservedGrassStagesForBuildMaterial(world, item, type, tarinai);
|
||||
const reserved = reservedBuildGrassStages(world, item, type, tarinai);
|
||||
if (available - reserved < required) continue;
|
||||
const d = dist(tarinai, item);
|
||||
if (d > maxDist) continue;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@
|
|||
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
|
||||
const { foodActionActive = false } = ctx;
|
||||
|
||||
if (!it || it.dead) return false;
|
||||
const ceremonialContact = it.type === "slave_chain" || it.type === "golden_crown";
|
||||
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
|
||||
const baseReach = foodInteractionReach(tarinai, it, ceremonialContact ? "medicine" : "food");
|
||||
const contactReach = baseReach + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? (ceremonialContact ? 10 : 4) : 0);
|
||||
if (d > contactReach) return false;
|
||||
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, {
|
||||
role: ceremonialContact ? "medicine" : "food",
|
||||
activeTargetBonus: it?.type === "duplicator" ? 0 : (ceremonialContact ? 10 : 4),
|
||||
});
|
||||
if (!contact) return false;
|
||||
const d = contact.distance;
|
||||
|
||||
if (ceremonialContact) {
|
||||
const itemLabel = typeof toolLabel === "function" ? toolLabel(it.type) : (it.type === "slave_chain" ? "\u5974\u96B7\u306E\u9396" : "\u9EC4\u91D1\u306E\u51A0");
|
||||
|
|
|
|||
|
|
@ -328,7 +328,6 @@
|
|||
if (!b || !this.world?.nestBoxEntryPoint) {
|
||||
this._nestBoxExitSmoothUntil = -Infinity;
|
||||
this._nestBoxExitDuration = 0;
|
||||
this._nestBoxExitBoxId = "";
|
||||
return false;
|
||||
}
|
||||
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(b, this) : { x: b.x, y: b.y };
|
||||
|
|
@ -346,7 +345,6 @@
|
|||
this._nestBoxExitEndY = Number.isFinite(exit.y) ? exit.y : this._nestBoxExitDoorY;
|
||||
this._nestBoxExitDuration = 0.58;
|
||||
this._nestBoxExitSmoothUntil = now + this._nestBoxExitDuration;
|
||||
this._nestBoxExitBoxId = b.id || "";
|
||||
this.nestFade = Math.max(this.nestFade || 0, 0.96);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -385,7 +383,6 @@
|
|||
this.nestFade = 0;
|
||||
this._nestBoxExitSmoothUntil = -Infinity;
|
||||
this._nestBoxExitDuration = 0;
|
||||
this._nestBoxExitBoxId = "";
|
||||
this._nestBoxExitStartX = this._nestBoxExitStartY = this._nestBoxExitDoorX = this._nestBoxExitDoorY = this._nestBoxExitEndX = this._nestBoxExitEndY = NaN;
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
if (!tarinai || tarinai.dead || !ctx || !it || it.dead) return false;
|
||||
const { foodNeed = 0, foodActionActive = false, canBite = false, canSweetBite = false, canZunchiBite = false } = ctx;
|
||||
|
||||
const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it);
|
||||
const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive ? (it?.type === "duplicator" ? 18 : 4) : 0);
|
||||
if (d > contactReach) return false;
|
||||
const contact = global.TarinaiItemInteractionContext.contactForCandidate(tarinai, ctx, it, { role: "food" });
|
||||
if (!contact) return false;
|
||||
const d = contact.distance;
|
||||
const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type;
|
||||
const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi";
|
||||
|
||||
|
|
|
|||
55
js/tarinai_food_prototype_mixin.js
Normal file
55
js/tarinai_food_prototype_mixin.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: tarinai/food-prototype
|
||||
// Food serving and hunger-relief helpers mixed into Tarinai.prototype.
|
||||
(function (global) {
|
||||
const Tarinai = global.Tarinai;
|
||||
if (!Tarinai) return;
|
||||
|
||||
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
||||
isServingFoodItem(it) {
|
||||
if (it?.type === "duplicator" && it.storedFoodType) return true;
|
||||
return Boolean(it && isServingFoodType(it.type));
|
||||
},
|
||||
foodItemHasServingLeft(it) {
|
||||
if (!it || it.dead) return false;
|
||||
if (it.type === "duplicator") return Boolean(it.storedFoodType);
|
||||
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
|
||||
this.ensureFoodServings(it);
|
||||
return (it.foodServingsRemaining || 0) > 0;
|
||||
},
|
||||
ensureFoodServings(it) {
|
||||
if (it?.type === "duplicator") return;
|
||||
if (!this.isServingFoodItem(it)) return;
|
||||
const max = foodServingsForSize(it.toolSize || "medium");
|
||||
if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max;
|
||||
if (!Number.isFinite(it.foodServingsRemaining)) {
|
||||
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
|
||||
const ratio = clamp((it.amount ?? original) / original, 0.05, 1);
|
||||
it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio));
|
||||
}
|
||||
it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax);
|
||||
it.amount = it.foodServingsRemaining;
|
||||
},
|
||||
consumeFoodServing(it, nutrition) {
|
||||
if (it?.type === "duplicator") {
|
||||
if (!it.storedFoodType) return 0;
|
||||
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
|
||||
it.usedCount = (it.usedCount || 0) + 1;
|
||||
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value });
|
||||
return value;
|
||||
}
|
||||
this.ensureFoodServings(it);
|
||||
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0;
|
||||
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.type, 0) ?? 0);
|
||||
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
|
||||
it.amount = it.foodServingsRemaining;
|
||||
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
|
||||
return value;
|
||||
},
|
||||
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {
|
||||
const relief = window.TarinaiItemRegistry.food?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier;
|
||||
return (Number(nutrition) || 0) * relief;
|
||||
},
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -277,7 +277,6 @@ function retryForcedBehavior(tarinai, behavior = null, reason = "retry") {
|
|||
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);
|
||||
|
|
@ -304,8 +303,7 @@ function processForcedBehaviorQueue(tarinai, world, needs) {
|
|||
} 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)));
|
||||
|
|
|
|||
|
|
@ -300,7 +300,6 @@
|
|||
this.poopCount = (this.poopCount || 0) + 1;
|
||||
this.digest = Math.max(0, (this.digest || 0) - 3.0);
|
||||
const now = this.world?.time || 0;
|
||||
this.nextPoopBubbleAt = now + 2.2;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 1.2, effectColor("laxative"), { force: true });
|
||||
if ((this.world.time || 0) > (this.nextLaxativeLogAt || -999)) {
|
||||
this.nextLaxativeLogAt = (this.world.time || 0) + 9;
|
||||
|
|
|
|||
|
|
@ -62,8 +62,22 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function contactForCandidate(tarinai, ctx, item, opts = {}) {
|
||||
if (!tarinai || tarinai.dead || !ctx || !item || item.dead) return null;
|
||||
const role = opts.role || "food";
|
||||
const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item);
|
||||
const baseReach = foodInteractionReach(tarinai, item, role);
|
||||
const activeBonus = item === tarinai.target && ctx.foodActionActive
|
||||
? Number(opts.activeTargetBonus ?? (item.type === "duplicator" ? 18 : 4))
|
||||
: 0;
|
||||
const contactReach = baseReach + activeBonus;
|
||||
if (d > contactReach) return null;
|
||||
return { distance: d, reach: contactReach, baseReach };
|
||||
}
|
||||
|
||||
global.TarinaiItemInteractionContext = Object.freeze({
|
||||
create,
|
||||
finalize,
|
||||
contactForCandidate,
|
||||
});
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -322,31 +322,15 @@ function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) {
|
|||
}
|
||||
|
||||
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 (Number(item.unreachableUntil || 0) > Number(world?.time || 0)) continue;
|
||||
if (type && item.type !== type) continue;
|
||||
if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue;
|
||||
const d = dist(tarinai, item);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
return globalThis.TarinaiNestSleepSystem.findOwnedStructure(world, tarinai, type, maxDist, {
|
||||
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
|
||||
}) || null;
|
||||
}
|
||||
|
||||
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 (item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(world?.time || 0)) continue;
|
||||
if (Number(item.unreachableUntil || 0) > Number(world?.time || 0)) continue;
|
||||
if (type && item.type !== type) continue;
|
||||
if (tarinai?.shouldAvoidTarget?.(item)) continue;
|
||||
if (tarinaiTargetBlockedByObstacle(world, tarinai, item, "structure")) continue;
|
||||
const d = dist(tarinai, item);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
return globalThis.TarinaiNestSleepSystem.findUnownedStructure(world, tarinai, type, maxDist, {
|
||||
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function consumeGrassMaterial(material, stages = 1) {
|
||||
|
|
@ -364,4 +348,3 @@ function moveToOrUse(t, target, state = "seek_food", reason = "") {
|
|||
t.setActionState?.(state, { target, reason, sleeping: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1063,55 +1063,6 @@ function resolveNeedsCore(dt) {
|
|||
isSleepFurniture(target) {
|
||||
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))));
|
||||
},
|
||||
isServingFoodItem(it) {
|
||||
if (it?.type === "duplicator" && it.storedFoodType) return true;
|
||||
return Boolean(it && isServingFoodType(it.type));
|
||||
},
|
||||
|
||||
foodItemHasServingLeft(it) {
|
||||
if (!it || it.dead) return false;
|
||||
if (it.type === "duplicator") return Boolean(it.storedFoodType);
|
||||
if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2;
|
||||
this.ensureFoodServings(it);
|
||||
return (it.foodServingsRemaining || 0) > 0;
|
||||
},
|
||||
|
||||
ensureFoodServings(it) {
|
||||
if (it?.type === "duplicator") return;
|
||||
if (!this.isServingFoodItem(it)) return;
|
||||
const max = foodServingsForSize(it.toolSize || "medium");
|
||||
if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max;
|
||||
if (!Number.isFinite(it.foodServingsRemaining)) {
|
||||
const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, laxative: 58, protein: 58, niteropu: 58, ammo: 48, mystery_drug: 52, mercury: 44, giant_drug: 52, dwarf_drug: 52, zunda_juice: 70 }[it.type] || 62;
|
||||
const ratio = clamp((it.amount ?? original) / original, 0.05, 1);
|
||||
it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio));
|
||||
}
|
||||
it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax);
|
||||
it.amount = it.foodServingsRemaining;
|
||||
},
|
||||
|
||||
consumeFoodServing(it, nutrition) {
|
||||
if (it?.type === "duplicator") {
|
||||
if (!it.storedFoodType) return 0;
|
||||
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.storedFoodType, 0) ?? 42);
|
||||
it.usedCount = (it.usedCount || 0) + 1;
|
||||
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value });
|
||||
return value;
|
||||
}
|
||||
this.ensureFoodServings(it);
|
||||
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0;
|
||||
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiItemRegistry.food?.nutrition?.(it.type, 0) ?? 0);
|
||||
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
|
||||
it.amount = it.foodServingsRemaining;
|
||||
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
|
||||
return value;
|
||||
},
|
||||
|
||||
foodHungerReliefFor(type = "", nutrition = 0, fallbackMultiplier = 0) {
|
||||
const relief = window.TarinaiItemRegistry.food?.hungerRelief?.(type, fallbackMultiplier) ?? fallbackMultiplier;
|
||||
return (Number(nutrition) || 0) * relief;
|
||||
},
|
||||
|
||||
sleepSpotFor(bed) {
|
||||
if (!bed) return { x: this.x, y: this.y };
|
||||
if (bed.type === "nest_box") {
|
||||
|
|
@ -1169,7 +1120,7 @@ function resolveNeedsCore(dt) {
|
|||
},
|
||||
|
||||
resolveNeeds(dt) {
|
||||
return global.TarinaiNeedPlannerSystem.updateOne(this, dt) ?? global.TarinaiNeedPlannerCoreSystem.updateOne(this, dt);
|
||||
return global.TarinaiNeedPlannerSystem.updateOne(this, dt);
|
||||
},
|
||||
|
||||
makePoop(force = 1) {
|
||||
|
|
@ -1191,7 +1142,6 @@ function resolveNeedsCore(dt) {
|
|||
const backY = this.y + this.radius * rand(0.25, 0.48);
|
||||
this.world.spawnZunchi(backX, backY, this);
|
||||
const now = this.world?.time || 0;
|
||||
this.nextPoopBubbleAt = now + 2.8;
|
||||
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true });
|
||||
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] });
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -84,32 +84,35 @@
|
|||
return occupancy < capacity || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
|
||||
}
|
||||
|
||||
function findOwnedStructure(worldRef, tarinai, type = "", maxDist = Infinity) {
|
||||
function findStructure(worldRef, tarinai, opts = {}) {
|
||||
const type = String(opts.type || "");
|
||||
const maxDist = Number.isFinite(Number(opts.maxDist)) ? Number(opts.maxDist) : Infinity;
|
||||
const owned = opts.owned === true;
|
||||
const unowned = opts.unowned === true;
|
||||
const blocked = typeof opts.blocked === "function" ? opts.blocked : sleepTargetBlocked;
|
||||
let best = null, bestD = maxDist;
|
||||
for (const item of worldRef?.items || []) {
|
||||
if (!item?.isStructure || item.dead || item.ownerId !== tarinai?.id) continue;
|
||||
if (!item?.isStructure || item.dead) continue;
|
||||
if (owned && item.ownerId !== tarinai?.id) continue;
|
||||
if (unowned && item.ownerId) continue;
|
||||
if (!owned && !unowned && opts.ownerId !== undefined && item.ownerId !== opts.ownerId) continue;
|
||||
if (Number(item.unreachableUntil || 0) > Number(worldRef?.time || 0)) continue;
|
||||
if (unowned && item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(worldRef?.time || 0)) continue;
|
||||
if (type && item.type !== type) continue;
|
||||
if (sleepTargetBlocked(worldRef, tarinai, item)) continue;
|
||||
if (unowned && tarinai?.shouldAvoidTarget?.(item)) continue;
|
||||
if (blocked(worldRef, tarinai, item)) continue;
|
||||
const d = distFn(tarinai, item);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function findUnownedStructure(worldRef, tarinai, type = "", maxDist = Infinity) {
|
||||
let best = null, bestD = maxDist;
|
||||
for (const item of worldRef?.items || []) {
|
||||
if (!item?.isStructure || item.dead || item.ownerId) continue;
|
||||
if (item.abandonedById && item.abandonedById === tarinai?.id && Number(item.abandonedUntil || 0) > Number(worldRef?.time || 0)) continue;
|
||||
if (Number(item.unreachableUntil || 0) > Number(worldRef?.time || 0)) continue;
|
||||
if (type && item.type !== type) continue;
|
||||
if (tarinai?.shouldAvoidTarget?.(item)) continue;
|
||||
if (sleepTargetBlocked(worldRef, tarinai, item)) continue;
|
||||
const d = distFn(tarinai, item);
|
||||
if (d < bestD) { best = item; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
function findOwnedStructure(worldRef, tarinai, type = "", maxDist = Infinity, opts = {}) {
|
||||
return findStructure(worldRef, tarinai, { ...opts, owned: true, type, maxDist });
|
||||
}
|
||||
|
||||
function findUnownedStructure(worldRef, tarinai, type = "", maxDist = Infinity, opts = {}) {
|
||||
return findStructure(worldRef, tarinai, { ...opts, unowned: true, type, maxDist });
|
||||
}
|
||||
|
||||
function nearestSleepPlaceOfType(worldRef, tarinai, type, maxDist = 520) {
|
||||
|
|
@ -148,6 +151,9 @@
|
|||
bedOccupancy,
|
||||
bedComfort,
|
||||
sleepPlaceAvailableFor,
|
||||
findStructure,
|
||||
findOwnedStructure,
|
||||
findUnownedStructure,
|
||||
nearestSleepPlaceOfType,
|
||||
findPrioritySleepPlaceBeforeGrassBedBuild,
|
||||
findNearestSleepPlace,
|
||||
|
|
|
|||
|
|
@ -534,6 +534,23 @@
|
|||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (this.burning || (this.burnTimer || 0) > 0.02) {
|
||||
ctx.save();
|
||||
const phase = t * 8.0 + this.age * 0.37 + this.seed;
|
||||
const fireAlpha = 0.82 * nestHideAlpha;
|
||||
const drawFire = typeof drawSimpleFireShape === "function" ? drawSimpleFireShape : null;
|
||||
if (drawFire) {
|
||||
drawFire(ctx, -dw * 0.20, -dh * 0.18, Math.max(10, dw * 0.17), { phase, alpha: fireAlpha });
|
||||
drawFire(ctx, dw * 0.16, -dh * 0.04, Math.max(8, dw * 0.13), { phase: phase + 1.7, alpha: fireAlpha * 0.86, shadow: false });
|
||||
drawFire(ctx, 0, -dh * 0.34, Math.max(9, dw * 0.14), { phase: phase + 3.1, alpha: fireAlpha * 0.76, shadow: false });
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(236,46,24,0.78)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, -dh * 0.20, Math.max(8, dw * 0.15), Math.max(12, dh * 0.22), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
if (faceRight) ctx.scale(-1, 1);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -435,13 +435,16 @@
|
|||
this.lastPanicCause = "burning";
|
||||
this.lastPanicDetail = burnText;
|
||||
this.thought = burnText;
|
||||
this.burnTurnTimer = Math.max(0, Number(this.burnTurnTimer || 0) - dt);
|
||||
if (this.burnTurnTimer <= 0) {
|
||||
this.burnWanderAngle = deterministicAngle(this.world, "tarinai-burn-run-angle", this, Math.floor((this.world?.time || 0) * 5));
|
||||
this.burnTurnTimer = deterministicRange(this.world, "tarinai-burn-run-turn", 0.10, 0.34, this);
|
||||
} else {
|
||||
this.burnWanderAngle += deterministicRange(this.world, "tarinai-burn-run-jitter", -8.0, 8.0, this) * dt;
|
||||
}
|
||||
globalThis.TarinaiBurnMotionUtil?.updateBurnWanderAngle(this, dt, {
|
||||
angleKey: "tarinai-burn-run-angle",
|
||||
turnKey: "tarinai-burn-run-turn",
|
||||
jitterKey: "tarinai-burn-run-jitter",
|
||||
bucketScale: 5,
|
||||
turnMin: 0.10,
|
||||
turnMax: 0.34,
|
||||
jitterMin: -8.0,
|
||||
jitterMax: 8.0,
|
||||
});
|
||||
ax += Math.cos(this.burnWanderAngle || this.wanderAngle || 0) * baseSpeed * 3.15;
|
||||
ay += Math.sin(this.burnWanderAngle || this.wanderAngle || 0) * baseSpeed * 3.15;
|
||||
} else {
|
||||
|
|
@ -641,7 +644,6 @@
|
|||
bed.unreachableUntil = 0;
|
||||
bed.abandonedById = this.id || "";
|
||||
bed.abandonedUntil = (this.world?.time || 0) + 90;
|
||||
bed.abandonedReason = reason;
|
||||
bed.carriedById = "";
|
||||
this.target = null;
|
||||
this.targetKey = "";
|
||||
|
|
|
|||
|
|
@ -12,26 +12,18 @@
|
|||
"TarinaiHealthUpdateStep",
|
||||
]);
|
||||
|
||||
let cachedSteps = null;
|
||||
function concreteSteps() {
|
||||
if (!cachedSteps) cachedSteps = CONCRETE_STEP_NAMES.map(name => global[name]);
|
||||
return cachedSteps;
|
||||
}
|
||||
|
||||
const runner = global.TarinaiUpdateStepPipelineRunner.create(
|
||||
CONCRETE_STEP_NAMES,
|
||||
(step, tarinai, dt, context) => step.update(tarinai, dt, context)
|
||||
);
|
||||
|
||||
function updateOne(tarinai, dt, options = {}) {
|
||||
if (!tarinai || tarinai.dead) return false;
|
||||
const steps = concreteSteps();
|
||||
if (steps.length !== CONCRETE_STEP_NAMES.length) return false;
|
||||
const context = {
|
||||
...(options.context || {}),
|
||||
wasSleepingAtFrameStart: false,
|
||||
};
|
||||
for (const step of steps) {
|
||||
const result = step.update(tarinai, dt, context) || {};
|
||||
if (result.done) return result.ok !== false;
|
||||
}
|
||||
return true;
|
||||
return runner.run(tarinai, dt, context);
|
||||
}
|
||||
|
||||
global.TarinaiUpdatePipeline = Object.freeze({ updateOne });
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@
|
|||
interval = isVisible ? 0.42 : (isNear ? 0.70 : 1.10);
|
||||
} else if (/idle|none/.test(id) || state === "idle") {
|
||||
lane = "idle";
|
||||
if (isVisible) interval = tier === "low" ? 0.24 : (tier === "medium" || tier === "mid" ? 0.20 : (count > 28 ? 0.16 : 0.12));
|
||||
if (isVisible) interval = tier === "low" ? 0.24 : (count > 28 ? 0.16 : 0.12);
|
||||
else interval = isNear ? 0.55 : 1.10;
|
||||
} else if (isVisible) {
|
||||
lane = "visible";
|
||||
interval = tier === "low" ? (count > 24 ? 0.18 : 0.14) : (tier === "medium" || tier === "mid" ? (count > 28 ? 0.14 : 0.10) : (count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05)));
|
||||
interval = tier === "low" ? (count > 24 ? 0.18 : 0.14) : (count > 36 ? 0.11 : (count > 24 ? 0.08 : 0.05));
|
||||
} else {
|
||||
lane = isNear ? "near" : "far";
|
||||
interval = isNear ? (tier === "low" ? 0.40 : 0.30) : (tier === "low" ? 1.20 : 0.90);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
if (speed > 0.55 || recentlyHit) {
|
||||
const hold = speed > 18 ? 1.15 : (speed > 3 ? 0.75 : 0.42);
|
||||
t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + hold);
|
||||
t._sleepExternalMotionReason = reason;
|
||||
return true;
|
||||
}
|
||||
return now < Number(t._sleepExternalMotionUntil || 0);
|
||||
|
|
@ -75,7 +74,6 @@
|
|||
if (now < Number(t._sleepExternalWakeSuppressUntil || -999)) return false;
|
||||
t._sleepExternalWakeSuppressUntil = now + 0.45;
|
||||
t._sleepExternalMotionUntil = 0;
|
||||
t._sleepExternalMotionReason = "";
|
||||
t.consumePendingGrassBedOnWake?.("\u5916\u529b\u3067\u8d77\u304d\u305f");
|
||||
t.sleeping = false;
|
||||
t.sleepSession = null;
|
||||
|
|
@ -248,20 +246,16 @@
|
|||
applyVelocityShockDamage(t, before, motionDt);
|
||||
t.updateNestBoxPresence(motionDt);
|
||||
|
||||
// t.move() normally calls world.moveTarinaiWithCollision(), which already
|
||||
// performs swept solid-obstacle resolution. Running resolveFenceCollision()
|
||||
// again for every creature made stationary/sleeping crowds pay the full
|
||||
// obstacle query cost twice per update. Keep the legacy fallback for worlds
|
||||
// without swept movement, and run a staggered low-frequency relax pass for
|
||||
// near-stationary bodies so old overlaps still self-heal.
|
||||
// t.move() calls world.moveTarinaiWithCollision(), which already performs
|
||||
// swept solid-obstacle resolution. Avoid a second full collision pass and
|
||||
// keep only a staggered low-frequency relax pass for near-stationary bodies
|
||||
// so old overlaps still self-heal.
|
||||
const world = t.world;
|
||||
if (world?.resolveFenceCollision) {
|
||||
const dx = (Number.isFinite(t.x) ? t.x : 0) - beforeX;
|
||||
const dy = (Number.isFinite(t.y) ? t.y : 0) - beforeY;
|
||||
const movedSq = dx * dx + dy * dy;
|
||||
if (!world.moveTarinaiWithCollision && movedSq > 0.25) {
|
||||
world.resolveFenceCollision(t);
|
||||
} else if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) {
|
||||
if (movedSq <= 0.25 && !world.isTarinaiHiddenInNestBox?.(t)) {
|
||||
const now = world.time || 0;
|
||||
if (now >= (t._nextSolidRelaxAt || 0)) {
|
||||
const jitter = stableUnit(t.id || t.familyKey || 0, "solid-relax") * 0.45;
|
||||
|
|
|
|||
|
|
@ -26,26 +26,13 @@ function lineageBuildComponentsIdle(family, token, onProgress, onDone) {
|
|||
let dfsIndex = 0;
|
||||
const seen = new Set();
|
||||
|
||||
const addParent = (childId, parentId) => {
|
||||
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
|
||||
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
|
||||
parentsByChild.get(childId).add(parentId);
|
||||
childrenByParent.get(parentId).add(childId);
|
||||
};
|
||||
const addParent = (childId, parentId) => lineageAddParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
|
||||
const addGraphEdge = (a, b) => {
|
||||
if (!a || !b || !graph.has(a) || !graph.has(b)) return;
|
||||
graph.get(a).add(b);
|
||||
graph.get(b).add(a);
|
||||
};
|
||||
const validParentChild = (childId, parentId) => {
|
||||
const child = family[childId];
|
||||
const parent = family[parentId];
|
||||
if (!child || !parent) return false;
|
||||
const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId);
|
||||
const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId);
|
||||
return childListsParent || parentListsChild;
|
||||
};
|
||||
const validParentChild = (childId, parentId) => globalThis.TarinaiFamilyGraph.relationAllowsParentChild(family, childId, parentId);
|
||||
const finishEmpty = () => {
|
||||
lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } };
|
||||
onDone([]);
|
||||
|
|
|
|||
|
|
@ -5,23 +5,12 @@ function lineageEdgeFamily(family = world.family || {}) {
|
|||
|
||||
let lineageRelationIndexCache = { family: null, version: null, index: null };
|
||||
|
||||
function lineageAddParentEdge(childId, parentId, known, parentsByChild, childrenByParent) {
|
||||
return globalThis.TarinaiFamilyGraph.addParentEdge(childId, parentId, known, parentsByChild, childrenByParent);
|
||||
}
|
||||
|
||||
function lineageBuildRelationIndex(family) {
|
||||
const known = new Set(Object.keys(family || {}));
|
||||
const parentsByChild = new Map();
|
||||
const childrenByParent = new Map();
|
||||
const addParent = (childId, parentId) => {
|
||||
if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return;
|
||||
if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set());
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set());
|
||||
parentsByChild.get(childId).add(parentId);
|
||||
childrenByParent.get(parentId).add(childId);
|
||||
};
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addParent(n.id, parentId);
|
||||
for (const childId of n.children || []) addParent(childId, n.id);
|
||||
}
|
||||
return { parentsByChild, childrenByParent };
|
||||
return globalThis.TarinaiFamilyGraph.buildRelationIndex(family);
|
||||
}
|
||||
|
||||
function lineageRelationIndex(family = world.family || {}) {
|
||||
|
|
|
|||
|
|
@ -21,15 +21,59 @@ function lineagePathFromPoints(points) {
|
|||
return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
||||
}
|
||||
|
||||
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) {
|
||||
function lineageNodeRectsBetween(positions, nodeW, nodeH, y1, y2, excludeIds = new Set(), pad = 10) {
|
||||
const top = Math.min(y1, y2) - pad;
|
||||
const bottom = Math.max(y1, y2) + pad;
|
||||
const rects = [];
|
||||
for (const pos of positions?.values?.() || []) {
|
||||
const id = pos?.node?.id;
|
||||
if (!pos || (id && excludeIds.has(id))) continue;
|
||||
const rectTop = pos.y - pad;
|
||||
const rectBottom = pos.y + nodeH + pad;
|
||||
if (rectBottom < top || rectTop > bottom) continue;
|
||||
rects.push({ left: pos.x - pad, right: pos.x + nodeW + pad, top: rectTop, bottom: rectBottom });
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
function lineageVerticalLaneClear(x, y1, y2, rects) {
|
||||
const top = Math.min(y1, y2);
|
||||
const bottom = Math.max(y1, y2);
|
||||
return !(rects || []).some(r => x > r.left && x < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom));
|
||||
}
|
||||
|
||||
function lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context = {}) {
|
||||
const ay = a.y + nodeH / 2;
|
||||
const by = b.y + nodeH / 2;
|
||||
const exclude = new Set([a.node?.id, b.node?.id].filter(Boolean));
|
||||
const rects = lineageNodeRectsBetween(context.positions, nodeW, nodeH, ay, by, exclude, 12);
|
||||
const ax = a.x + nodeW / 2;
|
||||
const bx = b.x + nodeW / 2;
|
||||
const ay = a.y + nodeH;
|
||||
const by = b.y + nodeH;
|
||||
const y = Math.max(unionY, ay + 12, by + 12);
|
||||
const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
|
||||
const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]];
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y };
|
||||
const directX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
|
||||
if (lineageVerticalLaneClear(directX, ay, by, rects)) return directX;
|
||||
const minLeft = Math.min(a.x, b.x, ...rects.map(r => r.left));
|
||||
const maxRight = Math.max(a.x + nodeW, b.x + nodeW, ...rects.map(r => r.right));
|
||||
const width = Number(context.width || 0) || Infinity;
|
||||
const candidates = [
|
||||
maxRight + 18,
|
||||
minLeft - 18,
|
||||
Math.max(a.x + nodeW, b.x + nodeW) + 18,
|
||||
Math.min(a.x, b.x) - 18,
|
||||
].filter(x => Number.isFinite(x) && x >= 12 && x <= width - 12);
|
||||
const clear = candidates.filter(x => lineageVerticalLaneClear(x, ay, by, rects));
|
||||
const pool = clear.length ? clear : candidates;
|
||||
if (pool.length) return pool.sort((x, y) => Math.abs(x - directX) - Math.abs(y - directX))[0];
|
||||
return directX;
|
||||
}
|
||||
|
||||
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context = {}) {
|
||||
const railX = lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context);
|
||||
const aMidY = a.y + nodeH / 2;
|
||||
const bMidY = b.y + nodeH / 2;
|
||||
const aSideX = railX >= a.x + nodeW / 2 ? a.x + nodeW : a.x;
|
||||
const bSideX = railX >= b.x + nodeW / 2 ? b.x + nodeW : b.x;
|
||||
const points = [[aSideX, aMidY], [railX, aMidY], [railX, bMidY], [bSideX, bMidY]];
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: railX, unionY: Math.max(aMidY, bMidY) };
|
||||
}
|
||||
|
||||
function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
|
|
@ -54,8 +98,8 @@ function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffs
|
|||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y };
|
||||
}
|
||||
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY);
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) {
|
||||
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context);
|
||||
return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB);
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +342,7 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
const attachA = 0;
|
||||
const attachB = 0;
|
||||
const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB, layout);
|
||||
if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX;
|
||||
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
|
||||
const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36);
|
||||
|
|
|
|||
|
|
@ -374,13 +374,10 @@
|
|||
if (!p?.inside) return false;
|
||||
global.TarinaiHistory.capture(world, "shoot");
|
||||
uiCache.shooting = true;
|
||||
uiCache.shootMoved = false;
|
||||
uiCache.shootSuppressClick = true;
|
||||
uiCache.shootFired = false;
|
||||
uiCache.shootStartX = clientX;
|
||||
uiCache.shootStartY = clientY;
|
||||
uiCache.shootLastClientX = clientX;
|
||||
uiCache.shootLastClientY = clientY;
|
||||
world.pointer.x = p.x;
|
||||
world.pointer.y = p.y;
|
||||
world.pointer.inside = true;
|
||||
|
|
@ -404,8 +401,6 @@
|
|||
}
|
||||
|
||||
function moveShoot(p, clientX, clientY, options = {}) {
|
||||
uiCache.shootLastClientX = clientX;
|
||||
uiCache.shootLastClientY = clientY;
|
||||
const rawDx = (Number(p.x) || 0) - (Number(uiCache.shootLastRawWorldX ?? p.x) || 0);
|
||||
const rawDy = (Number(p.y) || 0) - (Number(uiCache.shootLastRawWorldY ?? p.y) || 0);
|
||||
uiCache.shootLastRawWorldX = p.x;
|
||||
|
|
@ -416,7 +411,6 @@
|
|||
if (p.inside) world.moveShootCursorBy?.(rawDx, rawDy);
|
||||
const totalMove = Math.hypot(clientX - (uiCache.shootStartX || clientX), clientY - (uiCache.shootStartY || clientY));
|
||||
if (totalMove > 3) {
|
||||
uiCache.shootMoved = true;
|
||||
if (options.touch) uiCache.touchMoved = true;
|
||||
}
|
||||
canvas.style.cursor = "none";
|
||||
|
|
@ -523,7 +517,6 @@
|
|||
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();
|
||||
if (world.tool === "shoot") {
|
||||
if (p.inside) {
|
||||
if (world.shootCursorActive) {
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ function applyFieldLayout(fieldType = world.fieldType || "garden") {
|
|||
resizeCanvas();
|
||||
}
|
||||
|
||||
const RESET_PRESET_IDS = new Set(["empty", "default", "athletic", "crowded", "family", "war", "happy"]);
|
||||
|
||||
function normalizeResetPresetId(presetId = "default") {
|
||||
return RESET_PRESET_IDS.has(String(presetId || "")) ? String(presetId) : "default";
|
||||
if (window.TarinaiResetPresets?.normalize) return window.TarinaiResetPresets.normalize(presetId);
|
||||
return new Set(["empty", "default", "athletic", "crowded", "family", "war", "happy"]).has(String(presetId || "")) ? String(presetId) : "default";
|
||||
}
|
||||
|
||||
function syncFieldDialogChoices() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
const SCALABLE_TOOLS = new Set(scalableToolIds());
|
||||
const EMOJI_ICON_TOOLS = new Set(["poke", "pinch", "water_hose", "fire", "flame_firecracker", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete", "clear_tool"]);
|
||||
const TEXT_ICON_TOOL_IDS = new Set(["poke", "pinch", "water_hose", "rope", "rod", "reciprocator", "copy", "undo", "redo", "area_delete", "clear_tool"]);
|
||||
|
||||
function applyToolTextIcon(btn, def, toolId) {
|
||||
if (!btn || !def?.iconText || !TEXT_ICON_TOOL_IDS.has(toolId)) return false;
|
||||
btn.classList.add("tool-emoji-icon");
|
||||
btn.style.setProperty("--tool-emoji", JSON.stringify(String(def.iconText)));
|
||||
return true;
|
||||
}
|
||||
|
||||
const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"];
|
||||
|
||||
|
|
@ -30,7 +37,7 @@ function renderToolPalette() {
|
|||
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");
|
||||
applyToolTextIcon(btn, def, toolId);
|
||||
btn.dataset.tool = toolId;
|
||||
btn.dataset.toolCategory = category.id || "";
|
||||
const label = document.createElement("span");
|
||||
|
|
@ -69,7 +76,7 @@ function createToolPreviewCanvas(className, size = 96) {
|
|||
|
||||
function attachToolSpritePreview(btn, def, toolId) {
|
||||
const itemType = def?.itemType || toolId;
|
||||
if (!btn || !itemType || !def?.placeable) return;
|
||||
if (!btn || !itemType || !def?.placeable || TEXT_ICON_TOOL_IDS.has(toolId)) return;
|
||||
if (toolId === "reciprocator") return;
|
||||
if (toolId === "ball" || itemType === "ball") return;
|
||||
if (typeof window.drawToolItemPreview !== "function") return;
|
||||
|
|
|
|||
29
js/update_step_pipeline_runner.js
Normal file
29
js/update_step_pipeline_runner.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: shared-runtime/pipeline
|
||||
// Small helper for explicit ordered update-step pipelines.
|
||||
(function (global) {
|
||||
function create(stepNames, invokeStep) {
|
||||
const names = Object.freeze([...(stepNames || [])]);
|
||||
let cachedSteps = null;
|
||||
|
||||
function steps() {
|
||||
if (!cachedSteps) cachedSteps = names.map(name => global[name]);
|
||||
return cachedSteps;
|
||||
}
|
||||
|
||||
function run(...args) {
|
||||
const concrete = steps();
|
||||
if (concrete.length !== names.length || concrete.some(step => !step || typeof step.update !== "function")) return false;
|
||||
for (const step of concrete) {
|
||||
const result = invokeStep(step, ...args) || {};
|
||||
if (result.done) return result.ok !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.freeze({ run });
|
||||
}
|
||||
|
||||
global.TarinaiUpdateStepPipelineRunner = Object.freeze({ create });
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
(function () {
|
||||
const APP_VERSION = "39.15.98";
|
||||
const APP_BUILD = "tarinai-fire-system-v39-15-98";
|
||||
const APP_VERSION = "39.15.101";
|
||||
const APP_BUILD = "tarinai-fire-effects-v39-15-101";
|
||||
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;
|
||||
|
||||
|
|
|
|||
56
js/world.js
56
js/world.js
|
|
@ -13,31 +13,8 @@ class World { constructor() {
|
|||
this.toolSize = "medium";
|
||||
this.toolSizes = {};
|
||||
this.toolAngles = {};
|
||||
this.tarinai = [];
|
||||
this.items = [];
|
||||
this.ants = [];
|
||||
this.effects = [];
|
||||
this.logs = [];
|
||||
this.events = window.TarinaiEvents || null;
|
||||
this.eventCounters = {};
|
||||
this.countsTimer = 0;
|
||||
this.compactTimer = 0;
|
||||
this.drawSortTimer = 0;
|
||||
this.drawListDirty = true;
|
||||
this.antUpdatePhase = 0;
|
||||
this.selected = null;
|
||||
this.deadCount = 0;
|
||||
this.liveIdNext = 1;
|
||||
this.liveIdSerial = 0;
|
||||
this.liveTarinai = new Map();
|
||||
this.lastBirthAt = -999;
|
||||
this.maxGeneration = 1;
|
||||
this.family = {};
|
||||
this.familyVersion = 0;
|
||||
this.familyTreeDirty = true;
|
||||
this.relationNotices = {};
|
||||
this.resolvedFightIds = {};
|
||||
this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
|
||||
this.resetRuntimeCollections();
|
||||
this.pointer = { x: 0, y: 0, inside: false, motion: 0 };
|
||||
this.hoverGrabTarget = null;
|
||||
this.hoverDeleteTarget = null;
|
||||
this.shootCursorActive = false;
|
||||
|
|
@ -65,7 +42,6 @@ class World { constructor() {
|
|||
this.currentTemperature = 10;
|
||||
this.temperatureAnnualRange = 25 + tempAnnualUnit * 10;
|
||||
this.temperatureDailyRange = 0;
|
||||
this.temperatureLastUpdateAt = -Infinity;
|
||||
this.spatial = new SpatialGrid(96);
|
||||
this.spatialItemScratch = [];
|
||||
this.spatialTarinaiScratch = [];
|
||||
|
|
@ -89,6 +65,34 @@ class World { constructor() {
|
|||
this.markTerrainDirty?.("reset");
|
||||
this.rebuildSpatial(true);
|
||||
}
|
||||
|
||||
resetRuntimeCollections() {
|
||||
this.tarinai = [];
|
||||
this.items = [];
|
||||
this.ants = [];
|
||||
this.effects = [];
|
||||
this.logs = [];
|
||||
this.events = window.TarinaiEvents || null;
|
||||
this.eventCounters = {};
|
||||
this.countsTimer = 0;
|
||||
this.compactTimer = 0;
|
||||
this.drawSortTimer = 0;
|
||||
this.drawListDirty = true;
|
||||
this.antUpdatePhase = 0;
|
||||
this.selected = null;
|
||||
this.deadCount = 0;
|
||||
this.liveIdNext = 1;
|
||||
this.liveIdSerial = 0;
|
||||
this.liveTarinai = new Map();
|
||||
this.lastBirthAt = -999;
|
||||
this.maxGeneration = 1;
|
||||
this.family = {};
|
||||
this.familyVersion = 0;
|
||||
this.familyTreeDirty = true;
|
||||
this.relationNotices = {};
|
||||
this.resolvedFightIds = {};
|
||||
this.fightPairCooldowns = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") window.World = World;
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@
|
|||
it.hp = 0;
|
||||
it.growth = 0;
|
||||
it.grassStage = -1;
|
||||
it.removedReason = "water-hose-grass";
|
||||
}
|
||||
cleaned += p * 0.72;
|
||||
} else if (it.type === "grass_bed") {
|
||||
|
|
@ -358,7 +357,6 @@
|
|||
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
|
||||
it.amount = 260;
|
||||
it.stage = "fresh";
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@
|
|||
|
||||
|
||||
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
|
||||
const c = Math.cos(angle);
|
||||
const s = Math.sin(angle);
|
||||
const ex = Math.abs(c) * halfW + Math.abs(s) * halfH;
|
||||
const ey = Math.abs(s) * halfW + Math.abs(c) * halfH;
|
||||
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
|
||||
return global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
|
||||
}
|
||||
|
||||
function obstacleQueryDistanceLocal(x, y, it) {
|
||||
|
|
@ -113,9 +109,7 @@
|
|||
}
|
||||
|
||||
function rectWorldNormal(r, nx, ny) {
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
||||
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
||||
return { x: nx * c - ny * s, y: nx * s + ny * c };
|
||||
return global.TarinaiGeometry.rectWorldNormal(r, nx, ny);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -186,24 +180,8 @@
|
|||
}
|
||||
|
||||
|
||||
function segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, padding = 0) {
|
||||
const a = rectLocalPoint(r, x1, y1);
|
||||
const b = rectLocalPoint(r, x2, y2);
|
||||
const minX = -(r.halfW || 0) - padding;
|
||||
const maxX = (r.halfW || 0) + padding;
|
||||
const minY = -(r.halfH || 0) - padding;
|
||||
const maxY = (r.halfH || 0) + padding;
|
||||
let t0 = 0, t1 = 1;
|
||||
const clip = (p, q) => {
|
||||
if (Math.abs(p) < 1e-9) return q >= 0;
|
||||
const t = q / p;
|
||||
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
|
||||
else { if (t < t0) return false; if (t < t1) t1 = t; }
|
||||
return true;
|
||||
};
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
return clip(-dx, a.x - minX) && clip(dx, maxX - a.x) && clip(-dy, a.y - minY) && clip(dy, maxY - a.y);
|
||||
function segmentHitsLocalRect(x1, y1, x2, y2, r, padding = 0) {
|
||||
return global.TarinaiGeometry.segmentIntersectsOrientedRect(x1, y1, x2, y2, r, padding);
|
||||
}
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
|
|
@ -232,255 +210,6 @@
|
|||
return best;
|
||||
},
|
||||
|
||||
temperatureNumber(value, fallback = CONFIG.standardTemperature ?? 15) {
|
||||
const v = Number(value);
|
||||
const fb = Number(fallback);
|
||||
return Number.isFinite(v) ? v : (Number.isFinite(fb) ? fb : 15);
|
||||
},
|
||||
|
||||
climateTemperature(value) {
|
||||
// \u81EA\u7136\u6C17\u5019\u3060\u3051\u306F\u4ED5\u69D8\u3069\u304A\u308A -20\u2103\u301C50\u2103 \u306E\u7BC4\u56F2\u306B\u53CE\u3081\u308B\u3002
|
||||
const v = this.temperatureNumber(value);
|
||||
return clamp(v, CONFIG.climateTemperatureMin ?? -20, CONFIG.climateTemperatureMax ?? 50);
|
||||
},
|
||||
|
||||
seasonIndex() {
|
||||
return Math.floor((((Number(this.day) || 1) - 1) % 20) / 5);
|
||||
},
|
||||
|
||||
seasonLabel() {
|
||||
return ["\u6625", "\u590F", "\u79CB", "\u51AC"][this.seasonIndex()] || "\u6625";
|
||||
},
|
||||
|
||||
seasonDay() {
|
||||
return ((((Number(this.day) || 1) - 1) % 5) + 1);
|
||||
},
|
||||
|
||||
seasonDayString() {
|
||||
return `${this.seasonLabel()}${this.seasonDay()}\u65E5`;
|
||||
},
|
||||
|
||||
temperatureStableUnit(salt = "") {
|
||||
const key = `${this.worldSeed || "world"}:temperature:${salt}`;
|
||||
if (typeof stableUnit === "function") return stableUnit(key, "temperature");
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 16777619);
|
||||
return ((h >>> 0) % 1000000) / 1000000;
|
||||
},
|
||||
|
||||
dailyTemperatureRange(day = this.day, weather = this.weather) {
|
||||
const raw = this.temperatureStableUnit(`daily-range:${day}`) * 15;
|
||||
if (weather === "sunny") return clamp(6 + raw * 0.62, 0, 15);
|
||||
if (weather === "light_rain") return clamp(raw * 0.32, 0, 5);
|
||||
return clamp(raw * 0.45, 0, 7);
|
||||
},
|
||||
|
||||
annualTemperatureOffset() {
|
||||
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
||||
const totalDays = Math.max(0, Number(this.time || 0) / dayLength);
|
||||
const phase = ((totalDays % 20) + 20) % 20 / 20;
|
||||
const range = Number.isFinite(Number(this.temperatureAnnualRange)) ? Number(this.temperatureAnnualRange) : 30;
|
||||
const wave = Math.sin(phase * Math.PI * 2 - Math.PI * 0.25);
|
||||
return wave * range * 0.5;
|
||||
},
|
||||
|
||||
dailyTemperatureWave(progress = 0) {
|
||||
const hour = ((((Number(progress) || 0) % 1) + 1) % 1) * 24;
|
||||
const minHour = 5.0; // \u671D\u65B9\u306B\u6700\u4F4E\u6C17\u6E29
|
||||
const maxHour = 14.0; // \u663C\u904E\u304E\u306B\u6700\u9AD8\u6C17\u6E29
|
||||
const smooth = (x) => {
|
||||
const t = clamp(x, 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
let warmth;
|
||||
if (hour >= minHour && hour <= maxHour) {
|
||||
warmth = smooth((hour - minHour) / Math.max(0.001, maxHour - minHour));
|
||||
} else {
|
||||
const downHour = hour > maxHour ? hour - maxHour : hour + 24 - maxHour;
|
||||
const downSpan = minHour + 24 - maxHour;
|
||||
warmth = 1 - smooth(downHour / Math.max(0.001, downSpan));
|
||||
}
|
||||
return warmth * 2 - 1;
|
||||
},
|
||||
|
||||
naturalTemperature() {
|
||||
// \u521D\u671F\u30ED\u30FC\u30C9\u76F4\u5F8C\u306F 10\u2103 \u3092\u4FDD\u6301\u3057\u3001\u521D\u56DE\u30D5\u30EC\u30FC\u30E0\u306Edt\u63FA\u308C\u30670\u2103\u8FD1\u304F\u3078\u843D\u3061\u306A\u3044\u3088\u3046\u306B\u3059\u308B\u3002
|
||||
if ((Number(this.time) || 0) <= 6.0 && (Number(this.day) || 1) === 1) {
|
||||
this.temperatureDailyRange = 0;
|
||||
return 10;
|
||||
}
|
||||
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
||||
const progress = ((Number(this.time || 0) % dayLength) + dayLength) % dayLength / dayLength;
|
||||
const dailyRange = this.dailyTemperatureRange(this.day, this.weather);
|
||||
const dailyWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(progress) : Math.sin(progress * Math.PI * 2 - Math.PI * 0.5);
|
||||
let temp;
|
||||
if ((Number(this.day) || 1) === 1) {
|
||||
// \u66251\u65E5\u76EE\u306F\u958B\u59CB\u664210\u2103\u3092\u57FA\u6E96\u306B\u3057\u3064\u3064\u3001\u65E5\u5185\u306E\u6700\u4F4E/\u6700\u9AD8\u6642\u523B\u3060\u3051\u306F\u7DAD\u6301\u3059\u308B\u3002
|
||||
const initialWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(0) : Math.sin(-Math.PI * 0.5);
|
||||
temp = 10 + (dailyWave - initialWave) * dailyRange * 0.5;
|
||||
} else {
|
||||
temp = (CONFIG.standardTemperature ?? 15) + this.annualTemperatureOffset() + dailyWave * dailyRange * 0.5;
|
||||
}
|
||||
this.temperatureDailyRange = dailyRange;
|
||||
return this.climateTemperature(temp);
|
||||
},
|
||||
|
||||
updateTemperature(dt = 0) {
|
||||
if (!Number.isFinite(Number(this.temperatureAnnualRange))) {
|
||||
this.temperatureAnnualRange = 25 + this.temperatureStableUnit("annual-range") * 10;
|
||||
}
|
||||
if (!Number.isFinite(Number(this.manualTemperature))) this.manualTemperature = CONFIG.standardTemperature ?? 15;
|
||||
this.currentTemperature = this.temperatureAuto === false
|
||||
? this.temperatureNumber(this.manualTemperature)
|
||||
: this.naturalTemperature();
|
||||
this.temperatureLastUpdateAt = this.time || 0;
|
||||
return this.currentTemperature;
|
||||
},
|
||||
|
||||
setTemperatureAuto(enabled = true) {
|
||||
this.temperatureAuto = Boolean(enabled);
|
||||
if (this.temperatureAuto === false) this.manualTemperature = this.temperatureNumber(this.currentTemperature ?? this.manualTemperature ?? CONFIG.standardTemperature ?? 15);
|
||||
return this.updateTemperature?.(0) ?? this.currentTemperature;
|
||||
},
|
||||
|
||||
setManualTemperature(value = 15) {
|
||||
this.manualTemperature = this.temperatureNumber(value);
|
||||
if (this.temperatureAuto === false) this.currentTemperature = this.manualTemperature;
|
||||
return this.manualTemperature;
|
||||
},
|
||||
|
||||
temperatureAt(x, y, actor = null) {
|
||||
let temp = Number.isFinite(Number(this.currentTemperature)) ? Number(this.currentTemperature) : this.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15);
|
||||
if (this.temperatureAuto === false) temp = this.temperatureNumber(this.manualTemperature);
|
||||
if ((this.groundType || "soil") === "ice") temp -= 5;
|
||||
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
||||
for (const it of this.nearbyItems(x, y, range + 8)) {
|
||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
||||
const d = distXY(x, y, it.x, it.y);
|
||||
if (d > range) continue;
|
||||
const edge = d <= range - 24 ? 1 : clamp((range - d) / 24, 0, 1);
|
||||
temp += (it.type === "stove" ? 10 : -10) * edge;
|
||||
}
|
||||
const nestWarmthLimit = 5;
|
||||
for (const box of this.nearbyItems?.(x, y, 86) || []) {
|
||||
if (!box || box.dead || box.type !== "nest_box") continue;
|
||||
const r = Math.max(36, Number(box.r || 42) || 42);
|
||||
const base = this.nestBoxBaseRect?.(box) || null;
|
||||
const inBase = base && x >= base.left - 4 && x <= base.right + 4 && y >= base.top - 4 && y <= base.bottom + 4;
|
||||
const inBody = distXY(x, y, box.x, box.y) <= r * 1.08;
|
||||
if (!inBase && !inBody) continue;
|
||||
const toward = 20 - temp;
|
||||
temp += clamp(toward, -nestWarmthLimit, nestWarmthLimit);
|
||||
break;
|
||||
}
|
||||
if (actor && (actor.state === "sunbath" || (actor.sunbathTimer || 0) > 0.04)) temp += 3;
|
||||
return this.temperatureNumber(temp);
|
||||
},
|
||||
|
||||
feltTemperatureFor(actor, x = actor?.x, y = actor?.y) {
|
||||
if (!actor) return this.temperatureAt(x, y);
|
||||
return this.temperatureAt(x, y, actor);
|
||||
},
|
||||
|
||||
temperatureComfortOffsetFor(actor = null) {
|
||||
const raw = actor?.genetics?.temperatureOffset;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
},
|
||||
|
||||
temperatureStatusFor(temp = CONFIG.standardTemperature ?? 15, actor = null) {
|
||||
const value = this.temperatureNumber(temp);
|
||||
const offset = this.temperatureComfortOffsetFor(actor);
|
||||
const min = (CONFIG.temperatureComfortMin ?? 10) + offset;
|
||||
const max = (CONFIG.temperatureComfortMax ?? 25) + offset;
|
||||
const discomfort = value < min ? min - value : (value > max ? value - max : 0);
|
||||
const direction = value < min ? "cold" : (value > max ? "hot" : "comfort");
|
||||
const stressExcess = Math.max(0, discomfort - (CONFIG.temperatureStressMargin ?? 5));
|
||||
const harmExcess = Math.max(0, discomfort - (CONFIG.temperatureEnergyMargin ?? 10));
|
||||
return {
|
||||
temp: value,
|
||||
direction,
|
||||
discomfort,
|
||||
comfortable: discomfort <= 0,
|
||||
stressExcess,
|
||||
harmExcess,
|
||||
harmful: harmExcess > 0,
|
||||
label: discomfort <= 0 ? "\u5FEB\u9069" : (direction === "cold" ? (harmExcess > 0 ? "\u5371\u967A\u306A\u5BD2\u3055" : "\u5BD2\u3044") : (harmExcess > 0 ? "\u5371\u967A\u306A\u6691\u3055" : "\u6691\u3044")),
|
||||
};
|
||||
},
|
||||
|
||||
findComfortTemperatureSpot(actor, opts = {}) {
|
||||
if (!actor || actor.dead) return null;
|
||||
const current = this.feltTemperatureFor?.(actor) ?? this.temperatureAt(actor.x, actor.y, actor);
|
||||
const currentStatus = this.temperatureStatusFor(current, actor);
|
||||
if (!opts.force && currentStatus.comfortable) return null;
|
||||
const comfortOffset = this.temperatureComfortOffsetFor(actor);
|
||||
const targetTemp = ((CONFIG.temperatureComfortMin ?? 10) + (CONFIG.temperatureComfortMax ?? 25)) * 0.5 + comfortOffset;
|
||||
const radius = actor.radius || actor.r || 20;
|
||||
const rings = [72, 128, 196, 288, 392];
|
||||
const dirs = 18;
|
||||
const candidates = [];
|
||||
const pushCandidate = (x, y, extra = 0) => candidates.push({
|
||||
x: clamp(x, radius + 8, (this.w || 1000) - radius - 8),
|
||||
y: clamp(y, radius + 8, (this.h || 720) - radius - 8),
|
||||
extra,
|
||||
});
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < dirs; i++) {
|
||||
const a = (Math.PI * 2 * i / dirs) + ring * 0.017;
|
||||
pushCandidate(actor.x + Math.cos(a) * ring, actor.y + Math.sin(a) * ring);
|
||||
}
|
||||
}
|
||||
const itemRange = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
||||
for (const it of this.items || []) {
|
||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
||||
if (currentStatus.direction === "cold" && it.type !== "stove") continue;
|
||||
if (currentStatus.direction === "hot" && it.type !== "dry_ice") continue;
|
||||
const toward = Math.atan2(actor.y - it.y, actor.x - it.x);
|
||||
pushCandidate(it.x + Math.cos(toward) * itemRange * 0.45, it.y + Math.sin(toward) * itemRange * 0.45, -60);
|
||||
pushCandidate(it.x + Math.cos(toward + 0.75) * itemRange * 0.62, it.y + Math.sin(toward + 0.75) * itemRange * 0.62, -32);
|
||||
pushCandidate(it.x + Math.cos(toward - 0.75) * itemRange * 0.62, it.y + Math.sin(toward - 0.75) * itemRange * 0.62, -32);
|
||||
}
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const c of candidates) {
|
||||
if (this.pointBlockedByObstacle?.(c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor })) continue;
|
||||
const temp = this.temperatureAt(c.x, c.y, actor);
|
||||
const status = this.temperatureStatusFor(temp, actor);
|
||||
if (currentStatus.direction === "cold" && temp <= current + 0.6 && !status.comfortable) continue;
|
||||
if (currentStatus.direction === "hot" && temp >= current - 0.6 && !status.comfortable) continue;
|
||||
const d = distXY(actor.x, actor.y, c.x, c.y);
|
||||
const blockedPenalty = this.pathBlockedByFence?.(actor.x, actor.y, c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor }) ? 480 : 0;
|
||||
const score = status.discomfort * 110 + Math.abs(temp - targetTemp) * 2.8 + d * 0.22 + blockedPenalty + (c.extra || 0);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = { x: c.x, y: c.y, dead: false, detour: true, temperatureTarget: true, temperature: temp, label: "\u5FEB\u9069\u306A\u6E29\u5EA6\u5E2F" };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
const bestStatus = this.temperatureStatusFor(best.temperature, actor);
|
||||
if (opts.requireComfort && !bestStatus.comfortable) return null;
|
||||
if (!bestStatus.comfortable && bestStatus.discomfort >= currentStatus.discomfort - 0.65) return null;
|
||||
best.temperatureStatus = bestStatus;
|
||||
return best;
|
||||
},
|
||||
|
||||
hasReachableComfortTemperatureSpot(actor) {
|
||||
return !!this.findComfortTemperatureSpot?.(actor, { force: true, requireComfort: true });
|
||||
},
|
||||
|
||||
sleepTargetTemperatureBlocked(actor, item) {
|
||||
if (!actor || !item || item.dead) return false;
|
||||
if (!(item.type === "grass_bed" || item.type === "bed" || item.type === "nest_box")) return false;
|
||||
const current = this.feltTemperatureFor?.(actor) ?? this.temperatureAt?.(actor.x, actor.y, actor);
|
||||
const currentStatus = this.temperatureStatusFor?.(current, actor);
|
||||
if (!currentStatus || currentStatus.comfortable) return false;
|
||||
if (!this.hasReachableComfortTemperatureSpot?.(actor)) return false;
|
||||
const temp = this.temperatureAt?.(item.x, item.y, actor);
|
||||
const status = this.temperatureStatusFor?.(temp, actor);
|
||||
return Boolean(status && !status.comfortable);
|
||||
},
|
||||
|
||||
fenceRect(it) {
|
||||
const type = it?.type || "";
|
||||
if (!this.isFenceType(type)) return null;
|
||||
|
|
@ -1062,7 +791,7 @@
|
|||
|
||||
segmentIntersectsRect(x1, y1, x2, y2, r) {
|
||||
if (!r) return false;
|
||||
if (r.oriented) return segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, 0);
|
||||
if (r.oriented) return segmentHitsLocalRect(x1, y1, x2, y2, r, 0);
|
||||
if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true;
|
||||
const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => {
|
||||
const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px);
|
||||
|
|
@ -1116,238 +845,5 @@
|
|||
return false;
|
||||
},
|
||||
|
||||
findTarinaiPathWaypoint(actor, target, opts = {}) {
|
||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
||||
const tx = Number(target.x);
|
||||
const ty = Number(target.y);
|
||||
const ax = Number(actor.x);
|
||||
const ay = Number(actor.y);
|
||||
const targetItem = opts?.targetItem || target?.hostItem || (target?.isStructure || target?.type ? target : null);
|
||||
const exclude = opts?.exclude || targetItem || null;
|
||||
const rr = Math.max(8, Number(actor.radius || 20) || 20);
|
||||
const padding = Math.max(16, Number(opts.padding || 0) || rr * 0.78);
|
||||
const directClear = !this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude });
|
||||
if (directClear) {
|
||||
actor._pathWaypoint = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetId = targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
||||
const cached = actor._pathWaypoint || null;
|
||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
||||
&& !this.pointBlockedByObstacle(cached.x, cached.y, Math.max(6, padding * 0.45), { exclude, maxChecks: 18 })
|
||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
|
||||
}
|
||||
|
||||
const midX = (ax + tx) * 0.5;
|
||||
const midY = (ay + ty) * 0.5;
|
||||
const totalD = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
||||
const rects = this.nearbySolidObstacleRects?.(midX, midY, totalD * 0.5 + padding + 190, { exclude, maxChecks: Math.max(32, Number(opts.maxChecks || 0) || 46), maxRects: 80 }) || [];
|
||||
const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45;
|
||||
const candidates = [];
|
||||
const add = (x, y, sourceRect, grade = 0) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return;
|
||||
candidates.push({ x, y, sourceRect, grade });
|
||||
};
|
||||
const worldPoint = (r, lx, ly) => {
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
||||
const sn = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
||||
const cx = Number(r.cx ?? ((Number(r.left || 0) + Number(r.right || 0)) * 0.5)) || 0;
|
||||
const cy = Number(r.cy ?? ((Number(r.top || 0) + Number(r.bottom || 0)) * 0.5)) || 0;
|
||||
return { x: cx + lx * c - ly * sn, y: cy + lx * sn + ly * c };
|
||||
};
|
||||
const addRectCandidates = (r, index = 0) => {
|
||||
const pad = Math.max(34, padding + rr * 1.45 + 18 + Math.min(18, index * 3));
|
||||
if (r.oriented) {
|
||||
const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad;
|
||||
const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad;
|
||||
for (const [lx, ly, grade] of [[-hw, -hh, 0], [hw, -hh, 0], [hw, hh, 0], [-hw, hh, 0], [0, -hh, 1], [hw, 0, 1], [0, hh, 1], [-hw, 0, 1]]) {
|
||||
const p = worldPoint(r, lx, ly);
|
||||
add(p.x, p.y, r, grade);
|
||||
}
|
||||
} else {
|
||||
const left = Number(r.left || 0) - pad;
|
||||
const right = Number(r.right || 0) + pad;
|
||||
const top = Number(r.top || 0) - pad;
|
||||
const bottom = Number(r.bottom || 0) + pad;
|
||||
const cx = (left + right) * 0.5;
|
||||
const cy = (top + bottom) * 0.5;
|
||||
for (const [x, y, grade] of [[left, top, 0], [right, top, 0], [right, bottom, 0], [left, bottom, 0], [cx, top, 1], [right, cy, 1], [cx, bottom, 1], [left, cy, 1]]) add(x, y, r, grade);
|
||||
}
|
||||
};
|
||||
|
||||
let blockers = 0;
|
||||
for (const rect of rects) {
|
||||
if (!this.isFenceRoutingRect?.(rect)) continue;
|
||||
const inflated = rect?.oriented
|
||||
? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }
|
||||
: { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding };
|
||||
if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue;
|
||||
addRectCandidates(rect, blockers);
|
||||
blockers += 1;
|
||||
if (blockers >= 4) break;
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
|
||||
for (const c of candidates) {
|
||||
if (this.pointBlockedByObstacle(c.x, c.y, Math.max(5, padding * 0.42), { exclude, maxChecks: 20 })) continue;
|
||||
const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude });
|
||||
if (firstBlocked) continue;
|
||||
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
|
||||
const d1 = Math.hypot(c.x - ax, c.y - ay);
|
||||
const d2 = Math.hypot(tx - c.x, ty - c.y);
|
||||
const progress = totalD - d2;
|
||||
const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0;
|
||||
const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias;
|
||||
if (score < bestScore) { best = { ...c, secondBlocked }; bestScore = score; }
|
||||
}
|
||||
if (!best || (best.secondBlocked && opts.preferFullPath)) {
|
||||
const grid = this.findGridPathWaypoint?.(actor, target, { ...opts, targetItem, exclude, padding, targetId });
|
||||
if (grid) return grid;
|
||||
if (!best) return null;
|
||||
}
|
||||
actor._pathWaypoint = { x: best.x, y: best.y, targetId, until: (this.time || 0) + 0.95 };
|
||||
return { x: best.x, y: best.y, dead: false, detour: true, routeTargetId: targetId };
|
||||
},
|
||||
|
||||
findGridPathWaypoint(actor, target, opts = {}) {
|
||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
||||
const ax = Number(actor.x), ay = Number(actor.y), tx = Number(target.x), ty = Number(target.y);
|
||||
const targetId = opts.targetId || opts.targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
||||
const cached = actor._gridPathWaypoint || null;
|
||||
const padding = Math.max(16, Number(opts.padding || 0) || (Number(actor.radius || 20) * 0.78));
|
||||
const exclude = opts.exclude || opts.targetItem || target || null;
|
||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
||||
&& !this.pointBlockedByObstacle?.(cached.x, cached.y, Math.max(6, padding * 0.42), { exclude, maxChecks: 20 })
|
||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
||||
}
|
||||
|
||||
const dTotal = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
||||
const step = Math.max(42, Math.min(72, Number(opts.gridStep || 0) || dTotal / 7));
|
||||
const padWorld = (CONFIG.worldPadding || 28) + Math.max(8, Number(actor.radius || 20) * 0.45);
|
||||
const margin = Math.max(190, step * 3.2);
|
||||
const minX = Math.max(padWorld, Math.min(ax, tx) - margin);
|
||||
const maxX = Math.min(this.w - padWorld, Math.max(ax, tx) + margin);
|
||||
const minY = Math.max(padWorld, Math.min(ay, ty) - margin);
|
||||
const maxY = Math.min(this.h - padWorld, Math.max(ay, ty) + margin);
|
||||
const cols = Math.max(2, Math.min(18, Math.ceil((maxX - minX) / step) + 1));
|
||||
const rows = Math.max(2, Math.min(18, Math.ceil((maxY - minY) / step) + 1));
|
||||
const px = (ix) => cols <= 1 ? minX : minX + (maxX - minX) * ix / (cols - 1);
|
||||
const py = (iy) => rows <= 1 ? minY : minY + (maxY - minY) * iy / (rows - 1);
|
||||
const nearest = (x, y) => ({ ix: clamp(Math.round((x - minX) / Math.max(1, maxX - minX) * (cols - 1)), 0, cols - 1), iy: clamp(Math.round((y - minY) / Math.max(1, maxY - minY) * (rows - 1)), 0, rows - 1) });
|
||||
const start = nearest(ax, ay);
|
||||
const goal = nearest(tx, ty);
|
||||
const key = (ix, iy) => `${ix},${iy}`;
|
||||
const inBounds = (ix, iy) => ix >= 0 && iy >= 0 && ix < cols && iy < rows;
|
||||
const pointOpen = (ix, iy) => {
|
||||
if (!inBounds(ix, iy)) return false;
|
||||
if (ix === start.ix && iy === start.iy) return true;
|
||||
if (ix === goal.ix && iy === goal.iy) return true;
|
||||
return !this.pointBlockedByObstacle?.(px(ix), py(iy), Math.max(6, padding * 0.45), { exclude, maxChecks: 18 });
|
||||
};
|
||||
const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude });
|
||||
const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);
|
||||
const open = [{ ...start, g: 0, f: h(start.ix, start.iy), parent: null }];
|
||||
const bestByKey = new Map([[key(start.ix, start.iy), open[0]]]);
|
||||
let found = null;
|
||||
let guard = 0;
|
||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||||
while (open.length && guard++ < 420) {
|
||||
open.sort((a, b) => a.f - b.f);
|
||||
const cur = open.shift();
|
||||
if (cur.ix === goal.ix && cur.iy === goal.iy) { found = cur; break; }
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = cur.ix + dx, ny = cur.iy + dy;
|
||||
if (!pointOpen(nx, ny)) continue;
|
||||
const nb = { ix: nx, iy: ny };
|
||||
if (!edgeOpen(cur, nb)) continue;
|
||||
const diagonal = dx && dy;
|
||||
const ng = cur.g + (diagonal ? 1.42 : 1) * step;
|
||||
const k = key(nx, ny);
|
||||
const oldNode = bestByKey.get(k);
|
||||
if (oldNode && oldNode.g <= ng) continue;
|
||||
const node = { ix: nx, iy: ny, g: ng, f: ng + h(nx, ny), parent: cur };
|
||||
bestByKey.set(k, node);
|
||||
open.push(node);
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
const path = [];
|
||||
for (let n = found; n; n = n.parent) path.push(n);
|
||||
path.reverse();
|
||||
const chosen = path[Math.min(path.length - 1, Math.max(1, path.length > 3 ? 2 : 1))];
|
||||
if (!chosen) return null;
|
||||
const wx = px(chosen.ix), wy = py(chosen.iy);
|
||||
actor._gridPathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
||||
actor._pathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
||||
return { x: wx, y: wy, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
||||
},
|
||||
|
||||
grassBlockedAt(x, y, self = null, opts = {}) {
|
||||
const ignoreSoft = Boolean(opts?.ignoreSoftPlacement);
|
||||
for (const it of this.nearbyItems(x, y, 96)) {
|
||||
if (it === self || it.dead) continue;
|
||||
if (ignoreSoft && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
|
||||
for (const r of this.solidObstacleRects(it)) {
|
||||
if (this.pointInRect(x, y, r, 10)) return true;
|
||||
}
|
||||
if (it.type !== "zunchi") continue;
|
||||
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
|
||||
const dy = (y - it.y) / Math.max(12, it.r * 0.82);
|
||||
if (dx * dx + dy * dy < 1) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
||||
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
|
||||
if (it === self || it.dead || it.type !== "grass") continue;
|
||||
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
grassOnTarinaiAt(x, y, minGap = 24) {
|
||||
for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
|
||||
if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
|
||||
if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
|
||||
if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
|
||||
if (this.grassBlockedAt(x, y, self)) return false;
|
||||
if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
|
||||
if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
|
||||
const cx = clamp(x, 44, this.w - 44);
|
||||
const cy = clamp(y, 44, this.h - 44);
|
||||
if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
|
||||
const blockedAtCenter = this.grassBlockedAt(cx, cy);
|
||||
const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
|
||||
const outer = Math.max(maxRadius, inner + 42);
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const t = attempts <= 1 ? 1 : i / (attempts - 1);
|
||||
const radius = lerp(inner, outer, Math.sqrt(t));
|
||||
const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
|
||||
const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
|
||||
const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
||||
if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
|
|||
|
|
@ -29,354 +29,11 @@
|
|||
const World = global.World;
|
||||
if (!World) throw new Error("World is not available for mixin: world_family_social.js");
|
||||
|
||||
const RESET_PRESETS = Object.freeze({
|
||||
empty: { label: "\u7A7A\u767D" },
|
||||
default: { label: "\u30C7\u30D5\u30A9\u30EB\u30C8" },
|
||||
athletic: { label: "\u30A2\u30B9\u30EC\u30C1\u30C3\u30AF" },
|
||||
crowded: { label: "\u904E\u5BC6" },
|
||||
family: { label: "\u5BB6\u65CF" },
|
||||
war: { label: "\u6226\u4E89" },
|
||||
happy: { label: "\u5E78\u798F" },
|
||||
});
|
||||
|
||||
function normalizeResetPresetId(presetId = "default") {
|
||||
const id = String(presetId || "default");
|
||||
return Object.prototype.hasOwnProperty.call(RESET_PRESETS, id) ? id : "default";
|
||||
}
|
||||
|
||||
function presetScaleForField(field) {
|
||||
const id = field?.id || "garden";
|
||||
if (id === "cage") return 0.65;
|
||||
if (id === "park") return 1.45;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function presetCount(field, base, min = 0, max = Infinity) {
|
||||
const value = Math.round((Number(base) || 0) * presetScaleForField(field));
|
||||
return Math.max(min, Math.min(Number.isFinite(max) ? max : value, value));
|
||||
}
|
||||
|
||||
function randPoint(worldRef, pad = 78, zone = null) {
|
||||
const x0 = Math.max(pad, Number(zone?.x0 ?? pad));
|
||||
const x1 = Math.min(worldRef.w - pad, Number(zone?.x1 ?? (worldRef.w - pad)));
|
||||
const y0 = Math.max(pad, Number(zone?.y0 ?? pad));
|
||||
const y1 = Math.min(worldRef.h - pad, Number(zone?.y1 ?? (worldRef.h - pad)));
|
||||
return { x: rand(Math.min(x0, x1), Math.max(x0, x1)), y: rand(Math.min(y0, y1), Math.max(y0, y1)) };
|
||||
}
|
||||
|
||||
function randomPresetAngle(step = Math.PI / 4) {
|
||||
const n = Math.max(1, Math.round((Math.PI * 2) / Math.max(0.001, step)));
|
||||
return Math.floor(rand(0, n)) * step;
|
||||
}
|
||||
|
||||
function presetShapeExtent(segments) {
|
||||
let max = 0;
|
||||
for (const seg of segments || []) {
|
||||
for (let i = 0; i < 4; i += 2) max = Math.max(max, Math.hypot(Number(seg[i]) || 0, Number(seg[i + 1]) || 0));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function configurePresetMechanicalItem(item, opts = {}) {
|
||||
const body = item?.physicsBody;
|
||||
if (!body || (item.type !== "rotator" && item.type !== "reciprocator")) return item;
|
||||
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
|
||||
body.pose.x = item.x;
|
||||
body.pose.y = item.y;
|
||||
body.pose.angle = Number(item.angle) || 0;
|
||||
body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 };
|
||||
body.velocity.x = 0;
|
||||
body.velocity.y = 0;
|
||||
body.velocity.angular = 0;
|
||||
body.velocity.linear = 0;
|
||||
body.shape = body.shape || { model: "segments", thickness: 12, segments: [], version: 0 };
|
||||
|
||||
if (item.type === "rotator") {
|
||||
const segments = opts.segments || [
|
||||
[-92, 0, 92, 0],
|
||||
[0, -58, 0, 58],
|
||||
[-58, -42, 58, 42],
|
||||
[-58, 42, 58, -42],
|
||||
];
|
||||
body.motor = body.motor || { powered: true, speed: Math.PI * 0.18, direction: 1 };
|
||||
body.motor.powered = opts.motorOn !== false;
|
||||
body.motor.speed = Number.isFinite(Number(opts.motorSpeed)) ? Number(opts.motorSpeed) : Math.PI * 0.18;
|
||||
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
|
||||
body.shape.model = "segments";
|
||||
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
|
||||
body.shape.segments = segments.map(seg => seg.slice(0, 4));
|
||||
body.shape.version = (Number(body.shape.version) || 0) + 1;
|
||||
item.spin = 0;
|
||||
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
|
||||
} else if (item.type === "reciprocator") {
|
||||
const railAxis = Number.isFinite(Number(opts.railAxis)) ? Number(opts.railAxis) : (Number(item.angle) || 0);
|
||||
const segments = opts.segments || [
|
||||
[-92, -24, -36, -24],
|
||||
[-36, -24, -36, 24],
|
||||
[-36, 24, 36, 24],
|
||||
[36, 24, 36, -24],
|
||||
[36, -24, 92, -24],
|
||||
];
|
||||
body.motor = body.motor || { powered: true, speed: 36, direction: 1 };
|
||||
body.motor.powered = opts.railOn !== false;
|
||||
body.motor.speed = Number.isFinite(Number(opts.railMotorSpeed)) ? Number(opts.railMotorSpeed) : 36;
|
||||
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
|
||||
body.rail = body.rail || { axisAngle: railAxis, travel: 120, phase: 0, anchorX: item.x, anchorY: item.y };
|
||||
body.rail.axisAngle = railAxis;
|
||||
body.rail.travel = Number.isFinite(Number(opts.railTravel)) ? Number(opts.railTravel) : 120;
|
||||
body.rail.phase = Number.isFinite(Number(opts.railPhase)) ? Number(opts.railPhase) : 0;
|
||||
body.rail.anchorX = item.x;
|
||||
body.rail.anchorY = item.y;
|
||||
body.shape.model = "segments";
|
||||
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
|
||||
body.shape.segments = segments.map(seg => seg.slice(0, 4));
|
||||
body.shape.version = (Number(body.shape.version) || 0) + 1;
|
||||
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
|
||||
}
|
||||
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-mechanical-shape");
|
||||
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function syncPresetMechanicalPose(item) {
|
||||
const body = item?.physicsBody;
|
||||
if (!body) return;
|
||||
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
|
||||
body.pose.x = item.x;
|
||||
body.pose.y = item.y;
|
||||
body.pose.angle = Number(item.angle) || 0;
|
||||
if (body.rail) {
|
||||
body.rail.anchorX = item.x;
|
||||
body.rail.anchorY = item.y;
|
||||
}
|
||||
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-placement-pose");
|
||||
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
}
|
||||
|
||||
function presetItemFootprintRadius(worldRef, item) {
|
||||
const radius = globalThis.TarinaiCollisionFootprints?.itemFootprintRadius?.(worldRef, item);
|
||||
if (Number.isFinite(Number(radius))) {
|
||||
if (item?.type === "ball") return Math.max(34, Number(radius) * 1.35);
|
||||
return Math.max(14, Number(radius));
|
||||
}
|
||||
return Math.max(14, Number(item?.r || 16) || 16);
|
||||
}
|
||||
|
||||
function presetItemPlacementBlocked(worldRef, item, opts = {}) {
|
||||
if (!worldRef || !item || item.dead) return true;
|
||||
if (typeof worldRef.placementBlocked === "function" && worldRef.placementBlocked(item)) return true;
|
||||
const itemRects = worldRef.solidObstacleRects?.(item) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, item) || [];
|
||||
const itemRadius = presetItemFootprintRadius(worldRef, item);
|
||||
const search = Math.max(120, itemRadius * 3.2);
|
||||
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || worldRef.items || []) {
|
||||
if (!other || other === item || other.dead) continue;
|
||||
const sameType = other.type === item.type;
|
||||
const otherIsFence = worldRef.isFenceType?.(other.type) || false;
|
||||
const itemIsFence = worldRef.isFenceType?.(item.type) || false;
|
||||
if (!opts.avoidAllObstacles && !sameType && !otherIsFence && !itemIsFence) continue;
|
||||
const otherRects = worldRef.solidObstacleRects?.(other) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, other) || [];
|
||||
const otherRadius = presetItemFootprintRadius(worldRef, other);
|
||||
const margin = (itemIsFence || otherIsFence) ? 4 : Math.max(8, Math.min(itemRadius, otherRadius) * 0.28);
|
||||
if (itemRects.length && otherRects.length) {
|
||||
if (itemRects.some(a => otherRects.some(b => globalThis.TarinaiCollisionFootprints?.rectsOverlap?.(a, b, margin)))) return true;
|
||||
continue;
|
||||
}
|
||||
if (itemRects.length) {
|
||||
if (itemRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, other.x, other.y, otherRadius, margin))) return true;
|
||||
continue;
|
||||
}
|
||||
if (otherRects.length) {
|
||||
if (otherRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, item.x, item.y, itemRadius, margin))) return true;
|
||||
continue;
|
||||
}
|
||||
if (Math.hypot((item.x || 0) - (other.x || 0), (item.y || 0) - (other.y || 0)) < itemRadius + otherRadius + margin) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findPresetItemSpot(worldRef, item, opts = {}) {
|
||||
const allowOriginal = opts.allowOriginal !== false;
|
||||
if (allowOriginal && !presetItemPlacementBlocked(worldRef, item, opts)) return { x: item.x, y: item.y };
|
||||
const solid = Boolean(worldRef?.isFenceType?.(item.type) || item.type === "rotator" || item.type === "reciprocator" || item.type === "nest_box");
|
||||
const count = Math.max(12, Number(opts.attempts || 0) || (solid ? 96 : 58));
|
||||
const maxRadius = Math.max(70, Number(opts.maxRadius || 0) || (solid ? 220 : 170));
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
const seed = `${item.type}:${Math.round(item.x)},${Math.round(item.y)}`;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count <= 1 ? 1 : i / (count - 1);
|
||||
const radius = Math.max(10, maxRadius * Math.sqrt(t));
|
||||
const angle = i * golden + (globalThis.stableUnit ? globalThis.stableUnit(seed, "preset-place") : Math.random()) * Math.PI * 2;
|
||||
const rawX = item.x + Math.cos(angle) * radius;
|
||||
const rawY = item.y + Math.sin(angle) * radius * 0.74;
|
||||
const p = worldRef.placementClampPointFor?.(item, rawX, rawY) || { x: clamp(rawX, 42, worldRef.w - 42), y: clamp(rawY, 42, worldRef.h - 42) };
|
||||
const oldX = item.x, oldY = item.y;
|
||||
item.x = p.x; item.y = p.y; syncPresetMechanicalPose(item);
|
||||
const blocked = presetItemPlacementBlocked(worldRef, item, opts);
|
||||
item.x = oldX; item.y = oldY; syncPresetMechanicalPose(item);
|
||||
if (blocked) continue;
|
||||
const d = Math.hypot(p.x - oldX, p.y - oldY);
|
||||
if (d < bestD) { best = p; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function addPresetItem(worldRef, type, x, y, opts = {}) {
|
||||
const item = new Item(type, x, y);
|
||||
if (Number.isFinite(Number(opts.r))) item.r = Number(opts.r);
|
||||
if (Number.isFinite(Number(opts.angle))) item.angle = Number(opts.angle);
|
||||
if (opts.toolSize) item.toolSize = opts.toolSize;
|
||||
if (type === "duplicator") {
|
||||
item.storedFoodType = opts.storedFoodType || "sweet";
|
||||
item.storedFoodLabel = typeof toolLabel === "function" ? toolLabel(item.storedFoodType) : item.storedFoodType;
|
||||
if (item.roles) item.roles.food = true;
|
||||
}
|
||||
if (type === "rotator" || type === "reciprocator") configurePresetMechanicalItem(item, opts);
|
||||
if (opts.avoidOverlap) {
|
||||
const spot = findPresetItemSpot(worldRef, item, opts);
|
||||
if (!spot) return null;
|
||||
item.x = spot.x;
|
||||
item.y = spot.y;
|
||||
syncPresetMechanicalPose(item);
|
||||
}
|
||||
if (worldRef.addItem) worldRef.addItem(item, opts.reason || `preset-${type}`);
|
||||
else worldRef.items.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function addPresetGrass(worldRef, count, zone = null) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = randPoint(worldRef, 70, zone);
|
||||
const grass = addPresetItem(worldRef, "grass", p.x, p.y, { reason: "preset-grass" });
|
||||
globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetTarinai(worldRef, count, zone = null, opts = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = randPoint(worldRef, 82, zone);
|
||||
worldRef.addTarinai({ x: p.x, y: p.y, generation: opts.generation || 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetItemsScattered(worldRef, type, count, zone = null, opts = {}) {
|
||||
let placed = 0;
|
||||
const n = Math.max(0, Number(count) | 0);
|
||||
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
|
||||
for (let i = 0; i < attempts && placed < n; i++) {
|
||||
const p = randPoint(worldRef, opts.pad || 82, zone);
|
||||
const item = addPresetItem(worldRef, type, p.x, p.y, opts);
|
||||
if (item) placed++;
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetFenceLine(worldRef, count, yRatio, angle = 0, type = "fence_h", opts = {}) {
|
||||
const n = Math.max(1, Number(count) | 0);
|
||||
const gap = worldRef.w / (n + 1);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
addPresetItem(worldRef, type, gap * i, worldRef.h * yRatio + rand(-24, 24), { angle, reason: opts.reason || "preset-fence", avoidOverlap: !!opts.avoidOverlap, avoidAllObstacles: !!opts.avoidAllObstacles, attempts: opts.attempts || 96, maxRadius: opts.maxRadius || 190 });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetFencesScattered(worldRef, count, zone = null, opts = {}) {
|
||||
const n = Math.max(0, Number(count) | 0);
|
||||
let placed = 0;
|
||||
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
|
||||
for (let i = 0; i < attempts && placed < n; i++) {
|
||||
const p = randPoint(worldRef, opts.pad || 78, zone);
|
||||
const vertical = Math.random() < 0.5;
|
||||
const item = addPresetItem(worldRef, vertical ? "fence_v" : "fence_h", p.x, p.y, { angle: randomPresetAngle(Math.PI / 4), reason: opts.reason || "preset-fence-scatter", avoidOverlap: !!opts.avoidOverlap, attempts: opts.attempts || 80, maxRadius: opts.maxRadius || 180 });
|
||||
if (item) placed++;
|
||||
}
|
||||
}
|
||||
|
||||
function generateDefaultPreset(worldRef, field, seedPopulation) {
|
||||
const population = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
|
||||
const grassCount = field.grass ?? 18;
|
||||
addPresetTarinai(worldRef, population);
|
||||
addPresetGrass(worldRef, grassCount);
|
||||
addPresetItem(worldRef, "stone", worldRef.w * 0.68, worldRef.h * 0.32, { reason: "reset-stone" });
|
||||
}
|
||||
|
||||
function generateAthleticPreset(worldRef, field) {
|
||||
const factor = presetScaleForField(field);
|
||||
const fieldId = field?.id || "garden";
|
||||
const compact = fieldId === "cage" || fieldId === "garden";
|
||||
const center = { x0: worldRef.w * 0.10, x1: worldRef.w * 0.90, y0: worldRef.h * 0.14, y1: worldRef.h * 0.86 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 14, 7, 24), { x0: worldRef.w * 0.08, x1: worldRef.w * 0.30, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 });
|
||||
// \u7269\u7406\u30D6\u30ED\u30C3\u30AF\u306F\u67F5\u3088\u308A\u5148\u306B\u7F6E\u304F\u3002\u72ED\u3044\u4E2D\u5C0F\u30D5\u30A3\u30FC\u30EB\u30C9\u3067\u306F\u67F5\u5074\u3092\u9593\u5F15\u304D\u3001\u30D6\u30ED\u30C3\u30AF\u3092\u512A\u5148\u3059\u308B\u3002
|
||||
addPresetItemsScattered(worldRef, "stone", presetCount(field, compact ? 4 : 6, 3, 9), center, { pad: compact ? 118 : 132, reason: "preset-athletic-physical-block", avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 260 : 220 });
|
||||
addPresetItemsScattered(worldRef, "stove", presetCount(field, 2, 1, 3), center, { pad: 118, reason: "preset-athletic-stove", avoidOverlap: true, avoidAllObstacles: true, attempts: 100, maxRadius: 210 });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, compact ? 8 : 10, 4, 18), null, { pad: 96, reason: "preset-ball", avoidOverlap: true, avoidAllObstacles: true, attempts: 90, maxRadius: compact ? 230 : 190 });
|
||||
addPresetFenceLine(worldRef, Math.max(2, Math.round((compact ? 3 : 4) * factor)), 0.36, 0, "fence_h", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
|
||||
addPresetFenceLine(worldRef, Math.max(1, Math.round((compact ? 2 : 3) * factor)), 0.62, Math.PI / 2, "fence_v", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
|
||||
addPresetFencesScattered(worldRef, Math.max(1, Math.round((compact ? 2 : 4) * factor)), null, { pad: 108, reason: "preset-athletic-fence", avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210 });
|
||||
addPresetItemsScattered(worldRef, "rotator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-rotator-slow-custom", motorSpeed: Math.PI * 0.18, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
|
||||
addPresetItemsScattered(worldRef, "reciprocator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-reciprocator-slow-custom", railMotorSpeed: 36, railTravel: 120, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
|
||||
}
|
||||
|
||||
function generateCrowdedPreset(worldRef, field) {
|
||||
const center = { x0: worldRef.w * 0.18, x1: worldRef.w * 0.82, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 36, 18, 58), center);
|
||||
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 13, 7, 22), center, { pad: 96, reason: "preset-nest-box" });
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), center, { pad: 110, storedFoodType: "sweet", reason: "preset-duplicator-zunda" });
|
||||
addPresetGrass(worldRef, presetCount(field, 18, 8, 34), center);
|
||||
addPresetItemsScattered(worldRef, "water", presetCount(field, 8, 4, 16), center, { pad: 84, reason: "preset-water" });
|
||||
}
|
||||
|
||||
function generateFamilyPreset(worldRef, field) {
|
||||
const cx = worldRef.w * 0.5;
|
||||
const cy = worldRef.h * 0.52;
|
||||
worldRef.addTarinai({ x: cx - 52, y: cy + 20, generation: 1 });
|
||||
worldRef.addTarinai({ x: cx + 52, y: cy + 20, generation: 1 });
|
||||
addPresetItem(worldRef, "nest_box", cx, cy - 58, { reason: "preset-family-nest" });
|
||||
addPresetItem(worldRef, "duplicator", cx, cy + 112, { storedFoodType: "love_mochi", reason: "preset-family-duplicator" });
|
||||
}
|
||||
|
||||
function generateWarPreset(worldRef, field) {
|
||||
const left = { x0: worldRef.w * 0.08, x1: worldRef.w * 0.45, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
const right = { x0: worldRef.w * 0.55, x1: worldRef.w * 0.92, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
addPresetTarinai(worldRef, Math.ceil(presetCount(field, 28, 14, 48) / 2), left);
|
||||
addPresetTarinai(worldRef, Math.floor(presetCount(field, 28, 14, 48) / 2), right);
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), { x0: worldRef.w * 0.38, x1: worldRef.w * 0.62, y0: worldRef.h * 0.22, y1: worldRef.h * 0.78 }, { pad: 96, storedFoodType: "fight_mochi", reason: "preset-war-duplicator" });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, 8, 4, 14), null, { pad: 90, reason: "preset-war-ball" });
|
||||
addPresetItemsScattered(worldRef, "ant_nest", presetCount(field, 1.25, 1, 2), null, { pad: 116, reason: "preset-war-ant-nest", avoidOverlap: true, attempts: 80, maxRadius: 190 });
|
||||
addPresetItemsScattered(worldRef, "pushpin", presetCount(field, 5, 2, 9), null, { pad: 88, reason: "preset-war-pushpin-reduced" });
|
||||
addPresetItemsScattered(worldRef, "oshibyo", presetCount(field, 8, 4, 16), null, { pad: 88, reason: "preset-war-oshibyo" });
|
||||
addPresetFencesScattered(worldRef, presetCount(field, 8, 4, 14), null, { pad: 94, reason: "preset-war-fence" });
|
||||
}
|
||||
|
||||
function generateHappyPreset(worldRef, field) {
|
||||
const center = { x0: worldRef.w * 0.16, x1: worldRef.w * 0.84, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 16, 8, 28), center);
|
||||
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 8, 4, 14), center, { pad: 96, reason: "preset-happy-nest-box" });
|
||||
addPresetItemsScattered(worldRef, "robot_cleaner", presetCount(field, 2, 1, 3), center, { pad: 110, reason: "preset-happy-robot-cleaner" });
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 2, 1, 4), center, { pad: 110, storedFoodType: "sweet", reason: "preset-happy-duplicator-zunda" });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, 6, 3, 10), center, { pad: 88, reason: "preset-happy-ball" });
|
||||
}
|
||||
|
||||
function generatePresetWorld(worldRef, field, presetId, seedPopulation) {
|
||||
switch (normalizeResetPresetId(presetId)) {
|
||||
case "empty": return;
|
||||
case "athletic": return generateAthleticPreset(worldRef, field);
|
||||
case "crowded": return generateCrowdedPreset(worldRef, field);
|
||||
case "family": return generateFamilyPreset(worldRef, field);
|
||||
case "war": return generateWarPreset(worldRef, field);
|
||||
case "happy": return generateHappyPreset(worldRef, field);
|
||||
case "default":
|
||||
default:
|
||||
return generateDefaultPreset(worldRef, field, seedPopulation);
|
||||
}
|
||||
}
|
||||
|
||||
function groundForResetPreset(presetId = "default") {
|
||||
const id = normalizeResetPresetId(presetId);
|
||||
if (id === "athletic") return "ice";
|
||||
if (id === "war") return "foot_massage";
|
||||
if (id === "happy") return "blanket";
|
||||
return "soil";
|
||||
}
|
||||
const resetPresets = global.TarinaiWorldResetPresets || global.TarinaiResetPresets || {};
|
||||
const normalizeResetPresetId = resetPresets.normalize || ((presetId = "default") => String(presetId || "default"));
|
||||
const generatePresetWorld = resetPresets.generatePresetWorld || (() => {});
|
||||
const groundForResetPreset = resetPresets.groundForResetPreset || (() => "soil");
|
||||
const resetPresetLabel = resetPresets.label || (() => "\u30C7\u30D5\u30A9\u30EB\u30C8");
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
reset(seedPopulation = null, fieldType = this.fieldType || "garden", presetId = "default") {
|
||||
|
|
@ -391,40 +48,17 @@
|
|||
this.speed = 1;
|
||||
this.toolSize = this.toolSize || "medium";
|
||||
this.toolSizes = this.toolSizes || {};
|
||||
this.tarinai = [];
|
||||
this.items = [];
|
||||
this.ants = [];
|
||||
this.effects = [];
|
||||
this.resetRuntimeCollections?.();
|
||||
this.itemCounts = {};
|
||||
this.effectCounts = {};
|
||||
this.itemTypeBuckets = new Map();
|
||||
this.itemIdMap = new Map();
|
||||
this.itemBucketsDirty = false;
|
||||
this.logs = [];
|
||||
this.events = window.TarinaiEvents || null;
|
||||
this.eventCounters = {};
|
||||
this.countsTimer = 0;
|
||||
this.compactTimer = 0;
|
||||
this.drawSortTimer = 0;
|
||||
this.drawListDirty = true;
|
||||
this.antUpdatePhase = 0;
|
||||
this.grassGrowthTimer = 0;
|
||||
this.nextGrassLimitCheckAt = 0;
|
||||
this.selected = null;
|
||||
this.deadCount = 0;
|
||||
this.liveIdNext = 1;
|
||||
this.liveIdSerial = 0;
|
||||
this.liveTarinai = new Map();
|
||||
this.worldSeed = global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
|
||||
this.birthSerial = 0;
|
||||
this.lastBirthAt = -999;
|
||||
this.maxGeneration = 1;
|
||||
this.family = {};
|
||||
this.familyVersion = 0;
|
||||
this.relationNotices = {};
|
||||
this.resolvedFightIds = {};
|
||||
this.fightPairCooldowns = {};
|
||||
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
|
||||
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0 };
|
||||
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
|
||||
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
|
||||
this.shakeTimer = 0;
|
||||
|
|
@ -446,7 +80,7 @@
|
|||
this.updateEffectCounts();
|
||||
this.markTerrainDirty?.("reset");
|
||||
this.rebuildSpatial(true);
|
||||
const label = RESET_PRESETS[preset]?.label || "\u30C7\u30D5\u30A9\u30EB\u30C8";
|
||||
const label = resetPresetLabel(preset);
|
||||
showToast(`${label}\u3067\u65B0\u3057\u3044\u89B3\u5BDF\u3092\u958B\u59CB\u3057\u307E\u3057\u305F\u3002`);
|
||||
syncTopButtons();
|
||||
},
|
||||
|
|
|
|||
65
js/world_grass_placement_system.js
Normal file
65
js/world_grass_placement_system.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: world/grass-placement
|
||||
// Grass placement clearance, crowding, and retry sampling helpers.
|
||||
(function (global) {
|
||||
const World = global.World;
|
||||
if (!World) return;
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
grassBlockedAt(x, y, self = null, opts = {}) {
|
||||
const ignoreSoft = Boolean(opts?.ignoreSoftPlacement);
|
||||
for (const it of this.nearbyItems(x, y, 96)) {
|
||||
if (it === self || it.dead) continue;
|
||||
if (ignoreSoft && global.TarinaiPhysicsSoftClear.isSoftPlacementType(it.type)) continue;
|
||||
for (const r of this.solidObstacleRects(it)) {
|
||||
if (this.pointInRect(x, y, r, 10)) return true;
|
||||
}
|
||||
if (it.type !== "zunchi") continue;
|
||||
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
|
||||
const dy = (y - it.y) / Math.max(12, it.r * 0.82);
|
||||
if (dx * dx + dy * dy < 1) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
grassCrowdedAt(x, y, minGap = 30, self = null) {
|
||||
for (const it of this.nearbyItems(x, y, Math.max(42, minGap + 16))) {
|
||||
if (it === self || it.dead || it.type !== "grass") continue;
|
||||
if (distXY(x, y, it.x, it.y) < minGap) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
grassOnTarinaiAt(x, y, minGap = 24) {
|
||||
for (const t of this.nearbyTarinai(x, y, minGap + 36)) {
|
||||
if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
|
||||
if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
grassSpotOpen(x, y, { minGrassGap = 30, avoidTarinai = true, self = null } = {}) {
|
||||
if (x < 44 || y < 44 || x > this.w - 44 || y > this.h - 44) return false;
|
||||
if (this.grassBlockedAt(x, y, self)) return false;
|
||||
if (this.grassCrowdedAt(x, y, minGrassGap, self)) return false;
|
||||
if (avoidTarinai && this.grassOnTarinaiAt(x, y)) return false;
|
||||
return true;
|
||||
},
|
||||
findGrassPlantingSpot(x, y, { allowOriginal = true, minRadius = 18, maxRadius = 160, attempts = 36, avoidTarinai = true } = {}) {
|
||||
const cx = clamp(x, 44, this.w - 44);
|
||||
const cy = clamp(y, 44, this.h - 44);
|
||||
if (allowOriginal && this.grassSpotOpen(cx, cy, { avoidTarinai })) return { x: cx, y: cy };
|
||||
const blockedAtCenter = this.grassBlockedAt(cx, cy);
|
||||
const inner = blockedAtCenter ? Math.max(minRadius, 34) : minRadius;
|
||||
const outer = Math.max(maxRadius, inner + 42);
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const t = attempts <= 1 ? 1 : i / (attempts - 1);
|
||||
const radius = lerp(inner, outer, Math.sqrt(t));
|
||||
const angle = i * golden + stableUnit(`${cx},${cy}`, "grass-plant") * Math.PI * 2;
|
||||
const px = clamp(cx + Math.cos(angle) * radius, 44, this.w - 44);
|
||||
const py = clamp(cy + Math.sin(angle) * radius * 0.72, 44, this.h - 44);
|
||||
if (this.grassSpotOpen(px, py, { avoidTarinai })) return { x: px, y: py };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
184
js/world_pathfinding_system.js
Normal file
184
js/world_pathfinding_system.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: world/pathfinding
|
||||
// Grid and detour waypoint helpers for obstacle-aware creature routing.
|
||||
(function (global) {
|
||||
const World = global.World;
|
||||
if (!World) return;
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
findTarinaiPathWaypoint(actor, target, opts = {}) {
|
||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
||||
const tx = Number(target.x);
|
||||
const ty = Number(target.y);
|
||||
const ax = Number(actor.x);
|
||||
const ay = Number(actor.y);
|
||||
const targetItem = opts?.targetItem || target?.hostItem || (target?.isStructure || target?.type ? target : null);
|
||||
const exclude = opts?.exclude || targetItem || null;
|
||||
const rr = Math.max(8, Number(actor.radius || 20) || 20);
|
||||
const padding = Math.max(16, Number(opts.padding || 0) || rr * 0.78);
|
||||
const directClear = !this.pathBlockedByFence?.(ax, ay, tx, ty, padding, { exclude });
|
||||
if (directClear) {
|
||||
actor._pathWaypoint = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetId = targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
||||
const cached = actor._pathWaypoint || null;
|
||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
||||
&& !this.pointBlockedByObstacle(cached.x, cached.y, Math.max(6, padding * 0.45), { exclude, maxChecks: 18 })
|
||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId };
|
||||
}
|
||||
|
||||
const midX = (ax + tx) * 0.5;
|
||||
const midY = (ay + ty) * 0.5;
|
||||
const totalD = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
||||
const rects = this.nearbySolidObstacleRects?.(midX, midY, totalD * 0.5 + padding + 190, { exclude, maxChecks: Math.max(32, Number(opts.maxChecks || 0) || 46), maxRects: 80 }) || [];
|
||||
const worldPad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 28) + rr * 0.45;
|
||||
const candidates = [];
|
||||
const add = (x, y, sourceRect, grade = 0) => {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
if (x < worldPad || y < worldPad || x > this.w - worldPad || y > this.h - worldPad) return;
|
||||
candidates.push({ x, y, sourceRect, grade });
|
||||
};
|
||||
const worldPoint = (r, lx, ly) => {
|
||||
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
||||
const sn = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
||||
const cx = Number(r.cx ?? ((Number(r.left || 0) + Number(r.right || 0)) * 0.5)) || 0;
|
||||
const cy = Number(r.cy ?? ((Number(r.top || 0) + Number(r.bottom || 0)) * 0.5)) || 0;
|
||||
return { x: cx + lx * c - ly * sn, y: cy + lx * sn + ly * c };
|
||||
};
|
||||
const addRectCandidates = (r, index = 0) => {
|
||||
const pad = Math.max(34, padding + rr * 1.45 + 18 + Math.min(18, index * 3));
|
||||
if (r.oriented) {
|
||||
const hw = Math.max(1, Number(r.halfW || 0) || 1) + pad;
|
||||
const hh = Math.max(1, Number(r.halfH || 0) || 1) + pad;
|
||||
for (const [lx, ly, grade] of [[-hw, -hh, 0], [hw, -hh, 0], [hw, hh, 0], [-hw, hh, 0], [0, -hh, 1], [hw, 0, 1], [0, hh, 1], [-hw, 0, 1]]) {
|
||||
const p = worldPoint(r, lx, ly);
|
||||
add(p.x, p.y, r, grade);
|
||||
}
|
||||
} else {
|
||||
const left = Number(r.left || 0) - pad;
|
||||
const right = Number(r.right || 0) + pad;
|
||||
const top = Number(r.top || 0) - pad;
|
||||
const bottom = Number(r.bottom || 0) + pad;
|
||||
const cx = (left + right) * 0.5;
|
||||
const cy = (top + bottom) * 0.5;
|
||||
for (const [x, y, grade] of [[left, top, 0], [right, top, 0], [right, bottom, 0], [left, bottom, 0], [cx, top, 1], [right, cy, 1], [cx, bottom, 1], [left, cy, 1]]) add(x, y, r, grade);
|
||||
}
|
||||
};
|
||||
|
||||
let blockers = 0;
|
||||
for (const rect of rects) {
|
||||
if (!this.isFenceRoutingRect?.(rect)) continue;
|
||||
const inflated = rect?.oriented
|
||||
? { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }
|
||||
: { left: (rect.left || 0) - padding, right: (rect.right || 0) + padding, top: (rect.top || 0) - padding, bottom: (rect.bottom || 0) + padding };
|
||||
if (!this.segmentIntersectsRect?.(ax, ay, tx, ty, inflated)) continue;
|
||||
addRectCandidates(rect, blockers);
|
||||
blockers += 1;
|
||||
if (blockers >= 4) break;
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
const old = cached && Number.isFinite(cached.x) && Number.isFinite(cached.y) ? cached : null;
|
||||
for (const c of candidates) {
|
||||
if (this.pointBlockedByObstacle(c.x, c.y, Math.max(5, padding * 0.42), { exclude, maxChecks: 20 })) continue;
|
||||
const firstBlocked = this.pathBlockedByFence?.(ax, ay, c.x, c.y, padding, { exclude });
|
||||
if (firstBlocked) continue;
|
||||
const secondBlocked = this.pathBlockedByFence?.(c.x, c.y, tx, ty, padding, { exclude });
|
||||
const d1 = Math.hypot(c.x - ax, c.y - ay);
|
||||
const d2 = Math.hypot(tx - c.x, ty - c.y);
|
||||
const progress = totalD - d2;
|
||||
const cacheBias = old ? Math.min(90, Math.hypot(c.x - old.x, c.y - old.y) * 0.35) : 0;
|
||||
const score = d1 + d2 + (secondBlocked ? 780 - progress * 0.45 : 0) + (c.grade || 0) * 26 + cacheBias;
|
||||
if (score < bestScore) { best = { ...c, secondBlocked }; bestScore = score; }
|
||||
}
|
||||
if (!best || (best.secondBlocked && opts.preferFullPath)) {
|
||||
const grid = this.findGridPathWaypoint?.(actor, target, { ...opts, targetItem, exclude, padding, targetId });
|
||||
if (grid) return grid;
|
||||
if (!best) return null;
|
||||
}
|
||||
actor._pathWaypoint = { x: best.x, y: best.y, targetId, until: (this.time || 0) + 0.95 };
|
||||
return { x: best.x, y: best.y, dead: false, detour: true, routeTargetId: targetId };
|
||||
},
|
||||
findGridPathWaypoint(actor, target, opts = {}) {
|
||||
if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return null;
|
||||
const ax = Number(actor.x), ay = Number(actor.y), tx = Number(target.x), ty = Number(target.y);
|
||||
const targetId = opts.targetId || opts.targetItem?.id || target?.id || `pos:${Math.round(tx)},${Math.round(ty)}`;
|
||||
const cached = actor._gridPathWaypoint || null;
|
||||
const padding = Math.max(16, Number(opts.padding || 0) || (Number(actor.radius || 20) * 0.78));
|
||||
const exclude = opts.exclude || opts.targetItem || target || null;
|
||||
if (cached && cached.targetId === targetId && (this.time || 0) < (cached.until || 0)
|
||||
&& Number.isFinite(cached.x) && Number.isFinite(cached.y)
|
||||
&& !this.pointBlockedByObstacle?.(cached.x, cached.y, Math.max(6, padding * 0.42), { exclude, maxChecks: 20 })
|
||||
&& !this.pathBlockedByFence?.(ax, ay, cached.x, cached.y, padding, { exclude })) {
|
||||
return { x: cached.x, y: cached.y, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
||||
}
|
||||
|
||||
const dTotal = Math.max(1, Math.hypot(tx - ax, ty - ay));
|
||||
const step = Math.max(42, Math.min(72, Number(opts.gridStep || 0) || dTotal / 7));
|
||||
const padWorld = (CONFIG.worldPadding || 28) + Math.max(8, Number(actor.radius || 20) * 0.45);
|
||||
const margin = Math.max(190, step * 3.2);
|
||||
const minX = Math.max(padWorld, Math.min(ax, tx) - margin);
|
||||
const maxX = Math.min(this.w - padWorld, Math.max(ax, tx) + margin);
|
||||
const minY = Math.max(padWorld, Math.min(ay, ty) - margin);
|
||||
const maxY = Math.min(this.h - padWorld, Math.max(ay, ty) + margin);
|
||||
const cols = Math.max(2, Math.min(18, Math.ceil((maxX - minX) / step) + 1));
|
||||
const rows = Math.max(2, Math.min(18, Math.ceil((maxY - minY) / step) + 1));
|
||||
const px = (ix) => cols <= 1 ? minX : minX + (maxX - minX) * ix / (cols - 1);
|
||||
const py = (iy) => rows <= 1 ? minY : minY + (maxY - minY) * iy / (rows - 1);
|
||||
const nearest = (x, y) => ({ ix: clamp(Math.round((x - minX) / Math.max(1, maxX - minX) * (cols - 1)), 0, cols - 1), iy: clamp(Math.round((y - minY) / Math.max(1, maxY - minY) * (rows - 1)), 0, rows - 1) });
|
||||
const start = nearest(ax, ay);
|
||||
const goal = nearest(tx, ty);
|
||||
const key = (ix, iy) => `${ix},${iy}`;
|
||||
const inBounds = (ix, iy) => ix >= 0 && iy >= 0 && ix < cols && iy < rows;
|
||||
const pointOpen = (ix, iy) => {
|
||||
if (!inBounds(ix, iy)) return false;
|
||||
if (ix === start.ix && iy === start.iy) return true;
|
||||
if (ix === goal.ix && iy === goal.iy) return true;
|
||||
return !this.pointBlockedByObstacle?.(px(ix), py(iy), Math.max(6, padding * 0.45), { exclude, maxChecks: 18 });
|
||||
};
|
||||
const edgeOpen = (a, b) => !this.pathBlockedByFence?.(px(a.ix), py(a.iy), px(b.ix), py(b.iy), padding, { exclude });
|
||||
const h = (ix, iy) => Math.hypot(px(ix) - tx, py(iy) - ty);
|
||||
const open = [{ ...start, g: 0, f: h(start.ix, start.iy), parent: null }];
|
||||
const bestByKey = new Map([[key(start.ix, start.iy), open[0]]]);
|
||||
let found = null;
|
||||
let guard = 0;
|
||||
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||||
while (open.length && guard++ < 420) {
|
||||
open.sort((a, b) => a.f - b.f);
|
||||
const cur = open.shift();
|
||||
if (cur.ix === goal.ix && cur.iy === goal.iy) { found = cur; break; }
|
||||
for (const [dx, dy] of dirs) {
|
||||
const nx = cur.ix + dx, ny = cur.iy + dy;
|
||||
if (!pointOpen(nx, ny)) continue;
|
||||
const nb = { ix: nx, iy: ny };
|
||||
if (!edgeOpen(cur, nb)) continue;
|
||||
const diagonal = dx && dy;
|
||||
const ng = cur.g + (diagonal ? 1.42 : 1) * step;
|
||||
const k = key(nx, ny);
|
||||
const oldNode = bestByKey.get(k);
|
||||
if (oldNode && oldNode.g <= ng) continue;
|
||||
const node = { ix: nx, iy: ny, g: ng, f: ng + h(nx, ny), parent: cur };
|
||||
bestByKey.set(k, node);
|
||||
open.push(node);
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
const path = [];
|
||||
for (let n = found; n; n = n.parent) path.push(n);
|
||||
path.reverse();
|
||||
const chosen = path[Math.min(path.length - 1, Math.max(1, path.length > 3 ? 2 : 1))];
|
||||
if (!chosen) return null;
|
||||
const wx = px(chosen.ix), wy = py(chosen.iy);
|
||||
actor._gridPathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
||||
actor._pathWaypoint = { x: wx, y: wy, targetId, until: (this.time || 0) + 1.15 };
|
||||
return { x: wx, y: wy, dead: false, detour: true, routeTargetId: targetId, gridPath: true };
|
||||
},
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -8,13 +8,19 @@
|
|||
const SIGNBOARD_LINES = 3;
|
||||
const SIGNBOARD_TEXT_LIMIT = SIGNBOARD_CHARS_PER_LINE * SIGNBOARD_LINES;
|
||||
|
||||
function ensureSignboardEditor() {
|
||||
let dialog = document.getElementById("signboardEditor");
|
||||
function ensureEditorDialog(id, className, html) {
|
||||
let dialog = document.getElementById(id);
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "signboardEditor";
|
||||
dialog.className = "signboard-editor hidden";
|
||||
dialog.innerHTML = `
|
||||
dialog.id = id;
|
||||
dialog.className = className;
|
||||
dialog.innerHTML = html;
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function ensureSignboardEditor() {
|
||||
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
|
||||
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
|
||||
<div class="signboard-editor-titlebar">
|
||||
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
|
||||
|
|
@ -27,9 +33,7 @@
|
|||
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
<button id="signboardEditorApply" class="btn primary" type="button">\u66f8\u304d\u8fbc\u3080</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
</div>`);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -78,8 +82,6 @@
|
|||
const closeBtn = dialog.querySelector("#signboardEditorClose");
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
dialog.__sign = null;
|
||||
dialog.__worldRef = null;
|
||||
};
|
||||
const syncCount = () => {
|
||||
if (!area || !count) return;
|
||||
|
|
@ -88,14 +90,11 @@
|
|||
const commit = (value) => {
|
||||
const next = sanitizeSignboardText(value);
|
||||
sign.text = next;
|
||||
sign.textEditedAt = worldRef?.time || 0;
|
||||
worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe");
|
||||
worldRef.drawListDirty = true;
|
||||
global.render?.();
|
||||
close();
|
||||
};
|
||||
dialog.__sign = sign;
|
||||
dialog.__worldRef = worldRef;
|
||||
if (area) {
|
||||
area.value = sanitizeSignboardText(sign.text || "");
|
||||
area.onkeydown = (e) => {
|
||||
|
|
@ -150,12 +149,7 @@
|
|||
|
||||
|
||||
function ensureRobotCleanerEditor() {
|
||||
let dialog = document.getElementById("robotCleanerEditor");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "robotCleanerEditor";
|
||||
dialog.className = "signboard-editor robot-cleaner-editor hidden";
|
||||
dialog.innerHTML = `
|
||||
return ensureEditorDialog("robotCleanerEditor", "signboard-editor robot-cleaner-editor hidden", `
|
||||
<div class="signboard-editor-panel robot-cleaner-editor-panel" role="dialog" aria-modal="true" aria-labelledby="robotCleanerEditorTitle">
|
||||
<div class="signboard-editor-titlebar">
|
||||
<h2 id="robotCleanerEditorTitle">\u30ed\u30dc\u6383\u9664\u6a5f</h2>
|
||||
|
|
@ -178,9 +172,7 @@
|
|||
<button id="robotCleanerCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
<button id="robotCleanerApply" class="btn primary" type="button">\u4fdd\u5b58</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
return dialog;
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function openRobotCleanerEditor(robot, worldRef) {
|
||||
|
|
@ -201,8 +193,6 @@
|
|||
};
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
dialog.__robot = null;
|
||||
dialog.__worldRef = null;
|
||||
};
|
||||
const commit = (mask) => {
|
||||
api?.setRobotCleanerMask?.(robot, mask);
|
||||
|
|
@ -217,8 +207,6 @@
|
|||
global.render?.();
|
||||
close();
|
||||
};
|
||||
dialog.__robot = robot;
|
||||
dialog.__worldRef = worldRef;
|
||||
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
|
||||
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
|
||||
if (closeBtn) closeBtn.onclick = close;
|
||||
|
|
@ -231,12 +219,7 @@
|
|||
}
|
||||
|
||||
function ensureRotatorEditor() {
|
||||
let dialog = document.getElementById("rotatorEditor");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "rotatorEditor";
|
||||
dialog.className = "rotator-editor hidden";
|
||||
dialog.innerHTML = `
|
||||
const dialog = ensureEditorDialog("rotatorEditor", "rotator-editor hidden", `
|
||||
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
|
||||
<div class="rotator-editor-titlebar">
|
||||
<h2 id="rotatorEditorTitle">\u56de\u8ee2\u4f53</h2>
|
||||
|
|
@ -264,8 +247,7 @@
|
|||
<button id="rotatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
<button id="rotatorApply" class="btn primary" type="button">\u53cd\u6620</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
</div>`);
|
||||
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "rotator");
|
||||
return dialog;
|
||||
}
|
||||
|
|
@ -300,15 +282,26 @@
|
|||
const apply = dialog.querySelector("#rotatorApply");
|
||||
const cancel = dialog.querySelector("#rotatorCancel");
|
||||
const closeBtn = dialog.querySelector("#rotatorEditorClose");
|
||||
const undo = dialog.querySelector("#rotatorUndo");
|
||||
const clear = dialog.querySelector("#rotatorClear");
|
||||
if (!canvas || !ctx) return false;
|
||||
let mode = "line";
|
||||
let segments = cloneSegmentsForRotator(item);
|
||||
let draft = null;
|
||||
let drawing = false;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const editor = global.TarinaiPhysicsShapeEditorSystem;
|
||||
const controller = editor.createSegmentEditorController({
|
||||
dialog,
|
||||
canvas,
|
||||
ctx,
|
||||
prefix: "rotator",
|
||||
segments: cloneSegmentsForRotator(item),
|
||||
fallback: [-78, 0, 78, 0],
|
||||
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
|
||||
eraseRadius: (lw) => Math.max(24, lw * 1.35),
|
||||
strokeStyle: () => isPoisonBlock ? "rgba(92,172,76,0.92)" : "rgba(127,92,190,0.92)",
|
||||
drawCenter: () => {
|
||||
ctx.fillStyle = isPoisonBlock ? "#eefee7" : "#f5edff";
|
||||
ctx.strokeStyle = isPoisonBlock ? "#3c8f32" : "#6d4ca0";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
},
|
||||
});
|
||||
if (!controller) return false;
|
||||
const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI);
|
||||
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12)));
|
||||
if (titleEl) titleEl.textContent = isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u56de\u8ee2\u4f53";
|
||||
|
|
@ -323,162 +316,17 @@
|
|||
if (thick) thick.value = String(initialThickness);
|
||||
if (thickNum) thickNum.value = String(initialThickness);
|
||||
if (poweredInput) poweredInput.checked = phyScalar(item, "motorOn", true) !== false;
|
||||
const toLocal = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = canvas.width / Math.max(1, rect.width);
|
||||
const sy = canvas.height / Math.max(1, rect.height);
|
||||
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
|
||||
};
|
||||
const cleanSegments = () => {
|
||||
const out = [];
|
||||
for (const seg of segments) {
|
||||
const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0));
|
||||
const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0));
|
||||
const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0));
|
||||
const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0));
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]);
|
||||
if (out.length >= 96) break;
|
||||
}
|
||||
if (!out.length) out.push([-78, 0, 78, 0]);
|
||||
segments = out;
|
||||
};
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#f7f3ea";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.translate(cx, cy);
|
||||
ctx.strokeStyle = "rgba(100,80,60,0.16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
|
||||
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
|
||||
ctx.strokeStyle = "rgba(70,60,50,0.38)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
|
||||
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = isPoisonBlock ? "rgba(92,172,76,0.92)" : "rgba(127,92,190,0.92)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.58)";
|
||||
ctx.lineWidth = Math.max(1.3, lw * 0.20);
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
if (draft) {
|
||||
ctx.setLineDash([7, 5]);
|
||||
ctx.strokeStyle = "rgba(60,130,210,0.85)";
|
||||
ctx.lineWidth = Math.max(2, lw * 0.45);
|
||||
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.fillStyle = isPoisonBlock ? "#eefee7" : "#f5edff";
|
||||
ctx.strokeStyle = isPoisonBlock ? "#3c8f32" : "#6d4ca0";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
const setMode = (next) => {
|
||||
mode = next === "free" || next === "erase" ? next : "line";
|
||||
for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode);
|
||||
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
|
||||
};
|
||||
const pointSegmentDistance = (p, seg) => global.TarinaiPhysicsShapeEditorSystem.pointSegmentDistance(p, seg) ?? (() => {
|
||||
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
|
||||
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
|
||||
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
|
||||
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
|
||||
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
|
||||
})();
|
||||
const eraseAt = (p) => {
|
||||
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
const radius = Math.max(24, lw * 1.35);
|
||||
const before = segments.length;
|
||||
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
|
||||
if (segments.length !== before) { draft = null; draw(); return true; }
|
||||
draw();
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.globalAlpha = 0.42;
|
||||
ctx.strokeStyle = "rgba(210,70,60,0.86)";
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
return false;
|
||||
};
|
||||
const setTemplate = (name) => {
|
||||
const shared = global.TarinaiPhysicsShapeEditorSystem.templateSegments(name);
|
||||
if (shared) segments = shared.map(seg => seg.slice(0, 4));
|
||||
cleanSegments(); draw();
|
||||
};
|
||||
const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; };
|
||||
syncPair(speed, speedNum);
|
||||
syncPair(thick, thickNum);
|
||||
dialog.querySelectorAll("[data-rotator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.rotatorMode); });
|
||||
dialog.querySelectorAll("[data-rotator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.rotatorTemplate); });
|
||||
canvas.onpointerdown = (e) => {
|
||||
canvas.setPointerCapture?.(e.pointerId);
|
||||
const p = toLocal(e);
|
||||
drawing = true;
|
||||
if (mode === "erase") {
|
||||
draft = null;
|
||||
eraseAt(p);
|
||||
return;
|
||||
}
|
||||
draft = [p.x, p.y, p.x, p.y];
|
||||
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
|
||||
draw();
|
||||
};
|
||||
canvas.onpointermove = (e) => {
|
||||
if (!drawing) return;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { eraseAt(p); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "free") {
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) return;
|
||||
const lx = last[2], ly = last[3];
|
||||
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
|
||||
last[2] = p.x; last[3] = p.y;
|
||||
segments.push([p.x, p.y, p.x, p.y]);
|
||||
}
|
||||
} else {
|
||||
draft[2] = p.x; draft[3] = p.y;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
canvas.onpointerup = (e) => {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { draft = null; draw(); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "line") {
|
||||
const seg = [draft[0], draft[1], p.x, p.y];
|
||||
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
|
||||
}
|
||||
draft = null;
|
||||
cleanSegments();
|
||||
draw();
|
||||
};
|
||||
if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); };
|
||||
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
|
||||
editor.syncInputPair(speed, speedNum, controller.draw);
|
||||
editor.syncInputPair(thick, thickNum, controller.draw);
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
canvas.style.cursor = "";
|
||||
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
|
||||
controller.close();
|
||||
};
|
||||
if (apply) apply.onclick = () => {
|
||||
global.TarinaiHistory.capture(worldRef, isPoisonBlock ? "poison-block-edit" : "rotator-edit");
|
||||
cleanSegments();
|
||||
physicsApi()?.setSegments?.(item, segments.map(seg => seg.slice(0, 4)), isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
|
||||
controller.cleanSegments();
|
||||
const editedSegments = controller.getSegments();
|
||||
physicsApi()?.setSegments?.(item, editedSegments, isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
|
||||
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))), "editor-thickness");
|
||||
phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
|
||||
if (isPoisonBlock) {
|
||||
|
|
@ -491,7 +339,6 @@
|
|||
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
||||
const extent = worldRef?.rotatorExtent?.(item);
|
||||
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
|
||||
item.rotatorEditorOpenAt = worldRef?.time || 0;
|
||||
global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap");
|
||||
worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited");
|
||||
worldRef.drawListDirty = true;
|
||||
|
|
@ -502,20 +349,14 @@
|
|||
if (cancel) cancel.onclick = close;
|
||||
if (closeBtn) closeBtn.onclick = close;
|
||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
||||
setMode(mode);
|
||||
draw();
|
||||
controller.draw();
|
||||
dialog.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function ensureReciprocatorEditor() {
|
||||
let dialog = document.getElementById("reciprocatorEditor");
|
||||
if (dialog) return dialog;
|
||||
dialog = document.createElement("div");
|
||||
dialog.id = "reciprocatorEditor";
|
||||
dialog.className = "rotator-editor hidden";
|
||||
dialog.innerHTML = `
|
||||
const dialog = ensureEditorDialog("reciprocatorEditor", "rotator-editor hidden", `
|
||||
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="reciprocatorEditorTitle">
|
||||
<div class="rotator-editor-titlebar">
|
||||
<h2 id="reciprocatorEditorTitle">\u5f80\u5fa9\u4f53</h2>
|
||||
|
|
@ -545,8 +386,7 @@
|
|||
<button id="reciprocatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
||||
<button id="reciprocatorApply" class="btn primary" type="button">\u53cd\u6620</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
</div>`);
|
||||
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "reciprocator");
|
||||
return dialog;
|
||||
}
|
||||
|
|
@ -570,15 +410,36 @@
|
|||
const apply = dialog.querySelector("#reciprocatorApply");
|
||||
const cancel = dialog.querySelector("#reciprocatorCancel");
|
||||
const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
|
||||
const undo = dialog.querySelector("#reciprocatorUndo");
|
||||
const clear = dialog.querySelector("#reciprocatorClear");
|
||||
if (!canvas || !ctx) return false;
|
||||
let mode = "line";
|
||||
let segments = cloneSegmentsForRotator(item);
|
||||
let draft = null;
|
||||
let drawing = false;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const editor = global.TarinaiPhysicsShapeEditorSystem;
|
||||
const controller = editor.createSegmentEditorController({
|
||||
dialog,
|
||||
canvas,
|
||||
ctx,
|
||||
prefix: "reciprocator",
|
||||
segments: cloneSegmentsForRotator(item),
|
||||
fallback: [-78, 0, 78, 0],
|
||||
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
|
||||
eraseRadius: (lw) => Math.max(28, lw * 1.55),
|
||||
strokeStyle: "rgba(80,150,205,0.92)",
|
||||
drawBeforeSegments: () => {
|
||||
const travelNow = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(70,120,170,0.42)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([7, 6]);
|
||||
ctx.beginPath(); ctx.moveTo(-travelNow * 0.5, 22); ctx.lineTo(travelNow * 0.5, 22); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
},
|
||||
drawCenter: () => {
|
||||
ctx.fillStyle = "#eaf6ff";
|
||||
ctx.strokeStyle = "#4d8cba";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
},
|
||||
});
|
||||
if (!controller) return false;
|
||||
const axisRad = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", (itemAngleFor(item)));
|
||||
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
|
||||
const speedVal = Math.round(Number(phyScalar(item, "railMotorSpeed", 92)) || 0);
|
||||
|
|
@ -594,167 +455,18 @@
|
|||
if (thickNum) thickNum.value = String(initialThickness);
|
||||
if (powered) powered.checked = phyScalar(item, "railOn", true) !== false;
|
||||
if (solidInput) solidInput.checked = phyScalar(item, "solid", true) !== false;
|
||||
const toLocal = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const sx = canvas.width / Math.max(1, rect.width);
|
||||
const sy = canvas.height / Math.max(1, rect.height);
|
||||
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
|
||||
};
|
||||
const cleanSegments = () => {
|
||||
const out = [];
|
||||
for (const seg of segments) {
|
||||
const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0));
|
||||
const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0));
|
||||
const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0));
|
||||
const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0));
|
||||
if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]);
|
||||
if (out.length >= 96) break;
|
||||
}
|
||||
if (!out.length) out.push([-78, 0, 78, 0]);
|
||||
segments = out;
|
||||
};
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#f7f3ea";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.translate(cx, cy);
|
||||
ctx.strokeStyle = "rgba(100,80,60,0.16)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
|
||||
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
|
||||
const travelNow = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(70,120,170,0.42)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([7, 6]);
|
||||
ctx.beginPath(); ctx.moveTo(-travelNow * 0.5, 22); ctx.lineTo(travelNow * 0.5, 22); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "rgba(70,60,50,0.38)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
|
||||
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(80,150,205,0.92)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.58)";
|
||||
ctx.lineWidth = Math.max(1.3, lw * 0.20);
|
||||
ctx.beginPath();
|
||||
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
|
||||
ctx.stroke();
|
||||
if (draft) {
|
||||
ctx.setLineDash([7, 5]);
|
||||
ctx.strokeStyle = "rgba(60,130,210,0.85)";
|
||||
ctx.lineWidth = Math.max(2, lw * 0.45);
|
||||
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.fillStyle = "#eaf6ff";
|
||||
ctx.strokeStyle = "#4d8cba";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
const setMode = (next) => {
|
||||
mode = next === "free" || next === "erase" ? next : "line";
|
||||
for (const btn of dialog.querySelectorAll("[data-reciprocator-mode]")) btn.classList.toggle("active", btn.dataset.reciprocatorMode === mode);
|
||||
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
|
||||
};
|
||||
const pointSegmentDistance = (p, seg) => global.TarinaiPhysicsShapeEditorSystem.pointSegmentDistance(p, seg) ?? (() => {
|
||||
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
|
||||
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
|
||||
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
|
||||
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
|
||||
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
|
||||
})();
|
||||
const eraseAt = (p) => {
|
||||
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
|
||||
const radius = Math.max(28, lw * 1.55);
|
||||
const before = segments.length;
|
||||
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
|
||||
if (segments.length !== before) { draft = null; draw(); return true; }
|
||||
draw();
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.globalAlpha = 0.42;
|
||||
ctx.strokeStyle = "rgba(210,70,60,0.86)";
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
return false;
|
||||
};
|
||||
const setTemplate = (name) => {
|
||||
const shared = global.TarinaiPhysicsShapeEditorSystem.templateSegments(name);
|
||||
if (shared) segments = shared.map(seg => seg.slice(0, 4));
|
||||
cleanSegments(); draw();
|
||||
};
|
||||
const syncPair = (a, b, redraw = false) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; if (redraw) draw(); }; b.oninput = () => { a.value = b.value; if (redraw) draw(); }; };
|
||||
syncPair(angle, angleNum, false);
|
||||
syncPair(speed, speedNum, false);
|
||||
syncPair(travel, travelNum, true);
|
||||
syncPair(thick, thickNum, true);
|
||||
dialog.querySelectorAll("[data-reciprocator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.reciprocatorMode); });
|
||||
dialog.querySelectorAll("[data-reciprocator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.reciprocatorTemplate); });
|
||||
canvas.onpointerdown = (e) => {
|
||||
canvas.setPointerCapture?.(e.pointerId);
|
||||
const p = toLocal(e);
|
||||
drawing = true;
|
||||
if (mode === "erase") { draft = null; eraseAt(p); return; }
|
||||
draft = [p.x, p.y, p.x, p.y];
|
||||
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
|
||||
draw();
|
||||
};
|
||||
canvas.onpointermove = (e) => {
|
||||
if (!drawing) return;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { eraseAt(p); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "free") {
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) return;
|
||||
const lx = last[2], ly = last[3];
|
||||
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
|
||||
last[2] = p.x; last[3] = p.y;
|
||||
segments.push([p.x, p.y, p.x, p.y]);
|
||||
}
|
||||
} else {
|
||||
draft[2] = p.x; draft[3] = p.y;
|
||||
}
|
||||
draw();
|
||||
};
|
||||
canvas.onpointerup = (e) => {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
const p = toLocal(e);
|
||||
if (mode === "erase") { draft = null; draw(); return; }
|
||||
if (!draft) return;
|
||||
if (mode === "line") {
|
||||
const seg = [draft[0], draft[1], p.x, p.y];
|
||||
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
|
||||
}
|
||||
draft = null;
|
||||
cleanSegments();
|
||||
draw();
|
||||
};
|
||||
if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); };
|
||||
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
|
||||
editor.syncInputPair(angle, angleNum);
|
||||
editor.syncInputPair(speed, speedNum);
|
||||
editor.syncInputPair(travel, travelNum, controller.draw);
|
||||
editor.syncInputPair(thick, thickNum, controller.draw);
|
||||
const close = () => {
|
||||
dialog.classList.add("hidden");
|
||||
canvas.style.cursor = "";
|
||||
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
|
||||
controller.close();
|
||||
};
|
||||
if (apply) apply.onclick = () => {
|
||||
global.TarinaiHistory.capture(worldRef, "reciprocator-edit");
|
||||
cleanSegments();
|
||||
controller.cleanSegments();
|
||||
const editedSegments = controller.getSegments();
|
||||
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
|
||||
const railAxis = normalizedItemAngle(angleDeg * Math.PI / 180, 0);
|
||||
phySet(item, "railAxis", railAxis, "editor-rail-axis");
|
||||
|
|
@ -764,7 +476,7 @@
|
|||
phySet(item, "solid", solidInput ? Boolean(solidInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
|
||||
if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset");
|
||||
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness");
|
||||
physicsApi()?.setSegments?.(item, segments.map(seg => seg.slice(0, 4)), "editor-segments");
|
||||
physicsApi()?.setSegments?.(item, editedSegments, "editor-segments");
|
||||
const sa = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", itemAngleFor(item));
|
||||
const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
|
||||
const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
|
||||
|
|
@ -783,8 +495,7 @@
|
|||
if (cancel) cancel.onclick = close;
|
||||
if (closeBtn) closeBtn.onclick = close;
|
||||
dialog.onclick = (e) => { if (e.target === dialog) close(); };
|
||||
setMode(mode);
|
||||
draw();
|
||||
controller.draw();
|
||||
dialog.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -960,14 +671,7 @@
|
|||
item.vy = 0;
|
||||
item.spinVelocity = 0;
|
||||
if (isPinType(item.type)) {
|
||||
item.pinState = "loose";
|
||||
item.pinTargetId = "";
|
||||
item.pinAttachAngle = 0;
|
||||
item.pinAttachDistance = 0;
|
||||
item.pinOffsetY = 0;
|
||||
item.pinDamageTick = 0;
|
||||
item.pinFallCheckTimer = 0;
|
||||
item.pinLogAt = -999;
|
||||
global.TarinaiPinAttachmentSystem?.resetLooseState?.(item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
|
@ -1003,7 +707,6 @@
|
|||
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
|
||||
if (item.type === "gate_fence") {
|
||||
item.gateOpen = !item.gateOpen;
|
||||
item.gateLastToggleAt = worldRef?.time || 0;
|
||||
worldRef?.markSpatialDirty?.("gate-toggle");
|
||||
worldRef.drawListDirty = true;
|
||||
worldRef?.log?.(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5\u3092\u958b\u3051\u305f\u3002" : "\u30b2\u30fc\u30c8\u67f5\u3092\u9589\u3081\u305f\u3002", "observe");
|
||||
|
|
@ -1066,8 +769,7 @@
|
|||
}
|
||||
|
||||
function isSoftPlacementIgnoredItemType(type = "") {
|
||||
const key = String(type || "");
|
||||
return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water";
|
||||
return global.TarinaiCollisionFootprints?.isSoftPlacementIgnoredType?.(type) || false;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1109,7 +811,7 @@
|
|||
return search;
|
||||
}
|
||||
|
||||
function collectSoftItemsUnderPlacementItem(worldRef, item) {
|
||||
function collectSoftUnderPlacement(worldRef, item) {
|
||||
if (!worldRef || !item || item.type === "trace" || item.type === "splat") return [];
|
||||
const out = [];
|
||||
const search = placementSoftSearchRadius(item);
|
||||
|
|
@ -1121,7 +823,7 @@
|
|||
}
|
||||
|
||||
function clearSoftItemsUnderPlacementItem(worldRef, item, reason = "placement-soft-overlap-clear") {
|
||||
const items = collectSoftItemsUnderPlacementItem(worldRef, item);
|
||||
const items = collectSoftUnderPlacement(worldRef, item);
|
||||
let removed = 0;
|
||||
for (const soft of items) {
|
||||
const ok = global.TarinaiStructureLifecycle.deleteItem(worldRef, soft, {
|
||||
|
|
@ -1147,11 +849,25 @@
|
|||
|
||||
global.TarinaiPhysicsSoftClear = Object.freeze({
|
||||
isSoftPlacementType: isSoftPlacementIgnoredItemType,
|
||||
collectForPlacement: collectSoftItemsUnderPlacementItem,
|
||||
collectForPlacement: collectSoftUnderPlacement,
|
||||
clearForItem: clearSoftItemsUnderPhysicsItem,
|
||||
});
|
||||
|
||||
function clearOverlappingGrassBedsForPlacement(worldRef, item) {
|
||||
|
||||
function ballPlacementBlocked(worldRef, item) {
|
||||
if (!worldRef || !item || item.type !== "ball") return false;
|
||||
const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback);
|
||||
const r = Math.max(8, Number(item.r || item.radius || radiusFor(item.type, 18) || 18) || 18);
|
||||
const search = Math.max(72, r * 3.2);
|
||||
for (const other of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
|
||||
if (!other || other === item || other.dead || other.type !== "ball") continue;
|
||||
const or = Math.max(8, Number(other.r || other.radius || radiusFor(other.type, 18) || 18) || 18);
|
||||
if (distXY(item.x, item.y, other.x, other.y) < r + or - 1.0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clearGrassBedsForPlacement(worldRef, item) {
|
||||
return clearSoftItemsUnderPlacementItem(worldRef, item, "placement-soft-overlap-clear");
|
||||
}
|
||||
|
||||
|
|
@ -1168,7 +884,6 @@
|
|||
applyCopyBufferToItem(item, this.copyBuffer);
|
||||
item.x = x; item.y = y;
|
||||
item.world = this;
|
||||
item.isCopyPreview = true;
|
||||
return item;
|
||||
},
|
||||
|
||||
|
|
@ -1189,7 +904,6 @@
|
|||
}
|
||||
const item = this.copyPreviewItemAt?.(x, y);
|
||||
if (item) {
|
||||
item.isCopyPreview = false;
|
||||
const placed = this.placeItem(item, false);
|
||||
if (placed) {
|
||||
placed.world = this;
|
||||
|
|
@ -1245,6 +959,7 @@
|
|||
}
|
||||
}
|
||||
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
|
||||
if (ballPlacementBlocked(this, item)) return true;
|
||||
if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true;
|
||||
if (this.importantPlacementOverlapBlocked(item)) return true;
|
||||
if (item.type === "nest_box") {
|
||||
|
|
@ -1387,14 +1102,13 @@
|
|||
item.dropTimer = 0;
|
||||
item.dropImpactDone = true;
|
||||
}
|
||||
const removedSoft = clearOverlappingGrassBedsForPlacement(this, item);
|
||||
const removedSoft = clearGrassBedsForPlacement(this, item);
|
||||
if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear");
|
||||
const placed = this.addItem?.(item, `place:${item.type}`);
|
||||
if (!placed) {
|
||||
if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
|
||||
return null;
|
||||
}
|
||||
this.emit("tool:place", { item, type: item.type, dropped });
|
||||
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
|
||||
this.drawListDirty = true;
|
||||
this.ensureSpatial?.(`place:${item.type}`);
|
||||
|
|
|
|||
375
js/world_reset_presets.js
Normal file
375
js/world_reset_presets.js
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"use strict";
|
||||
|
||||
// Reset preset definitions and world population generators.
|
||||
(function (global) {
|
||||
|
||||
const RESET_PRESETS = Object.freeze({
|
||||
empty: { label: "\u7A7A\u767D" },
|
||||
default: { label: "\u30C7\u30D5\u30A9\u30EB\u30C8" },
|
||||
athletic: { label: "\u30A2\u30B9\u30EC\u30C1\u30C3\u30AF" },
|
||||
crowded: { label: "\u904E\u5BC6" },
|
||||
family: { label: "\u5BB6\u65CF" },
|
||||
war: { label: "\u6226\u4E89" },
|
||||
happy: { label: "\u5E78\u798F" },
|
||||
});
|
||||
|
||||
function normalizeResetPresetId(presetId = "default") {
|
||||
const id = String(presetId || "default");
|
||||
return Object.prototype.hasOwnProperty.call(RESET_PRESETS, id) ? id : "default";
|
||||
}
|
||||
|
||||
global.TarinaiResetPresets = Object.freeze({
|
||||
ids: Object.freeze(Object.keys(RESET_PRESETS)),
|
||||
definitions: RESET_PRESETS,
|
||||
normalize: normalizeResetPresetId,
|
||||
});
|
||||
|
||||
function presetScaleForField(field) {
|
||||
const id = field?.id || "garden";
|
||||
if (id === "cage") return 0.65;
|
||||
if (id === "park") return 1.45;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function presetCount(field, base, min = 0, max = Infinity) {
|
||||
const value = Math.round((Number(base) || 0) * presetScaleForField(field));
|
||||
return Math.max(min, Math.min(Number.isFinite(max) ? max : value, value));
|
||||
}
|
||||
|
||||
function randPoint(worldRef, pad = 78, zone = null) {
|
||||
const x0 = Math.max(pad, Number(zone?.x0 ?? pad));
|
||||
const x1 = Math.min(worldRef.w - pad, Number(zone?.x1 ?? (worldRef.w - pad)));
|
||||
const y0 = Math.max(pad, Number(zone?.y0 ?? pad));
|
||||
const y1 = Math.min(worldRef.h - pad, Number(zone?.y1 ?? (worldRef.h - pad)));
|
||||
return { x: rand(Math.min(x0, x1), Math.max(x0, x1)), y: rand(Math.min(y0, y1), Math.max(y0, y1)) };
|
||||
}
|
||||
|
||||
function randomPresetAngle(step = Math.PI / 4) {
|
||||
const n = Math.max(1, Math.round((Math.PI * 2) / Math.max(0.001, step)));
|
||||
return Math.floor(rand(0, n)) * step;
|
||||
}
|
||||
|
||||
function presetShapeExtent(segments) {
|
||||
let max = 0;
|
||||
for (const seg of segments || []) {
|
||||
for (let i = 0; i < 4; i += 2) max = Math.max(max, Math.hypot(Number(seg[i]) || 0, Number(seg[i + 1]) || 0));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function configurePresetMechanicalItem(item, opts = {}) {
|
||||
const body = item?.physicsBody;
|
||||
if (!body || (item.type !== "rotator" && item.type !== "reciprocator")) return item;
|
||||
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
|
||||
body.pose.x = item.x;
|
||||
body.pose.y = item.y;
|
||||
body.pose.angle = Number(item.angle) || 0;
|
||||
body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 };
|
||||
body.velocity.x = 0;
|
||||
body.velocity.y = 0;
|
||||
body.velocity.angular = 0;
|
||||
body.velocity.linear = 0;
|
||||
body.shape = body.shape || { model: "segments", thickness: 12, segments: [], version: 0 };
|
||||
|
||||
if (item.type === "rotator") {
|
||||
const segments = opts.segments || [
|
||||
[-92, 0, 92, 0],
|
||||
[0, -58, 0, 58],
|
||||
[-58, -42, 58, 42],
|
||||
[-58, 42, 58, -42],
|
||||
];
|
||||
body.motor = body.motor || { powered: true, speed: Math.PI * 0.18, direction: 1 };
|
||||
body.motor.powered = opts.motorOn !== false;
|
||||
body.motor.speed = Number.isFinite(Number(opts.motorSpeed)) ? Number(opts.motorSpeed) : Math.PI * 0.18;
|
||||
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
|
||||
body.shape.model = "segments";
|
||||
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
|
||||
body.shape.segments = segments.map(seg => seg.slice(0, 4));
|
||||
body.shape.version = (Number(body.shape.version) || 0) + 1;
|
||||
item.spin = 0;
|
||||
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
|
||||
} else if (item.type === "reciprocator") {
|
||||
const railAxis = Number.isFinite(Number(opts.railAxis)) ? Number(opts.railAxis) : (Number(item.angle) || 0);
|
||||
const segments = opts.segments || [
|
||||
[-92, -24, -36, -24],
|
||||
[-36, -24, -36, 24],
|
||||
[-36, 24, 36, 24],
|
||||
[36, 24, 36, -24],
|
||||
[36, -24, 92, -24],
|
||||
];
|
||||
body.motor = body.motor || { powered: true, speed: 36, direction: 1 };
|
||||
body.motor.powered = opts.railOn !== false;
|
||||
body.motor.speed = Number.isFinite(Number(opts.railMotorSpeed)) ? Number(opts.railMotorSpeed) : 36;
|
||||
body.motor.direction = Number(opts.direction) < 0 ? -1 : 1;
|
||||
body.rail = body.rail || { axisAngle: railAxis, travel: 120, phase: 0, anchorX: item.x, anchorY: item.y };
|
||||
body.rail.axisAngle = railAxis;
|
||||
body.rail.travel = Number.isFinite(Number(opts.railTravel)) ? Number(opts.railTravel) : 120;
|
||||
body.rail.phase = Number.isFinite(Number(opts.railPhase)) ? Number(opts.railPhase) : 0;
|
||||
body.rail.anchorX = item.x;
|
||||
body.rail.anchorY = item.y;
|
||||
body.shape.model = "segments";
|
||||
body.shape.thickness = Number.isFinite(Number(opts.thickness)) ? Number(opts.thickness) : 14;
|
||||
body.shape.segments = segments.map(seg => seg.slice(0, 4));
|
||||
body.shape.version = (Number(body.shape.version) || 0) + 1;
|
||||
item.r = Math.max(item.r || 64, presetShapeExtent(segments) + body.shape.thickness + 8);
|
||||
}
|
||||
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-mechanical-shape");
|
||||
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function syncPresetMechanicalPose(item) {
|
||||
const body = item?.physicsBody;
|
||||
if (!body) return;
|
||||
body.pose = body.pose || { x: item.x, y: item.y, angle: Number(item.angle) || 0 };
|
||||
body.pose.x = item.x;
|
||||
body.pose.y = item.y;
|
||||
body.pose.angle = Number(item.angle) || 0;
|
||||
if (body.rail) {
|
||||
body.rail.anchorX = item.x;
|
||||
body.rail.anchorY = item.y;
|
||||
}
|
||||
globalThis.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "preset-placement-pose");
|
||||
globalThis.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
|
||||
}
|
||||
|
||||
function presetItemFootprintRadius(worldRef, item) {
|
||||
const radius = globalThis.TarinaiCollisionFootprints?.itemFootprintRadius?.(worldRef, item);
|
||||
if (Number.isFinite(Number(radius))) {
|
||||
if (item?.type === "ball") return Math.max(34, Number(radius) * 1.35);
|
||||
return Math.max(14, Number(radius));
|
||||
}
|
||||
return Math.max(14, Number(item?.r || 16) || 16);
|
||||
}
|
||||
|
||||
function presetItemPlacementBlocked(worldRef, item, opts = {}) {
|
||||
if (!worldRef || !item || item.dead) return true;
|
||||
if (typeof worldRef.placementBlocked === "function" && worldRef.placementBlocked(item)) return true;
|
||||
const itemRects = worldRef.solidObstacleRects?.(item) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, item) || [];
|
||||
const itemRadius = presetItemFootprintRadius(worldRef, item);
|
||||
const search = Math.max(120, itemRadius * 3.2);
|
||||
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || worldRef.items || []) {
|
||||
if (!other || other === item || other.dead) continue;
|
||||
const sameType = other.type === item.type;
|
||||
const otherIsFence = worldRef.isFenceType?.(other.type) || false;
|
||||
const itemIsFence = worldRef.isFenceType?.(item.type) || false;
|
||||
if (!opts.avoidAllObstacles && !sameType && !otherIsFence && !itemIsFence) continue;
|
||||
const otherRects = worldRef.solidObstacleRects?.(other) || globalThis.TarinaiCollisionFootprints?.placementRectsFor?.(worldRef, other) || [];
|
||||
const otherRadius = presetItemFootprintRadius(worldRef, other);
|
||||
const margin = (itemIsFence || otherIsFence) ? 4 : Math.max(8, Math.min(itemRadius, otherRadius) * 0.28);
|
||||
if (itemRects.length && otherRects.length) {
|
||||
if (itemRects.some(a => otherRects.some(b => globalThis.TarinaiCollisionFootprints?.rectsOverlap?.(a, b, margin)))) return true;
|
||||
continue;
|
||||
}
|
||||
if (itemRects.length) {
|
||||
if (itemRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, other.x, other.y, otherRadius, margin))) return true;
|
||||
continue;
|
||||
}
|
||||
if (otherRects.length) {
|
||||
if (otherRects.some(r => globalThis.TarinaiCollisionFootprints?.rectCircleOverlap?.(r, item.x, item.y, itemRadius, margin))) return true;
|
||||
continue;
|
||||
}
|
||||
if (Math.hypot((item.x || 0) - (other.x || 0), (item.y || 0) - (other.y || 0)) < itemRadius + otherRadius + margin) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findPresetItemSpot(worldRef, item, opts = {}) {
|
||||
const allowOriginal = opts.allowOriginal !== false;
|
||||
if (allowOriginal && !presetItemPlacementBlocked(worldRef, item, opts)) return { x: item.x, y: item.y };
|
||||
const solid = Boolean(worldRef?.isFenceType?.(item.type) || item.type === "rotator" || item.type === "reciprocator" || item.type === "nest_box");
|
||||
const count = Math.max(12, Number(opts.attempts || 0) || (solid ? 96 : 58));
|
||||
const maxRadius = Math.max(70, Number(opts.maxRadius || 0) || (solid ? 220 : 170));
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
const seed = `${item.type}:${Math.round(item.x)},${Math.round(item.y)}`;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count <= 1 ? 1 : i / (count - 1);
|
||||
const radius = Math.max(10, maxRadius * Math.sqrt(t));
|
||||
const angle = i * golden + (globalThis.stableUnit ? globalThis.stableUnit(seed, "preset-place") : Math.random()) * Math.PI * 2;
|
||||
const rawX = item.x + Math.cos(angle) * radius;
|
||||
const rawY = item.y + Math.sin(angle) * radius * 0.74;
|
||||
const p = worldRef.placementClampPointFor?.(item, rawX, rawY) || { x: clamp(rawX, 42, worldRef.w - 42), y: clamp(rawY, 42, worldRef.h - 42) };
|
||||
const oldX = item.x, oldY = item.y;
|
||||
item.x = p.x; item.y = p.y; syncPresetMechanicalPose(item);
|
||||
const blocked = presetItemPlacementBlocked(worldRef, item, opts);
|
||||
item.x = oldX; item.y = oldY; syncPresetMechanicalPose(item);
|
||||
if (blocked) continue;
|
||||
const d = Math.hypot(p.x - oldX, p.y - oldY);
|
||||
if (d < bestD) { best = p; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function addPresetItem(worldRef, type, x, y, opts = {}) {
|
||||
const item = new Item(type, x, y);
|
||||
if (Number.isFinite(Number(opts.r))) item.r = Number(opts.r);
|
||||
if (Number.isFinite(Number(opts.angle))) item.angle = Number(opts.angle);
|
||||
if (opts.toolSize) item.toolSize = opts.toolSize;
|
||||
if (type === "duplicator") {
|
||||
item.storedFoodType = opts.storedFoodType || "sweet";
|
||||
item.storedFoodLabel = typeof toolLabel === "function" ? toolLabel(item.storedFoodType) : item.storedFoodType;
|
||||
if (item.roles) item.roles.food = true;
|
||||
}
|
||||
if (type === "rotator" || type === "reciprocator") configurePresetMechanicalItem(item, opts);
|
||||
if (opts.avoidOverlap) {
|
||||
const spot = findPresetItemSpot(worldRef, item, opts);
|
||||
if (!spot) return null;
|
||||
item.x = spot.x;
|
||||
item.y = spot.y;
|
||||
syncPresetMechanicalPose(item);
|
||||
}
|
||||
if (worldRef.addItem) worldRef.addItem(item, opts.reason || `preset-${type}`);
|
||||
else worldRef.items.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function addPresetGrass(worldRef, count, zone = null) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = randPoint(worldRef, 70, zone);
|
||||
const grass = addPresetItem(worldRef, "grass", p.x, p.y, { reason: "preset-grass" });
|
||||
globalThis.TarinaiGrass?.setStage?.(grass, Math.floor(rand(0, 2.999)), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetTarinai(worldRef, count, zone = null, opts = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = randPoint(worldRef, 82, zone);
|
||||
worldRef.addTarinai({ x: p.x, y: p.y, generation: opts.generation || 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetItemsScattered(worldRef, type, count, zone = null, opts = {}) {
|
||||
let placed = 0;
|
||||
const n = Math.max(0, Number(count) | 0);
|
||||
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
|
||||
for (let i = 0; i < attempts && placed < n; i++) {
|
||||
const p = randPoint(worldRef, opts.pad || 82, zone);
|
||||
const item = addPresetItem(worldRef, type, p.x, p.y, opts);
|
||||
if (item) placed++;
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetFenceLine(worldRef, count, yRatio, angle = 0, type = "fence_h", opts = {}) {
|
||||
const n = Math.max(1, Number(count) | 0);
|
||||
const gap = worldRef.w / (n + 1);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
addPresetItem(worldRef, type, gap * i, worldRef.h * yRatio + rand(-24, 24), { angle, reason: opts.reason || "preset-fence", avoidOverlap: !!opts.avoidOverlap, avoidAllObstacles: !!opts.avoidAllObstacles, attempts: opts.attempts || 96, maxRadius: opts.maxRadius || 190 });
|
||||
}
|
||||
}
|
||||
|
||||
function addPresetFencesScattered(worldRef, count, zone = null, opts = {}) {
|
||||
const n = Math.max(0, Number(count) | 0);
|
||||
let placed = 0;
|
||||
const attempts = opts.avoidOverlap ? Math.max(n * 4, n + 8) : n;
|
||||
for (let i = 0; i < attempts && placed < n; i++) {
|
||||
const p = randPoint(worldRef, opts.pad || 78, zone);
|
||||
const vertical = Math.random() < 0.5;
|
||||
const item = addPresetItem(worldRef, vertical ? "fence_v" : "fence_h", p.x, p.y, { angle: randomPresetAngle(Math.PI / 4), reason: opts.reason || "preset-fence-scatter", avoidOverlap: !!opts.avoidOverlap, attempts: opts.attempts || 80, maxRadius: opts.maxRadius || 180 });
|
||||
if (item) placed++;
|
||||
}
|
||||
}
|
||||
|
||||
function generateDefaultPreset(worldRef, field, seedPopulation) {
|
||||
const population = seedPopulation ?? field.population ?? CONFIG.initialPopulation;
|
||||
const grassCount = field.grass ?? 18;
|
||||
addPresetTarinai(worldRef, population);
|
||||
addPresetGrass(worldRef, grassCount);
|
||||
addPresetItem(worldRef, "stone", worldRef.w * 0.68, worldRef.h * 0.32, { reason: "reset-stone" });
|
||||
}
|
||||
|
||||
function generateAthleticPreset(worldRef, field) {
|
||||
const factor = presetScaleForField(field);
|
||||
const fieldId = field?.id || "garden";
|
||||
const compact = fieldId === "cage" || fieldId === "garden";
|
||||
const center = { x0: worldRef.w * 0.10, x1: worldRef.w * 0.90, y0: worldRef.h * 0.14, y1: worldRef.h * 0.86 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 14, 7, 24), { x0: worldRef.w * 0.08, x1: worldRef.w * 0.30, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 });
|
||||
// \u7269\u7406\u30D6\u30ED\u30C3\u30AF\u306F\u67F5\u3088\u308A\u5148\u306B\u7F6E\u304F\u3002\u72ED\u3044\u4E2D\u5C0F\u30D5\u30A3\u30FC\u30EB\u30C9\u3067\u306F\u67F5\u5074\u3092\u9593\u5F15\u304D\u3001\u30D6\u30ED\u30C3\u30AF\u3092\u512A\u5148\u3059\u308B\u3002
|
||||
addPresetItemsScattered(worldRef, "stone", presetCount(field, compact ? 4 : 6, 3, 9), center, { pad: compact ? 118 : 132, reason: "preset-athletic-physical-block", avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 260 : 220 });
|
||||
addPresetItemsScattered(worldRef, "stove", presetCount(field, 2, 1, 3), center, { pad: 118, reason: "preset-athletic-stove", avoidOverlap: true, avoidAllObstacles: true, attempts: 100, maxRadius: 210 });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, compact ? 8 : 10, 4, 18), null, { pad: 96, reason: "preset-ball", avoidOverlap: true, avoidAllObstacles: true, attempts: 90, maxRadius: compact ? 230 : 190 });
|
||||
addPresetFenceLine(worldRef, Math.max(2, Math.round((compact ? 3 : 4) * factor)), 0.36, 0, "fence_h", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
|
||||
addPresetFenceLine(worldRef, Math.max(1, Math.round((compact ? 2 : 3) * factor)), 0.62, Math.PI / 2, "fence_v", { avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210, reason: "preset-athletic-fence-line" });
|
||||
addPresetFencesScattered(worldRef, Math.max(1, Math.round((compact ? 2 : 4) * factor)), null, { pad: 108, reason: "preset-athletic-fence", avoidOverlap: true, avoidAllObstacles: true, attempts: 120, maxRadius: compact ? 250 : 210 });
|
||||
addPresetItemsScattered(worldRef, "rotator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-rotator-slow-custom", motorSpeed: Math.PI * 0.18, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
|
||||
addPresetItemsScattered(worldRef, "reciprocator", Math.max(1, Math.round(2 * factor)), null, { pad: 148, reason: "preset-reciprocator-slow-custom", railMotorSpeed: 36, railTravel: 120, thickness: 14, avoidOverlap: true, avoidAllObstacles: true, attempts: 140, maxRadius: compact ? 280 : 240 });
|
||||
}
|
||||
|
||||
function generateCrowdedPreset(worldRef, field) {
|
||||
const center = { x0: worldRef.w * 0.18, x1: worldRef.w * 0.82, y0: worldRef.h * 0.18, y1: worldRef.h * 0.82 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 36, 18, 58), center);
|
||||
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 13, 7, 22), center, { pad: 96, reason: "preset-nest-box" });
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), center, { pad: 110, storedFoodType: "sweet", reason: "preset-duplicator-zunda" });
|
||||
addPresetGrass(worldRef, presetCount(field, 18, 8, 34), center);
|
||||
addPresetItemsScattered(worldRef, "water", presetCount(field, 8, 4, 16), center, { pad: 84, reason: "preset-water" });
|
||||
}
|
||||
|
||||
function generateFamilyPreset(worldRef, field) {
|
||||
const cx = worldRef.w * 0.5;
|
||||
const cy = worldRef.h * 0.52;
|
||||
worldRef.addTarinai({ x: cx - 52, y: cy + 20, generation: 1 });
|
||||
worldRef.addTarinai({ x: cx + 52, y: cy + 20, generation: 1 });
|
||||
addPresetItem(worldRef, "nest_box", cx, cy - 58, { reason: "preset-family-nest" });
|
||||
addPresetItem(worldRef, "duplicator", cx, cy + 112, { storedFoodType: "love_mochi", reason: "preset-family-duplicator" });
|
||||
}
|
||||
|
||||
function generateWarPreset(worldRef, field) {
|
||||
const left = { x0: worldRef.w * 0.08, x1: worldRef.w * 0.45, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
const right = { x0: worldRef.w * 0.55, x1: worldRef.w * 0.92, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
addPresetTarinai(worldRef, Math.ceil(presetCount(field, 28, 14, 48) / 2), left);
|
||||
addPresetTarinai(worldRef, Math.floor(presetCount(field, 28, 14, 48) / 2), right);
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 3, 2, 5), { x0: worldRef.w * 0.38, x1: worldRef.w * 0.62, y0: worldRef.h * 0.22, y1: worldRef.h * 0.78 }, { pad: 96, storedFoodType: "fight_mochi", reason: "preset-war-duplicator" });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, 8, 4, 14), null, { pad: 90, reason: "preset-war-ball" });
|
||||
addPresetItemsScattered(worldRef, "ant_nest", presetCount(field, 1.25, 1, 2), null, { pad: 116, reason: "preset-war-ant-nest", avoidOverlap: true, attempts: 80, maxRadius: 190 });
|
||||
addPresetItemsScattered(worldRef, "pushpin", presetCount(field, 5, 2, 9), null, { pad: 88, reason: "preset-war-pushpin-reduced" });
|
||||
addPresetItemsScattered(worldRef, "oshibyo", presetCount(field, 8, 4, 16), null, { pad: 88, reason: "preset-war-oshibyo" });
|
||||
addPresetFencesScattered(worldRef, presetCount(field, 8, 4, 14), null, { pad: 94, reason: "preset-war-fence" });
|
||||
}
|
||||
|
||||
function generateHappyPreset(worldRef, field) {
|
||||
const center = { x0: worldRef.w * 0.16, x1: worldRef.w * 0.84, y0: worldRef.h * 0.16, y1: worldRef.h * 0.84 };
|
||||
addPresetTarinai(worldRef, presetCount(field, 16, 8, 28), center);
|
||||
addPresetItemsScattered(worldRef, "nest_box", presetCount(field, 8, 4, 14), center, { pad: 96, reason: "preset-happy-nest-box" });
|
||||
addPresetItemsScattered(worldRef, "robot_cleaner", presetCount(field, 2, 1, 3), center, { pad: 110, reason: "preset-happy-robot-cleaner" });
|
||||
addPresetItemsScattered(worldRef, "duplicator", presetCount(field, 2, 1, 4), center, { pad: 110, storedFoodType: "sweet", reason: "preset-happy-duplicator-zunda" });
|
||||
addPresetItemsScattered(worldRef, "ball", presetCount(field, 6, 3, 10), center, { pad: 88, reason: "preset-happy-ball" });
|
||||
}
|
||||
|
||||
function generatePresetWorld(worldRef, field, presetId, seedPopulation) {
|
||||
switch (normalizeResetPresetId(presetId)) {
|
||||
case "empty": return;
|
||||
case "athletic": return generateAthleticPreset(worldRef, field);
|
||||
case "crowded": return generateCrowdedPreset(worldRef, field);
|
||||
case "family": return generateFamilyPreset(worldRef, field);
|
||||
case "war": return generateWarPreset(worldRef, field);
|
||||
case "happy": return generateHappyPreset(worldRef, field);
|
||||
case "default":
|
||||
default:
|
||||
return generateDefaultPreset(worldRef, field, seedPopulation);
|
||||
}
|
||||
}
|
||||
|
||||
function groundForResetPreset(presetId = "default") {
|
||||
const id = normalizeResetPresetId(presetId);
|
||||
if (id === "athletic") return "ice";
|
||||
if (id === "war") return "foot_massage";
|
||||
if (id === "happy") return "blanket";
|
||||
return "soil";
|
||||
}
|
||||
|
||||
function presetLabel(presetId = "default") {
|
||||
return RESET_PRESETS[normalizeResetPresetId(presetId)]?.label || "\u30C7\u30D5\u30A9\u30EB\u30C8";
|
||||
}
|
||||
|
||||
const api = Object.freeze({
|
||||
ids: Object.freeze(Object.keys(RESET_PRESETS)),
|
||||
definitions: RESET_PRESETS,
|
||||
normalize: normalizeResetPresetId,
|
||||
generatePresetWorld,
|
||||
groundForResetPreset,
|
||||
label: presetLabel,
|
||||
});
|
||||
global.TarinaiResetPresets = api;
|
||||
global.TarinaiWorldResetPresets = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
238
js/world_temperature_system.js
Normal file
238
js/world_temperature_system.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"use strict";
|
||||
|
||||
// Layer: world/temperature
|
||||
// Temperature, seasons, climate comfort scoring, and temperature-driven target selection.
|
||||
(function (global) {
|
||||
const World = global.World;
|
||||
if (!World) return;
|
||||
|
||||
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
||||
temperatureNumber(value, fallback = CONFIG.standardTemperature ?? 15) {
|
||||
const v = Number(value);
|
||||
const fb = Number(fallback);
|
||||
return Number.isFinite(v) ? v : (Number.isFinite(fb) ? fb : 15);
|
||||
},
|
||||
climateTemperature(value) {
|
||||
// \u81EA\u7136\u6C17\u5019\u3060\u3051\u306F\u4ED5\u69D8\u3069\u304A\u308A -20\u2103\u301C50\u2103 \u306E\u7BC4\u56F2\u306B\u53CE\u3081\u308B\u3002
|
||||
const v = this.temperatureNumber(value);
|
||||
return clamp(v, CONFIG.climateTemperatureMin ?? -20, CONFIG.climateTemperatureMax ?? 50);
|
||||
},
|
||||
seasonIndex() {
|
||||
return Math.floor((((Number(this.day) || 1) - 1) % 20) / 5);
|
||||
},
|
||||
seasonLabel() {
|
||||
return ["\u6625", "\u590F", "\u79CB", "\u51AC"][this.seasonIndex()] || "\u6625";
|
||||
},
|
||||
seasonDay() {
|
||||
return ((((Number(this.day) || 1) - 1) % 5) + 1);
|
||||
},
|
||||
seasonDayString() {
|
||||
return `${this.seasonLabel()}${this.seasonDay()}\u65E5`;
|
||||
},
|
||||
temperatureStableUnit(salt = "") {
|
||||
const key = `${this.worldSeed || "world"}:temperature:${salt}`;
|
||||
if (typeof stableUnit === "function") return stableUnit(key, "temperature");
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 16777619);
|
||||
return ((h >>> 0) % 1000000) / 1000000;
|
||||
},
|
||||
dailyTemperatureRange(day = this.day, weather = this.weather) {
|
||||
const raw = this.temperatureStableUnit(`daily-range:${day}`) * 15;
|
||||
if (weather === "sunny") return clamp(6 + raw * 0.62, 0, 15);
|
||||
if (weather === "light_rain") return clamp(raw * 0.32, 0, 5);
|
||||
return clamp(raw * 0.45, 0, 7);
|
||||
},
|
||||
annualTemperatureOffset() {
|
||||
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
||||
const totalDays = Math.max(0, Number(this.time || 0) / dayLength);
|
||||
const phase = ((totalDays % 20) + 20) % 20 / 20;
|
||||
const range = Number.isFinite(Number(this.temperatureAnnualRange)) ? Number(this.temperatureAnnualRange) : 30;
|
||||
const wave = Math.sin(phase * Math.PI * 2 - Math.PI * 0.25);
|
||||
return wave * range * 0.5;
|
||||
},
|
||||
dailyTemperatureWave(progress = 0) {
|
||||
const hour = ((((Number(progress) || 0) % 1) + 1) % 1) * 24;
|
||||
const minHour = 5.0; // \u671D\u65B9\u306B\u6700\u4F4E\u6C17\u6E29
|
||||
const maxHour = 14.0; // \u663C\u904E\u304E\u306B\u6700\u9AD8\u6C17\u6E29
|
||||
const smooth = (x) => {
|
||||
const t = clamp(x, 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
let warmth;
|
||||
if (hour >= minHour && hour <= maxHour) {
|
||||
warmth = smooth((hour - minHour) / Math.max(0.001, maxHour - minHour));
|
||||
} else {
|
||||
const downHour = hour > maxHour ? hour - maxHour : hour + 24 - maxHour;
|
||||
const downSpan = minHour + 24 - maxHour;
|
||||
warmth = 1 - smooth(downHour / Math.max(0.001, downSpan));
|
||||
}
|
||||
return warmth * 2 - 1;
|
||||
},
|
||||
naturalTemperature() {
|
||||
// \u521D\u671F\u30ED\u30FC\u30C9\u76F4\u5F8C\u306F 10\u2103 \u3092\u4FDD\u6301\u3057\u3001\u521D\u56DE\u30D5\u30EC\u30FC\u30E0\u306Edt\u63FA\u308C\u30670\u2103\u8FD1\u304F\u3078\u843D\u3061\u306A\u3044\u3088\u3046\u306B\u3059\u308B\u3002
|
||||
if ((Number(this.time) || 0) <= 6.0 && (Number(this.day) || 1) === 1) {
|
||||
this.temperatureDailyRange = 0;
|
||||
return 10;
|
||||
}
|
||||
const dayLength = Math.max(1, CONFIG.dayLength || 120);
|
||||
const progress = ((Number(this.time || 0) % dayLength) + dayLength) % dayLength / dayLength;
|
||||
const dailyRange = this.dailyTemperatureRange(this.day, this.weather);
|
||||
const dailyWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(progress) : Math.sin(progress * Math.PI * 2 - Math.PI * 0.5);
|
||||
let temp;
|
||||
if ((Number(this.day) || 1) === 1) {
|
||||
// \u66251\u65E5\u76EE\u306F\u958B\u59CB\u664210\u2103\u3092\u57FA\u6E96\u306B\u3057\u3064\u3064\u3001\u65E5\u5185\u306E\u6700\u4F4E/\u6700\u9AD8\u6642\u523B\u3060\u3051\u306F\u7DAD\u6301\u3059\u308B\u3002
|
||||
const initialWave = this.dailyTemperatureWave ? this.dailyTemperatureWave(0) : Math.sin(-Math.PI * 0.5);
|
||||
temp = 10 + (dailyWave - initialWave) * dailyRange * 0.5;
|
||||
} else {
|
||||
temp = (CONFIG.standardTemperature ?? 15) + this.annualTemperatureOffset() + dailyWave * dailyRange * 0.5;
|
||||
}
|
||||
this.temperatureDailyRange = dailyRange;
|
||||
return this.climateTemperature(temp);
|
||||
},
|
||||
updateTemperature(dt = 0) {
|
||||
if (!Number.isFinite(Number(this.temperatureAnnualRange))) {
|
||||
this.temperatureAnnualRange = 25 + this.temperatureStableUnit("annual-range") * 10;
|
||||
}
|
||||
if (!Number.isFinite(Number(this.manualTemperature))) this.manualTemperature = CONFIG.standardTemperature ?? 15;
|
||||
this.currentTemperature = this.temperatureAuto === false
|
||||
? this.temperatureNumber(this.manualTemperature)
|
||||
: this.naturalTemperature();
|
||||
return this.currentTemperature;
|
||||
},
|
||||
setTemperatureAuto(enabled = true) {
|
||||
this.temperatureAuto = Boolean(enabled);
|
||||
if (this.temperatureAuto === false) this.manualTemperature = this.temperatureNumber(this.currentTemperature ?? this.manualTemperature ?? CONFIG.standardTemperature ?? 15);
|
||||
return this.updateTemperature?.(0) ?? this.currentTemperature;
|
||||
},
|
||||
setManualTemperature(value = 15) {
|
||||
this.manualTemperature = this.temperatureNumber(value);
|
||||
if (this.temperatureAuto === false) this.currentTemperature = this.manualTemperature;
|
||||
return this.manualTemperature;
|
||||
},
|
||||
temperatureAt(x, y, actor = null) {
|
||||
let temp = Number.isFinite(Number(this.currentTemperature)) ? Number(this.currentTemperature) : this.updateTemperature?.(0) ?? (CONFIG.standardTemperature ?? 15);
|
||||
if (this.temperatureAuto === false) temp = this.temperatureNumber(this.manualTemperature);
|
||||
if ((this.groundType || "soil") === "ice") temp -= 5;
|
||||
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
||||
for (const it of this.nearbyItems(x, y, range + 8)) {
|
||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
||||
const d = distXY(x, y, it.x, it.y);
|
||||
if (d > range) continue;
|
||||
const edge = d <= range - 24 ? 1 : clamp((range - d) / 24, 0, 1);
|
||||
temp += (it.type === "stove" ? 10 : -10) * edge;
|
||||
}
|
||||
const nestWarmthLimit = 5;
|
||||
for (const box of this.nearbyItems?.(x, y, 86) || []) {
|
||||
if (!box || box.dead || box.type !== "nest_box") continue;
|
||||
const r = Math.max(36, Number(box.r || 42) || 42);
|
||||
const base = this.nestBoxBaseRect?.(box) || null;
|
||||
const inBase = base && x >= base.left - 4 && x <= base.right + 4 && y >= base.top - 4 && y <= base.bottom + 4;
|
||||
const inBody = distXY(x, y, box.x, box.y) <= r * 1.08;
|
||||
if (!inBase && !inBody) continue;
|
||||
const toward = 20 - temp;
|
||||
temp += clamp(toward, -nestWarmthLimit, nestWarmthLimit);
|
||||
break;
|
||||
}
|
||||
if (actor && (actor.state === "sunbath" || (actor.sunbathTimer || 0) > 0.04)) temp += 3;
|
||||
return this.temperatureNumber(temp);
|
||||
},
|
||||
feltTemperatureFor(actor, x = actor?.x, y = actor?.y) {
|
||||
if (!actor) return this.temperatureAt(x, y);
|
||||
return this.temperatureAt(x, y, actor);
|
||||
},
|
||||
temperatureComfortOffsetFor(actor = null) {
|
||||
const raw = actor?.genetics?.temperatureOffset;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
},
|
||||
temperatureStatusFor(temp = CONFIG.standardTemperature ?? 15, actor = null) {
|
||||
const value = this.temperatureNumber(temp);
|
||||
const offset = this.temperatureComfortOffsetFor(actor);
|
||||
const min = (CONFIG.temperatureComfortMin ?? 10) + offset;
|
||||
const max = (CONFIG.temperatureComfortMax ?? 25) + offset;
|
||||
const discomfort = value < min ? min - value : (value > max ? value - max : 0);
|
||||
const direction = value < min ? "cold" : (value > max ? "hot" : "comfort");
|
||||
const stressExcess = Math.max(0, discomfort - (CONFIG.temperatureStressMargin ?? 5));
|
||||
const harmExcess = Math.max(0, discomfort - (CONFIG.temperatureEnergyMargin ?? 10));
|
||||
return {
|
||||
temp: value,
|
||||
direction,
|
||||
discomfort,
|
||||
comfortable: discomfort <= 0,
|
||||
stressExcess,
|
||||
harmExcess,
|
||||
harmful: harmExcess > 0,
|
||||
label: discomfort <= 0 ? "\u5FEB\u9069" : (direction === "cold" ? (harmExcess > 0 ? "\u5371\u967A\u306A\u5BD2\u3055" : "\u5BD2\u3044") : (harmExcess > 0 ? "\u5371\u967A\u306A\u6691\u3055" : "\u6691\u3044")),
|
||||
};
|
||||
},
|
||||
findComfortTemperatureSpot(actor, opts = {}) {
|
||||
if (!actor || actor.dead) return null;
|
||||
const current = this.feltTemperatureFor?.(actor) ?? this.temperatureAt(actor.x, actor.y, actor);
|
||||
const currentStatus = this.temperatureStatusFor(current, actor);
|
||||
if (!opts.force && currentStatus.comfortable) return null;
|
||||
const comfortOffset = this.temperatureComfortOffsetFor(actor);
|
||||
const targetTemp = ((CONFIG.temperatureComfortMin ?? 10) + (CONFIG.temperatureComfortMax ?? 25)) * 0.5 + comfortOffset;
|
||||
const radius = actor.radius || actor.r || 20;
|
||||
const rings = [72, 128, 196, 288, 392];
|
||||
const dirs = 18;
|
||||
const candidates = [];
|
||||
const pushCandidate = (x, y, extra = 0) => candidates.push({
|
||||
x: clamp(x, radius + 8, (this.w || 1000) - radius - 8),
|
||||
y: clamp(y, radius + 8, (this.h || 720) - radius - 8),
|
||||
extra,
|
||||
});
|
||||
for (const ring of rings) {
|
||||
for (let i = 0; i < dirs; i++) {
|
||||
const a = (Math.PI * 2 * i / dirs) + ring * 0.017;
|
||||
pushCandidate(actor.x + Math.cos(a) * ring, actor.y + Math.sin(a) * ring);
|
||||
}
|
||||
}
|
||||
const itemRange = Math.max(24, CONFIG.temperatureItemRange ?? 190);
|
||||
for (const it of this.items || []) {
|
||||
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
|
||||
if (currentStatus.direction === "cold" && it.type !== "stove") continue;
|
||||
if (currentStatus.direction === "hot" && it.type !== "dry_ice") continue;
|
||||
const toward = Math.atan2(actor.y - it.y, actor.x - it.x);
|
||||
pushCandidate(it.x + Math.cos(toward) * itemRange * 0.45, it.y + Math.sin(toward) * itemRange * 0.45, -60);
|
||||
pushCandidate(it.x + Math.cos(toward + 0.75) * itemRange * 0.62, it.y + Math.sin(toward + 0.75) * itemRange * 0.62, -32);
|
||||
pushCandidate(it.x + Math.cos(toward - 0.75) * itemRange * 0.62, it.y + Math.sin(toward - 0.75) * itemRange * 0.62, -32);
|
||||
}
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const c of candidates) {
|
||||
if (this.pointBlockedByObstacle?.(c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor })) continue;
|
||||
const temp = this.temperatureAt(c.x, c.y, actor);
|
||||
const status = this.temperatureStatusFor(temp, actor);
|
||||
if (currentStatus.direction === "cold" && temp <= current + 0.6 && !status.comfortable) continue;
|
||||
if (currentStatus.direction === "hot" && temp >= current - 0.6 && !status.comfortable) continue;
|
||||
const d = distXY(actor.x, actor.y, c.x, c.y);
|
||||
const blockedPenalty = this.pathBlockedByFence?.(actor.x, actor.y, c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor }) ? 480 : 0;
|
||||
const score = status.discomfort * 110 + Math.abs(temp - targetTemp) * 2.8 + d * 0.22 + blockedPenalty + (c.extra || 0);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = { x: c.x, y: c.y, dead: false, detour: true, temperatureTarget: true, temperature: temp, label: "\u5FEB\u9069\u306A\u6E29\u5EA6\u5E2F" };
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
const bestStatus = this.temperatureStatusFor(best.temperature, actor);
|
||||
if (opts.requireComfort && !bestStatus.comfortable) return null;
|
||||
if (!bestStatus.comfortable && bestStatus.discomfort >= currentStatus.discomfort - 0.65) return null;
|
||||
best.temperatureStatus = bestStatus;
|
||||
return best;
|
||||
},
|
||||
hasReachableComfortTemperatureSpot(actor) {
|
||||
return !!this.findComfortTemperatureSpot?.(actor, { force: true, requireComfort: true });
|
||||
},
|
||||
sleepTargetTemperatureBlocked(actor, item) {
|
||||
if (!actor || !item || item.dead) return false;
|
||||
if (!(item.type === "grass_bed" || item.type === "bed" || item.type === "nest_box")) return false;
|
||||
const current = this.feltTemperatureFor?.(actor) ?? this.temperatureAt?.(actor.x, actor.y, actor);
|
||||
const currentStatus = this.temperatureStatusFor?.(current, actor);
|
||||
if (!currentStatus || currentStatus.comfortable) return false;
|
||||
if (!this.hasReachableComfortTemperatureSpot?.(actor)) return false;
|
||||
const temp = this.temperatureAt?.(item.x, item.y, actor);
|
||||
const status = this.temperatureStatusFor?.(temp, actor);
|
||||
return Boolean(status && !status.comfortable);
|
||||
},
|
||||
}));
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
|
@ -898,6 +898,7 @@ def check_tarinai_update_pipeline_boundary() -> None:
|
|||
"js/tarinai_update_step_environment.js",
|
||||
"js/tarinai_update_step_movement.js",
|
||||
"js/tarinai_update_step_health.js",
|
||||
"js/update_step_pipeline_runner.js",
|
||||
"js/tarinai_update_pipeline.js",
|
||||
]
|
||||
for rel in required:
|
||||
|
|
@ -938,6 +939,7 @@ def check_tarinai_update_pipeline_boundary() -> None:
|
|||
"js/tarinai_update_step_environment.js": "global.TarinaiEnvironmentUpdateStep",
|
||||
"js/tarinai_update_step_movement.js": "global.TarinaiMovementUpdateStep",
|
||||
"js/tarinai_update_step_health.js": "global.TarinaiHealthUpdateStep",
|
||||
"js/update_step_pipeline_runner.js": "global.TarinaiUpdateStepPipelineRunner",
|
||||
"js/tarinai_update_pipeline.js": "global.TarinaiUpdatePipeline",
|
||||
}
|
||||
for rel, token in exports.items():
|
||||
|
|
@ -956,9 +958,7 @@ def check_tarinai_update_pipeline_boundary() -> None:
|
|||
fail("Tarinai.prototype.update residue remains in needs mixin")
|
||||
if "TarinaiItemInteractionSystem.updateOne" not in needs_items or "const foodNeed = Number(this.needRaw?.food" in needs_items:
|
||||
fail("Tarinai.prototype.interactWithItems is not a thin facade over the interaction system")
|
||||
for facade_token in ["TarinaiSunbathSystem.updateOne", "TarinaiCursorCareSystem.updateOne", "TarinaiLocalEnvironmentSystem.updateOne"]:
|
||||
if facade_token not in needs_items:
|
||||
fail(f"Tarinai prototype subsystem facade missing: {facade_token}")
|
||||
# These subsystems are now routed by the split update steps rather than the prototype needs mixin.
|
||||
interaction_context = read_text(ROOT / "js/tarinai_item_interaction_context.js")
|
||||
food_interaction = read_text(ROOT / "js/tarinai_food_interaction_system.js")
|
||||
contact_interaction = read_text(ROOT / "js/tarinai_contact_item_system.js")
|
||||
|
|
@ -1093,6 +1093,7 @@ def check_item_lifecycle_pipeline_boundary() -> None:
|
|||
"js/item_lifecycle_step_decay.js",
|
||||
"js/item_lifecycle_step_dynamic.js",
|
||||
"js/item_lifecycle_step_growth.js",
|
||||
"js/update_step_pipeline_runner.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
]
|
||||
for rel in required:
|
||||
|
|
@ -1110,6 +1111,7 @@ def check_item_lifecycle_pipeline_boundary() -> None:
|
|||
"js/item_lifecycle_step_decay.js",
|
||||
"js/item_lifecycle_step_dynamic.js",
|
||||
"js/item_lifecycle_step_growth.js",
|
||||
"js/update_step_pipeline_runner.js",
|
||||
"js/item_lifecycle_pipeline.js",
|
||||
"js/item_runtime.js",
|
||||
]
|
||||
|
|
@ -1129,6 +1131,7 @@ def check_item_lifecycle_pipeline_boundary() -> None:
|
|||
"js/item_lifecycle_step_decay.js": "global.TarinaiItemDecayLifecycleStep",
|
||||
"js/item_lifecycle_step_dynamic.js": "global.TarinaiItemDynamicLifecycleStep",
|
||||
"js/item_lifecycle_step_growth.js": "global.TarinaiItemGrowthLifecycleStep",
|
||||
"js/update_step_pipeline_runner.js": "global.TarinaiUpdateStepPipelineRunner",
|
||||
"js/item_lifecycle_pipeline.js": "global.TarinaiItemLifecyclePipeline",
|
||||
}
|
||||
for rel, token in exports.items():
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use strict";
|
||||
|
||||
const APP_VERSION = "39.15.98";
|
||||
const APP_VERSION = "39.15.101";
|
||||
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
|
||||
const v = `v=${APP_VERSION}`;
|
||||
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
|
||||
const coreScriptNames = [
|
||||
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_runtime", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
|
||||
];
|
||||
const CORE_ASSETS = [
|
||||
"./",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue