This commit is contained in:
33333-33333 2026-06-27 19:22:38 +09:00
commit 129c07183a
51 changed files with 5711 additions and 1122 deletions

View file

@ -1,5 +1,5 @@
{ {
"version": "39.00.00", "version": "39.15.18",
"css": [ "css": [
"css/base.css", "css/base.css",
"css/layout.css", "css/layout.css",
@ -22,6 +22,7 @@
"js/collision_footprint_system.js", "js/collision_footprint_system.js",
"js/mechanical_system.js", "js/mechanical_system.js",
"js/constraint_system.js", "js/constraint_system.js",
"js/physics_world_system.js",
"js/assets.js", "js/assets.js",
"js/audio.js", "js/audio.js",
"js/perf_profiler.js", "js/perf_profiler.js",

View file

@ -7,11 +7,11 @@
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=16.07.17" /> <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="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="shortcut icon" href="favicon.ico?v=16.07.17" />
<link rel="stylesheet" href="css/base.css?v=39.00.00" /> <link rel="stylesheet" href="css/base.css?v=39.15.18" />
<link rel="stylesheet" href="css/layout.css?v=39.00.00" /> <link rel="stylesheet" href="css/layout.css?v=39.15.18" />
<link rel="stylesheet" href="css/panel.css?v=39.00.00" /> <link rel="stylesheet" href="css/panel.css?v=39.15.18" />
<link rel="stylesheet" href="css/components.css?v=39.00.00" /> <link rel="stylesheet" href="css/components.css?v=39.15.18" />
<link rel="stylesheet" href="css/mobile.css?v=39.00.00" /> <link rel="stylesheet" href="css/mobile.css?v=39.15.18" />
</head> </head>
<body> <body>
<div id="loadingScreen" class="loading-screen" aria-live="polite"> <div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -189,140 +189,141 @@
</div> </div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div> <div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.00.00" defer></script> <script src="js/version.js?v=39.15.18" defer></script>
<script src="js/event_bus.js?v=39.00.00" defer></script> <script src="js/event_bus.js?v=39.15.18" defer></script>
<script src="js/registry_base.js?v=39.00.00" defer></script> <script src="js/registry_base.js?v=39.15.18" defer></script>
<script src="js/disease_registry.js?v=39.00.00" defer></script> <script src="js/disease_registry.js?v=39.15.18" defer></script>
<script src="js/sound_pack.js?v=39.00.00" defer></script> <script src="js/sound_pack.js?v=39.15.18" defer></script>
<script src="js/item_registry.js?v=39.00.00" defer></script> <script src="js/item_registry.js?v=39.15.18" defer></script>
<script src="js/data.js?v=39.00.00" defer></script> <script src="js/data.js?v=39.15.18" defer></script>
<script src="js/ground_types.js?v=39.00.00" defer></script> <script src="js/ground_types.js?v=39.15.18" defer></script>
<script src="js/input_mode_manager.js?v=39.00.00" defer></script> <script src="js/input_mode_manager.js?v=39.15.18" defer></script>
<script src="js/math.js?v=39.00.00" defer></script> <script src="js/math.js?v=39.15.18" defer></script>
<script src="js/physics_helpers.js?v=39.00.00" defer></script> <script src="js/physics_helpers.js?v=39.15.18" defer></script>
<script src="js/collision_footprint_system.js?v=39.00.00" defer></script> <script src="js/collision_footprint_system.js?v=39.15.18" defer></script>
<script src="js/mechanical_system.js?v=39.00.00" defer></script> <script src="js/mechanical_system.js?v=39.15.18" defer></script>
<script src="js/constraint_system.js?v=39.00.00" defer></script> <script src="js/constraint_system.js?v=39.15.18" defer></script>
<script src="js/assets.js?v=39.00.00" defer></script> <script src="js/physics_world_system.js?v=39.15.18" defer></script>
<script src="js/audio.js?v=39.00.00" defer></script> <script src="js/assets.js?v=39.15.18" defer></script>
<script src="js/perf_profiler.js?v=39.00.00" defer></script> <script src="js/audio.js?v=39.15.18" defer></script>
<script src="js/render.js?v=39.00.00" defer></script> <script src="js/perf_profiler.js?v=39.15.18" defer></script>
<script src="js/sim_core.js?v=39.00.00" defer></script> <script src="js/render.js?v=39.15.18" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.00.00" defer></script> <script src="js/sim_core.js?v=39.15.18" defer></script>
<script src="js/structures.js?v=39.00.00" defer></script> <script src="js/tarinai_seed_factory.js?v=39.15.18" defer></script>
<script src="js/items.js?v=39.00.00" defer></script> <script src="js/structures.js?v=39.15.18" defer></script>
<script src="js/item_type_initializers.js?v=39.00.00" defer></script> <script src="js/items.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_support.js?v=39.00.00" defer></script> <script src="js/item_type_initializers.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_runtime.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_support.js?v=39.15.18" defer></script>
<script src="js/item_update_policy.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_runtime.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.00.00" defer></script> <script src="js/item_update_policy.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_tool_system.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_ball_system.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_duplicator_system.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_pin_system.js?v=39.15.18" defer></script>
<script src="js/item_dynamic_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_zunchi_system.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.00.00" defer></script> <script src="js/item_dynamic_system.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_decay_system.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_growth_system.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_step_frame.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_step_decay.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_step_dynamic.js?v=39.15.18" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_step_growth.js?v=39.15.18" defer></script>
<script src="js/item_runtime.js?v=39.00.00" defer></script> <script src="js/item_lifecycle_pipeline.js?v=39.15.18" defer></script>
<script src="js/structure_lifecycle.js?v=39.00.00" defer></script> <script src="js/item_runtime.js?v=39.15.18" defer></script>
<script src="js/item_render_runtime.js?v=39.00.00" defer></script> <script src="js/structure_lifecycle.js?v=39.15.18" defer></script>
<script src="js/ants.js?v=39.00.00" defer></script> <script src="js/item_render_runtime.js?v=39.15.18" defer></script>
<script src="js/health.js?v=39.00.00" defer></script> <script src="js/ants.js?v=39.15.18" defer></script>
<script src="js/tarinai.js?v=39.00.00" defer></script> <script src="js/health.js?v=39.15.18" defer></script>
<script src="js/tarinai_action_spec.js?v=39.00.00" defer></script> <script src="js/tarinai.js?v=39.15.18" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.00.00" defer></script> <script src="js/tarinai_action_spec.js?v=39.15.18" defer></script>
<script src="js/tarinai_identity_social.js?v=39.00.00" defer></script> <script src="js/tarinai_behavior_state.js?v=39.15.18" defer></script>
<script src="js/tarinai_action_state.js?v=39.00.00" defer></script> <script src="js/tarinai_identity_social.js?v=39.15.18" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.00.00" defer></script> <script src="js/tarinai_action_state.js?v=39.15.18" defer></script>
<script src="js/tarinai_item_effects.js?v=39.00.00" defer></script> <script src="js/tarinai_disease_nest.js?v=39.15.18" defer></script>
<script src="js/tarinai_needs_core.js?v=39.00.00" defer></script> <script src="js/tarinai_item_effects.js?v=39.15.18" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.00.00" defer></script> <script src="js/tarinai_needs_core.js?v=39.15.18" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.00.00" defer></script> <script src="js/tarinai_behavior_text.js?v=39.15.18" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.00.00" defer></script> <script src="js/tarinai_forced_behavior.js?v=39.15.18" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.00.00" defer></script> <script src="js/tarinai_item_targeting.js?v=39.15.18" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.00.00" defer></script> <script src="js/tarinai_consumable_behavior.js?v=39.15.18" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.00.00" defer></script> <script src="js/tarinai_social_action_runtime.js?v=39.15.18" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.00.00" defer></script> <script src="js/tarinai_building_behavior.js?v=39.15.18" defer></script>
<script src="js/tarinai_needs_items.js?v=39.00.00" defer></script> <script src="js/tarinai_action_definitions.js?v=39.15.18" defer></script>
<script src="js/tarinai_forced_action_planner_system.js?v=39.00.00" defer></script> <script src="js/tarinai_needs_items.js?v=39.15.18" defer></script>
<script src="js/tarinai_action_planner_system.js?v=39.00.00" defer></script> <script src="js/tarinai_forced_action_planner_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.00.00" defer></script> <script src="js/tarinai_action_planner_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.00.00" defer></script> <script src="js/tarinai_need_planner_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.00.00" defer></script> <script src="js/tarinai_item_interaction_context.js?v=39.15.18" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.00.00" defer></script> <script src="js/tarinai_food_interaction_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.00.00" defer></script> <script src="js/tarinai_contact_item_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.00.00" defer></script> <script src="js/tarinai_item_interaction_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.00.00" defer></script> <script src="js/tarinai_sunbath_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.00.00" defer></script> <script src="js/tarinai_cursor_care_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.00.00" defer></script> <script src="js/tarinai_local_environment_system.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.00.00" defer></script> <script src="js/tarinai_social_move_life.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.00.00" defer></script> <script src="js/tarinai_update_step_frame.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.00.00" defer></script> <script src="js/tarinai_update_step_ai.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.00.00" defer></script> <script src="js/tarinai_update_step_environment.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.00.00" defer></script> <script src="js/tarinai_update_step_movement.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.00.00" defer></script> <script src="js/tarinai_update_step_health.js?v=39.15.18" defer></script>
<script src="js/tarinai_runtime.js?v=39.00.00" defer></script> <script src="js/tarinai_update_pipeline.js?v=39.15.18" defer></script>
<script src="js/tarinai_render.js?v=39.00.00" defer></script> <script src="js/tarinai_runtime.js?v=39.15.18" defer></script>
<script src="js/world.js?v=39.00.00" defer></script> <script src="js/tarinai_render.js?v=39.15.18" defer></script>
<script src="js/world_view.js?v=39.00.00" defer></script> <script src="js/world.js?v=39.15.18" defer></script>
<script src="js/family_graph.js?v=39.00.00" defer></script> <script src="js/world_view.js?v=39.15.18" defer></script>
<script src="js/world_family_social.js?v=39.00.00" defer></script> <script src="js/family_graph.js?v=39.15.18" defer></script>
<script src="js/world_combat_effects.js?v=39.00.00" defer></script> <script src="js/world_family_social.js?v=39.15.18" defer></script>
<script src="js/world_environment.js?v=39.00.00" defer></script> <script src="js/world_combat_effects.js?v=39.15.18" defer></script>
<script src="js/world_spatial_budget.js?v=39.00.00" defer></script> <script src="js/world_environment.js?v=39.15.18" defer></script>
<script src="js/world_ants_system.js?v=39.00.00" defer></script> <script src="js/world_spatial_budget.js?v=39.15.18" defer></script>
<script src="js/weather_system.js?v=39.00.00" defer></script> <script src="js/world_ants_system.js?v=39.15.18" defer></script>
<script src="js/item_update_scheduler.js?v=39.00.00" defer></script> <script src="js/weather_system.js?v=39.15.18" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.00.00" defer></script> <script src="js/item_update_scheduler.js?v=39.15.18" defer></script>
<script src="js/tarinai_update_policy.js?v=39.00.00" defer></script> <script src="js/simulation_runtime_helpers.js?v=39.15.18" defer></script>
<script src="js/simulation_environment_system.js?v=39.00.00" defer></script> <script src="js/tarinai_update_policy.js?v=39.15.18" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.00.00" defer></script> <script src="js/simulation_environment_system.js?v=39.15.18" defer></script>
<script src="js/simulation_effects_system.js?v=39.00.00" defer></script> <script src="js/simulation_item_ant_system.js?v=39.15.18" defer></script>
<script src="js/simulation_creature_system.js?v=39.00.00" defer></script> <script src="js/simulation_effects_system.js?v=39.15.18" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.00.00" defer></script> <script src="js/simulation_creature_system.js?v=39.15.18" defer></script>
<script src="js/simulation_ambient_system.js?v=39.00.00" defer></script> <script src="js/simulation_maintenance_system.js?v=39.15.18" defer></script>
<script src="js/simulation_systems.js?v=39.00.00" defer></script> <script src="js/simulation_ambient_system.js?v=39.15.18" defer></script>
<script src="js/world_update_phases.js?v=39.00.00" defer></script> <script src="js/simulation_systems.js?v=39.15.18" defer></script>
<script src="js/system_order.js?v=39.00.00" defer></script> <script src="js/world_update_phases.js?v=39.15.18" defer></script>
<script src="js/simulation.js?v=39.00.00" defer></script> <script src="js/system_order.js?v=39.15.18" defer></script>
<script src="js/world_update.js?v=39.00.00" defer></script> <script src="js/simulation.js?v=39.15.18" defer></script>
<script src="js/world_tool_actions.js?v=39.00.00" defer></script> <script src="js/world_update.js?v=39.15.18" defer></script>
<script src="js/world_placement_log.js?v=39.00.00" defer></script> <script src="js/world_tool_actions.js?v=39.15.18" defer></script>
<script src="js/world_event_effects.js?v=39.00.00" defer></script> <script src="js/world_placement_log.js?v=39.15.18" defer></script>
<script src="js/command_dispatcher.js?v=39.00.00" defer></script> <script src="js/world_event_effects.js?v=39.15.18" defer></script>
<script src="js/text_catalog.js?v=39.00.00" defer></script> <script src="js/command_dispatcher.js?v=39.15.18" defer></script>
<script src="js/ui.js?v=39.00.00" defer></script> <script src="js/text_catalog.js?v=39.15.18" defer></script>
<script src="js/ui_log.js?v=39.00.00" defer></script> <script src="js/ui.js?v=39.15.18" defer></script>
<script src="js/ui_helpers.js?v=39.00.00" defer></script> <script src="js/ui_log.js?v=39.15.18" defer></script>
<script src="js/ui_selected.js?v=39.00.00" defer></script> <script src="js/ui_helpers.js?v=39.15.18" defer></script>
<script src="js/save_schema.js?v=39.00.00" defer></script> <script src="js/ui_selected.js?v=39.15.18" defer></script>
<script src="js/snapshot_system.js?v=39.00.00" defer></script> <script src="js/save_schema.js?v=39.15.18" defer></script>
<script src="js/restore_coordinator.js?v=39.00.00" defer></script> <script src="js/snapshot_system.js?v=39.15.18" defer></script>
<script src="js/history_system.js?v=39.00.00" defer></script> <script src="js/restore_coordinator.js?v=39.15.18" defer></script>
<script src="js/save_codec.js?v=39.00.00" defer></script> <script src="js/history_system.js?v=39.15.18" defer></script>
<script src="js/save_storage.js?v=39.00.00" defer></script> <script src="js/save_codec.js?v=39.15.18" defer></script>
<script src="js/save_system.js?v=39.00.00" defer></script> <script src="js/save_storage.js?v=39.15.18" defer></script>
<script src="js/patch_helpers.js?v=39.00.00" defer></script> <script src="js/save_system.js?v=39.15.18" defer></script>
<script src="js/ui_tooltips.js?v=39.00.00" defer></script> <script src="js/patch_helpers.js?v=39.15.18" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.00.00" defer></script> <script src="js/ui_tooltips.js?v=39.15.18" defer></script>
<script src="js/ui_ground.js?v=39.00.00" defer></script> <script src="js/ui_layout_dialogs.js?v=39.15.18" defer></script>
<script src="js/ui_tools.js?v=39.00.00" defer></script> <script src="js/ui_ground.js?v=39.15.18" defer></script>
<script src="js/ui_input_shared.js?v=39.00.00" defer></script> <script src="js/ui_tools.js?v=39.15.18" defer></script>
<script src="js/ui_input_touch.js?v=39.00.00" defer></script> <script src="js/ui_input_shared.js?v=39.15.18" defer></script>
<script src="js/ui_input_mouse.js?v=39.00.00" defer></script> <script src="js/ui_input_touch.js?v=39.15.18" defer></script>
<script src="js/ui_bind.js?v=39.00.00" defer></script> <script src="js/ui_input_mouse.js?v=39.15.18" defer></script>
<script src="js/ui_charts.js?v=39.00.00" defer></script> <script src="js/ui_bind.js?v=39.15.18" defer></script>
<script src="js/ui_family_data.js?v=39.00.00" defer></script> <script src="js/ui_charts.js?v=39.15.18" defer></script>
<script src="js/ui_family_async.js?v=39.00.00" defer></script> <script src="js/ui_family_data.js?v=39.15.18" defer></script>
<script src="js/ui_family_layout.js?v=39.00.00" defer></script> <script src="js/ui_family_async.js?v=39.15.18" defer></script>
<script src="js/ui_family_paths.js?v=39.00.00" defer></script> <script src="js/ui_family_layout.js?v=39.15.18" defer></script>
<script src="js/ui_family_render.js?v=39.00.00" defer></script> <script src="js/ui_family_paths.js?v=39.15.18" defer></script>
<script src="js/main.js?v=39.00.00" defer></script> <script src="js/ui_family_render.js?v=39.15.18" defer></script>
<script src="js/debug_tools.js?v=39.00.00" defer></script> <script src="js/main.js?v=39.15.18" defer></script>
<script src="js/debug_tools.js?v=39.15.18" defer></script>
</body> </body>
</html> </html>

View file

@ -164,7 +164,8 @@
function hitTestItem(worldRef, item, x, y, opts = {}) { function hitTestItem(worldRef, item, x, y, opts = {}) {
if (!item || item.dead) return { hit: false, distance: Infinity }; if (!item || item.dead) return { hit: false, distance: Infinity };
const padding = Math.max(0, num(opts.padding, 0)); const padding = Math.max(0, num(opts.padding, 0));
const rects = opts.rects || worldRef?.solidObstacleRects?.(item) || []; let rects = opts.rects || worldRef?.solidObstacleRects?.(item) || [];
if ((!rects || !rects.length) && item?.type === "poison_block") rects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(item) || [];
let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y)); let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y));
let hit = false; let hit = false;
if (rects.length) { if (rects.length) {
@ -175,7 +176,7 @@
} }
const mechHit = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type); const mechHit = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type);
if (mechHit) { if (mechHit) {
const hub = Math.max(18, Math.min(40, num(item.rotatorThickness, 12) * 2.3)); const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) ?? 12) * 2.3));
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true; if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
} else if (!rects.length) { } else if (!rects.length) {
const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2); const mul = num(opts.radiusMultiplier, item.type === "ball" ? 4.0 : 2.2);
@ -187,7 +188,7 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true; if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "nest_box", "water"]); const important = opts.important || new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "water"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type))); const isPhysicsItem = (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || item.type === "rope" || item.type === "rod" || (typeof isFenceItemType === "function" && isFenceItemType(item.type)));
if (!important.has(item.type) && !isPhysicsItem) return false; if (!important.has(item.type) && !isPhysicsItem) return false;
const placingGrass = item.type === "grass"; const placingGrass = item.type === "grass";
@ -197,6 +198,11 @@
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) { for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) {
if (!other || other === item || other.dead) continue; if (!other || other === item || other.dead) continue;
if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue; if (isPhysicsItem && (other.type === "water" || other.type === "grass_bed")) continue;
if (other.type === "poison_block") {
const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(other, worldRef, { syncFromLegacy: false });
const solid = body?.collision ? body.collision.solid !== false : true;
if (!solid) continue;
}
if (!important.has(other.type)) continue; if (!important.has(other.type)) continue;
if (!placingGrass && other.type === "grass") continue; if (!placingGrass && other.type === "grass") continue;
const otherRects = worldRef.solidObstacleRects?.(other) || []; const otherRects = worldRef.solidObstacleRects?.(other) || [];

File diff suppressed because it is too large Load diff

View file

@ -4,47 +4,174 @@
const params = new URLSearchParams(location.search || ""); const params = new URLSearchParams(location.search || "");
const enabled = params.get("debug") === "1" || params.get("debug") === "true"; const enabled = params.get("debug") === "1" || params.get("debug") === "true";
if (!enabled) return; if (!enabled) return;
const panel = document.createElement("div");
panel.id = "debugOverlay"; const host = document.createElement("div");
panel.style.cssText = [ host.id = "debugOverlay";
"position:fixed", "left:8px", "top:76px", "z-index:99999", "min-width:230px", "max-width:360px", host.style.cssText = [
"font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", "position:fixed", "left:8px", "top:76px", "z-index:99999", "width:min(620px, calc(100vw - 16px))", "max-height:calc(100vh - 92px)",
"background:rgba(18,24,30,.78)", "border:1px solid rgba(255,255,255,.18)", "border-radius:10px", "overflow:hidden", "font:11px/1.42 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff",
"padding:8px 10px", "pointer-events:none", "white-space:pre-wrap", "box-shadow:0 8px 24px rgba(0,0,0,.22)" "background:rgba(18,24,30,.86)", "border:1px solid rgba(255,255,255,.18)", "border-radius:10px",
"pointer-events:auto", "box-shadow:0 8px 24px rgba(0,0,0,.22)"
].join(";"); ].join(";");
document.addEventListener("DOMContentLoaded", () => document.body.appendChild(panel));
const bar = document.createElement("div");
bar.style.cssText = [
"display:flex", "gap:6px", "align-items:center", "padding:6px 8px", "border-bottom:1px solid rgba(255,255,255,.14)",
"background:rgba(255,255,255,.06)", "user-select:none", "-webkit-user-select:none"
].join(";");
const title = document.createElement("span");
title.style.cssText = "flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;";
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.textContent = "Copy";
const minBtn = document.createElement("button");
minBtn.type = "button";
minBtn.textContent = "Min";
for (const btn of [copyBtn, minBtn]) {
btn.style.cssText = [
"font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace", "color:#f7fbff", "background:rgba(255,255,255,.12)",
"border:1px solid rgba(255,255,255,.22)", "border-radius:6px", "padding:3px 7px", "cursor:pointer"
].join(";");
}
bar.append(title, copyBtn, minBtn);
const body = document.createElement("pre");
body.style.cssText = [
"margin:0", "padding:8px 10px", "max-height:calc(100vh - 128px)", "overflow:auto", "white-space:pre-wrap",
"user-select:text", "-webkit-user-select:text", "cursor:text"
].join(";");
body.tabIndex = 0;
host.append(bar, body);
document.addEventListener("DOMContentLoaded", () => document.body.appendChild(host));
let copiedMessageUntil = 0;
let minimized = false;
let lastText = "";
function selectBodyText() {
const range = document.createRange();
range.selectNodeContents(body);
const selection = window.getSelection?.();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
copyBtn.addEventListener("click", async (ev) => {
ev.preventDefault();
const text = lastText || body.textContent || "";
try {
await navigator.clipboard?.writeText?.(text);
copiedMessageUntil = Date.now() + 1400;
copyBtn.textContent = "Copied";
} catch (_) {
selectBodyText();
copiedMessageUntil = Date.now() + 1400;
copyBtn.textContent = "Select";
}
});
minBtn.addEventListener("click", (ev) => {
ev.preventDefault();
minimized = !minimized;
body.style.display = minimized ? "none" : "block";
host.style.width = minimized ? "min(360px, calc(100vw - 16px))" : "min(620px, calc(100vw - 16px))";
minBtn.textContent = minimized ? "Open" : "Min";
});
let lastEvents = []; let lastEvents = [];
let lastAudio = []; let lastAudio = [];
window.TarinaiEvents?.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; }); window.TarinaiEvents?.on("*", ev => { lastEvents.unshift(ev.type); if (lastEvents.length > 10) lastEvents.length = 10; });
window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; }); window.TarinaiEvents?.on("audio:play", ev => { lastAudio.unshift(`${ev.detail?.id || "?"}:${ev.detail?.category || "?"}`); if (lastAudio.length > 8) lastAudio.length = 8; });
function fmtMs(v) {
const n = Number(v || 0);
return n >= 10 ? n.toFixed(1) : n.toFixed(2);
}
function shortLabel(label) {
return String(label || "?")
.replace(/^update\.phase\./, "phase.")
.replace(/^update\./, "u.")
.replace(/^render\./, "r.")
.replace(/^frame\./, "f.");
}
function topMap(map, limit = 4) {
return Object.entries(map || {}).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([k, v]) => `${k}:${v}`).join(", ") || "-";
}
function perfLines(perf) {
const self = (perf.topSelf || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 10);
const incl = (perf.topInclusive || perf.entries || []).filter(e => e.label !== "frame.total").slice(0, 6);
const selfLines = self.map((e, i) => `${String(i + 1).padStart(2, " ")}. ${shortLabel(e.label).padEnd(28, " ")} self ${fmtMs(e.selfAvg)}ms total ${fmtMs(e.avg)}ms ${e.selfPct || 0}%`);
const inclLine = incl.map(e => `${shortLabel(e.label)}:${fmtMs(e.avg)}`).join(" ");
return [
`heavy self avg`,
...(selfLines.length ? selfLines : [" -"]),
`heavy inclusive ${inclLine || "-"}`,
];
}
function diagnosticHints(diag, perf) {
const hints = [];
const entries = perf.topSelf || perf.entries || [];
const top = entries[0];
if (top && top.selfAvg >= 4) hints.push(`hot:${shortLabel(top.label)} ${fmtMs(top.selfAvg)}ms self`);
if ((diag.spatial?.rebuildsThisFrame || 0) > 1) hints.push(`spatial rebuild/frame ${diag.spatial.rebuildsThisFrame}`);
if ((diag.creatures?.smooth || 0) > 0) hints.push(`smooth motion ${diag.creatures.smooth}`);
if ((diag.scheduler?.activeRealtime || 0) > 120) hints.push(`realtime items ${diag.scheduler.activeRealtime}`);
if ((diag.scheduler?.heap || 0) > 700) hints.push(`scheduled heap ${diag.scheduler.heap}`);
if ((diag.items?.bucketRebuildsTotal || 0) > 0 && diag.items?.bucketsDirty) hints.push("item buckets dirty");
const m = diag.scheduler?.mechanicalStats || diag.items?.mechanicalStats || diag.scheduler?.physics?.mechanical || null;
if (m?.candidates > 250 || m?.pairs > 180) hints.push(`mechanical candidates ${m.candidates || "?"} pairs ${m.pairs || m.solved || "?"}`);
const c = diag.scheduler?.constraintStats || null;
if (c?.totalLinks > 80) hints.push(`constraints links ${c.totalLinks} ran ${c.ran}`);
return hints.join(" ") || "-";
}
window.setInterval(() => { window.setInterval(() => {
const w = window.world; const w = window.world;
const input = window.TarinaiInputMode?.snapshot?.() || {}; const input = window.TarinaiInputMode?.snapshot?.() || {};
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {}; const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
const behaviorCounts = diag.behavior?.counts || {}; const behaviorCounts = diag.behavior?.counts || {};
const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", "); const topBehaviors = topMap(behaviorCounts, 5);
const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {}; const perf = diag.perf || window.TarinaiPerf?.snapshot?.() || {};
const perfTop = (perf.entries || []).slice(0, 5).map(e => `${e.label}:${e.avg}ms`).join(", "); const metrics = perf.metrics || {};
panel.textContent = [ const currentDpr = (typeof uiCache !== "undefined" && uiCache) ? uiCache.canvasDpr : "?";
`tarinai v${window.TARINAI_VERSION || "?"}`, const creature = diag.creatures || {};
`fps ${window.__tarinaiFps ?? "?"}`, const scheduler = diag.scheduler || {};
`live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} dpr ${diag.runtime?.dpr || uiCache?.canvasDpr || "?"}`, const terrain = diag.terrain || {};
`items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`, const lines = [
`effects ${(w?.effects || []).length}`, `tarinai v${window.TARINAI_VERSION || "?"} debug profiler:${perf.enabled ? "on" : "off"}${Date.now() < copiedMessageUntil ? " copied" : " copy:button/select"}`,
`terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`, `fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms quality ${perf.tier || "?"} dprScale:${perf.dprScale || 1}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`, `live ${diag.runtime?.liveTarinai ?? (w?.tarinai || []).length} items ${(w?.items || []).length} ants ${(w?.ants || []).length} effects ${(w?.effects || []).length}`,
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`, `visible layered:${metrics["render.visibleLayered"] ?? "?"} back:${metrics["render.visibleBackItems"] ?? "?"} effects:${metrics["render.visibleEffects"] ?? "?"}`,
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`, `creatures full:${creature.full ?? 0} realtime:${creature.realtime ?? 0} smooth:${creature.smooth ?? 0} skipped:${creature.skipped ?? 0} selected:${creature.selected ?? 0} urgent:${creature.urgent ?? 0} visible:${creature.visible ?? 0} sleepPhys:${creature.sleepPhysical ?? 0} passive:${creature.passivePhysical ?? 0}`,
`creature lanes ${topMap(creature.lanes, 6)} smoothStates ${topMap(creature.smoothStates, 4)}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} partial:${diag.spatial?.partialRebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} deferred:${diag.spatial?.deferredReads ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`,
`nearby obstacleRects hit:${diag.nearby?.obstacleRectHits ?? 0} miss:${diag.nearby?.obstacleRectMisses ?? 0} built:${diag.nearby?.obstacleRectBuilt ?? 0} bypass:${diag.nearby?.obstacleRectBypass ?? 0} constraintDirty mark:${diag.constraintDirty?.marked ?? 0} coal:${diag.constraintDirty?.coalesced ?? 0}`,
`scheduler heap:${scheduler.heap ?? 0} realtime:${scheduler.activeRealtime ?? scheduler.realtime ?? 0} ran:${scheduler.lastRan ?? 0} itemRt:${scheduler.realtimeRan ?? 0} itemDue:${scheduler.dueRan ?? 0} constraints:${scheduler.constraints ?? 0} postPairs:${scheduler.postConstraintPairs ?? 0} rodStretch:${fmtMs(scheduler.constraintStats?.rodStretchMax || 0)} corr:${scheduler.constraintStats?.rodCorrections ?? 0}`,
`items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"}`,
`terrain dirty raw:${terrain.raw ?? 0} global:${terrain.global ?? 0} chunk:${terrain.chunk ?? 0} coalesced:${terrain.coalesced ?? 0} reasons:${topMap(terrain.reasons, 4)}`,
`work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`, `work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`,
`scheduler heap:${diag.scheduler?.heap ?? 0} realtime:${diag.scheduler?.realtime ?? 0} ran:${diag.scheduler?.lastRan ?? 0} rebuilds:${diag.scheduler?.rebuilds ?? 0}`, `hints ${diagnosticHints(diag, perf)}`,
`perf ${perf.tier || "?"} dprScale:${perf.dprScale || 1} ${perfTop || "-"}`, ...perfLines(perf),
`behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`, `behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`,
`dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`, `terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0} drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)}`,
`sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`, `item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} bucketRebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`, `dpr ${currentDpr} cache ${window.TARINAI_APP?.cacheName || "?"} sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"}`,
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)}`,
`audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`, `audio ${lastAudio.join(", ") || window.TarinaiAudio?.lastPlayedId || "-"}`,
`effects ${window.TarinaiItemRegistry?.effect?.debugSnapshot?.().length || 0} registered`,
`events ${lastEvents.join(", ")}`, `events ${lastEvents.join(", ")}`,
].join("\n"); ];
lastText = lines.join("\n");
title.textContent = minimized
? `tarinai v${window.TARINAI_VERSION || "?"} fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms`
: `tarinai debug fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms`;
if (Date.now() >= copiedMessageUntil && copyBtn.textContent !== "Copy") copyBtn.textContent = "Copy";
if (!minimized) body.textContent = lastText;
}, 500); }, 500);
})(); })();

View file

@ -11,6 +11,7 @@ const HEALTH = (() => {
poke: "\u3064\u3064\u304b\u308c\u3059\u304e", poke: "\u3064\u3064\u304b\u308c\u3059\u304e",
firecracker: "\u7206\u7af9", firecracker: "\u7206\u7af9",
genkotsu: "\u3052\u3093\u3053\u3064", genkotsu: "\u3052\u3093\u3053\u3064",
poisonBlock: "毒ブロック",
accident: "\u4e8b\u6545", accident: "\u4e8b\u6545",
collision: "\u885d\u7a81", collision: "\u885d\u7a81",
outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664", outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664",
@ -31,6 +32,7 @@ const HEALTH = (() => {
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
if (/\u3052\u3093\u3053\u3064|genkotsu/.test(text)) return CAUSES.genkotsu; if (/\u3052\u3093\u3053\u3064|genkotsu/.test(text)) return CAUSES.genkotsu;
if (/毒ブロック|poison.?block/.test(text)) return CAUSES.poisonBlock;
if (/\u5de8\u5927\u85ac|giant/.test(text)) return CAUSES.giantDrug; if (/\u5de8\u5927\u85ac|giant/.test(text)) return CAUSES.giantDrug;
if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug; if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug;
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Layer: entity-runtime/items/dynamic-system // Layer: entity-runtime/items/dynamic-system
// Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach // Owns pushpin/oshibyo item motion, attachment, lodged damage, and detach
@ -85,7 +115,7 @@
} else { } else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3); t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
} }
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2"); if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
@ -127,12 +157,12 @@
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2"); if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); if (deterministicChance(worldRef, "pin-pain-bubble", 0.22, item, t)) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
} }
t.sleeping = false; t.sleeping = false;
if (t.enterPanic) t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" }); if (t.enterPanic) t.enterPanic({ threat: item, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
else { else {
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(item) : { x: t.x + deterministicRange(worldRef, "pin-panic-target-x", -80, 80, item, t), y: t.y + deterministicRange(worldRef, "pin-panic-target-y", -80, 80, item, t) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, 0.65); t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4); t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
@ -140,7 +170,7 @@
item.pinFallCheckTimer = (item.pinFallCheckTimer || 0) + dt; item.pinFallCheckTimer = (item.pinFallCheckTimer || 0) + dt;
while (item.pinFallCheckTimer >= 1.0) { while (item.pinFallCheckTimer >= 1.0) {
item.pinFallCheckTimer -= 1.0; item.pinFallCheckTimer -= 1.0;
if (Math.random() < 0.10) { if (deterministicChance(worldRef, "pin-fall-out", 0.10, item, t)) {
detach(item, worldRef, "fall"); detach(item, worldRef, "fall");
return; return;
} }
@ -174,13 +204,13 @@
item.vy = 0; item.vy = 0;
item.spinVelocity = 0; item.spinVelocity = 0;
} else { } else {
item.vx = rand(-24, 24); item.vx = deterministicRange(worldRef, "pin-detach-vx", -24, 24, item, t, reason);
item.vy = rand(-10, 18); item.vy = deterministicRange(worldRef, "pin-detach-vy", -10, 18, item, t, reason);
item.spinVelocity = rand(-1.6, 1.6); item.spinVelocity = deterministicRange(worldRef, "pin-detach-spin-velocity", -1.6, 1.6, item, t, reason);
item.spin += rand(-0.25, 0.25); item.spin += deterministicRange(worldRef, "pin-detach-spin", -0.25, 0.25, item, t, reason);
if (t) { if (t) {
item.x = clamp(t.x + rand(-(t.radius || 16) * 0.75, (t.radius || 16) * 0.75), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30)); item.x = clamp(t.x + deterministicRange(worldRef, "pin-detach-x", -(t.radius || 16) * 0.75, (t.radius || 16) * 0.75, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.w || item.x) - (CONFIG.worldPadding || 30));
item.y = clamp(t.y + rand((t.radius || 16) * 0.12, (t.radius || 16) * 0.72), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30)); item.y = clamp(t.y + deterministicRange(worldRef, "pin-detach-y", (t.radius || 16) * 0.12, (t.radius || 16) * 0.72, item, t, reason), CONFIG.worldPadding || 30, (worldRef?.h || item.y) - (CONFIG.worldPadding || 30));
} }
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" })); worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(14, (item.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] }); if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });

View file

@ -10,6 +10,7 @@
case "firecracker": case "firecracker":
case "genkotsu": case "genkotsu":
case "rotator": case "rotator":
case "poison_block":
case "reciprocator": case "reciprocator":
case "gate_fence": case "gate_fence":
case "rope": case "rope":

View file

@ -35,6 +35,10 @@
return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef)); return Boolean(global.TarinaiMechanicalSystem?.updateReciprocator?.(item, dt, worldRef));
} }
function updatePoisonBlock(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.updatePoisonBlock?.(item, dt, worldRef));
}
function resolveMechanicalInteractions(item, dt, worldRef) { function resolveMechanicalInteractions(item, dt, worldRef) {
return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef)); return Boolean(global.TarinaiMechanicalSystem?.resolveInteractions?.(item, dt, worldRef));
} }
@ -60,6 +64,7 @@
if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef); if (item.type === "firecracker") return updateFirecracker(item, dt, worldRef);
if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef); if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef);
if (item.type === "rotator") return updateRotator(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 === "reciprocator") return updateReciprocator(item, dt, worldRef);
if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef); if (item.type === "gate_fence") return updateGateFence(item, dt, worldRef);
if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef); if (item.type === "rope") return updateFlexibleLink(item, dt, worldRef);
@ -73,6 +78,7 @@
updateGenkotsu, updateGenkotsu,
updateRotator, updateRotator,
updateReciprocator, updateReciprocator,
updatePoisonBlock,
updateFlexibleLink, updateFlexibleLink,
updateRigidLink, updateRigidLink,
resolveMechanicalInteractions, resolveMechanicalInteractions,

View file

@ -8,13 +8,13 @@
const DUPLICATOR_STORABLE_TYPES = new Set([ const DUPLICATOR_STORABLE_TYPES = new Set([
"food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug",
"laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug",
"zunda_juice", "grass", "water", "pushpin", "oshibyo" "zunda_juice", "water", "pushpin", "oshibyo"
]); ]);
function duplicatorLoadTypeForItem(item) { function duplicatorLoadTypeForItem(item) {
if (!item || item.dead || item.type === "duplicator") return ""; if (!item || item.dead || item.type === "duplicator") return "";
const type = String(item.type || ""); const type = String(item.type || "");
if (!type || type === "water_bowl") return ""; if (!type || type === "water_bowl" || type === "grass") return "";
if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return ""; if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return "";
// Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が
// serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Unified item definition source: tools, item traits, visuals, food metadata, and item effects. // Unified item definition source: tools, item traits, visuals, food metadata, and item effects.
@ -10,11 +40,11 @@ const TOOL_DEFINITIONS = Object.freeze({
undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" }, undo: { id: "undo", label: "戻す", placeable: false, scalable: false, iconText: "↶", tooltip: "直前の操作を取り消す。" },
redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" }, redo: { id: "redo", label: "進む", placeable: false, scalable: false, iconText: "↷", tooltip: "取り消した操作をやり直す。" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" }, pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや一部の置きものを移動させる。" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" }, rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" },
rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。距離を常に一定に保つ。" }, rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。" },
zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" },
sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" },
love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" },
@ -48,6 +78,7 @@ const TOOL_DEFINITIONS = Object.freeze({
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" }, rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" },
poison_block: { id: "poison_block", itemType: "poison_block", label: "毒ブロック", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "☠", collisionShape: "custom_poison_block", tooltip: "自由に形を描ける毒の物理ブロック。外力で動き、触れたたりないにダメージ。当たり判定ON/OFFを切り替えられる。" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" }, reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" },
water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" }, water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" },
trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
@ -58,20 +89,20 @@ const TOOL_DEFINITIONS = Object.freeze({
const ITEM_TRAITS = Object.freeze({ const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]), obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h", "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", "sleep_drug"]), 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", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]), pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box"]), sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]), draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
}); });
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"])); const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "reciprocator"])); const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"])); const LINK_ITEM_TYPES = Object.freeze(new Set(["rope", "rod"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "reciprocator"]); const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES])); 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 isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type || "")); }
function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); } function isMechanicalItemType(type = "") { return MECHANICAL_ITEM_TYPES.has(String(type || "")); }
@ -493,7 +524,7 @@ function toolTipsFromDefinitions() {
Object.assign(rawDefinitions.love_mochi, { Object.assign(rawDefinitions.love_mochi, {
onApply(tarinai, context = {}) { onApply(tarinai, context = {}) {
const scale = context.scale ?? 1; const scale = context.scale ?? 1;
tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + deterministicRange(tarinai.world, "love-mochi-duration", 4, 12, tarinai, context.item));
const nutrition = Number(context.nutrition) || 0; const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") { if (context.source === "eating") {
@ -506,7 +537,7 @@ function toolTipsFromDefinitions() {
Object.assign(rawDefinitions.fight_mochi, { Object.assign(rawDefinitions.fight_mochi, {
onApply(tarinai, context = {}) { onApply(tarinai, context = {}) {
const scale = context.scale ?? 1; const scale = context.scale ?? 1;
tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + deterministicRange(tarinai.world, "fight-mochi-duration", 10, 18, tarinai, context.item));
const nutrition = Number(context.nutrition) || 0; const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") { if (context.source === "eating") {
@ -529,7 +560,7 @@ function toolTipsFromDefinitions() {
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" }); else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; if (!tarinai.sleepDisease && deterministicChance(tarinai.world, "sleep-drug-disease", Math.min(0.98, sleepChance), tarinai, context.item)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true; return true;
} }
return tarinai.infectSleepDisease?.(context.item || null) || true; return tarinai.infectSleepDisease?.(context.item || null) || true;

View file

@ -2,6 +2,72 @@
// Item field rendering. Kept separate from construction and lifecycle logic. // Item field rendering. Kept separate from construction and lifecycle logic.
function segmentPathCacheKey(item, segments, extra = "") {
const version = Number(item?._mechanicalShapeVersion || item?._segmentsVersion || 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?.() || "high";
}
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, { Object.assign(Item.prototype, {
draw(ctx, t, lighting = null) { draw(ctx, t, lighting = null) {
@ -549,10 +615,12 @@ draw(ctx, t, lighting = null) {
ctx.restore(); ctx.restore();
} else if (this.type === "rotator") { } else if (this.type === "rotator") {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const powered = this.rotatorPowered !== false; const pb = window.TarinaiPhysicsBodySystem;
const speed = Number((powered ? this.rotatorSpeed : this.rotatorAngularVelocity) || 0) || 0; const powered = pb?.scalar?.(this, "motorOn", true) !== false;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]])); const speed = Number((powered ? pb?.scalar?.(this, "motorSpeed", 0) : pb?.scalar?.(this, "spin", 0)) || 0) || 0;
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12)); const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0], [0, -52, 0, 52]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
const tier = mechanicalRenderTier();
ctx.save(); ctx.save();
ctx.rotate(angle); ctx.rotate(angle);
ctx.lineCap = "round"; ctx.lineCap = "round";
@ -561,24 +629,14 @@ draw(ctx, t, lighting = null) {
ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5"; ctx.strokeStyle = styleNight ? "#6e5f89" : "#8b6cc5";
ctx.lineWidth = thick; ctx.lineWidth = thick;
ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)"; ctx.shadowColor = powered ? (styleNight ? "rgba(116,92,170,0.38)" : "rgba(126,82,190,0.30)") : "rgba(88,80,96,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28); ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28));
ctx.beginPath(); strokeSegments(ctx, this, segments, "body");
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();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)"; if (tier !== "low") {
ctx.lineWidth = Math.max(1.4, thick * 0.22); ctx.strokeStyle = powered ? (styleNight ? "rgba(236,220,255,0.44)" : "rgba(255,255,255,0.46)") : "rgba(255,255,255,0.28)";
ctx.beginPath(); ctx.lineWidth = Math.max(1.4, thick * 0.22);
for (const seg of segments) { strokeSegments(ctx, this, segments, "body");
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();
ctx.restore(); ctx.restore();
ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef"); ctx.fillStyle = powered ? (styleNight ? "#3c324e" : "#f4eaff") : (styleNight ? "#34343a" : "#ece8ef");
ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)"; ctx.strokeStyle = powered ? (styleNight ? "#a28cc8" : "#6e4fa4") : "rgba(92,88,98,0.82)";
@ -591,20 +649,59 @@ draw(ctx, t, lighting = null) {
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0); ctx.fillText(powered ? (speed >= 0 ? "↻" : "↺") : "○", 0, 0);
} else if (this.type === "poison_block") {
const angle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const pb = window.TarinaiPhysicsBodySystem;
const enabled = pb?.scalar?.(this, "solid", true) !== false;
const segments = (pb?.segments?.(this) || [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 14) || 14) || 14));
const tier = mechanicalRenderTier();
ctx.save();
ctx.rotate(angle);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha *= enabled ? 1 : 0.48;
if (!enabled) ctx.setLineDash([10, 7]);
ctx.strokeStyle = styleNight ? "#4d8e4e" : "#5cac4d";
ctx.lineWidth = thick;
ctx.shadowColor = enabled ? (styleNight ? "rgba(92,190,86,0.34)" : "rgba(62,170,54,0.28)") : "rgba(70,120,70,0.16)";
ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.18) : Math.max(4, thick * 0.32));
strokeSegments(ctx, this, segments, "body");
ctx.shadowBlur = 0;
ctx.setLineDash([]);
if (tier !== "low") {
ctx.strokeStyle = enabled ? (styleNight ? "rgba(226,255,218,0.46)" : "rgba(255,255,255,0.50)") : "rgba(245,255,236,0.28)";
ctx.lineWidth = Math.max(1.4, thick * 0.22);
strokeSegments(ctx, this, segments, "body");
}
ctx.restore();
ctx.fillStyle = enabled ? (styleNight ? "#263b27" : "#efffe9") : (styleNight ? "#303934" : "#eef4e8");
ctx.strokeStyle = enabled ? (styleNight ? "#80c56e" : "#3e8f35") : "rgba(78,118,74,0.72)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(0, 0, Math.max(8, thick * 0.74), 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = enabled ? (styleNight ? "#d8ffd0" : "#26711f") : "rgba(70,92,68,0.70)";
ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(enabled ? "毒" : "透", 0, 0);
} else if (this.type === "reciprocator") { } else if (this.type === "reciprocator") {
const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0);
const axisAngle = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(this) ?? (Number.isFinite(Number(this.reciprocatorAxisAngle)) ? Number(this.reciprocatorAxisAngle) : bodyAngle); const pb = window.TarinaiPhysicsBodySystem;
const powered = this.reciprocatorPowered !== false; const axisAngle = window.TarinaiMechanicalSystem?.railAxisAngle?.(this) ?? pb?.scalar?.(this, "railAxis", bodyAngle) ?? bodyAngle;
const thick = Math.max(4, Math.min(34, Number(this.rotatorThickness || 12) || 12)); const powered = pb?.scalar?.(this, "railOn", true) !== false;
const travel = Math.max(24, Number(this.reciprocatorTravel || 150) || 150); const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 12) || 12) || 12));
const tier = mechanicalRenderTier();
const travel = Math.max(24, Number(pb?.scalar?.(this, "railTravel", 150) || 150) || 150);
const halfTravel = travel * 0.5; const halfTravel = travel * 0.5;
const phase = Math.max(-1, Math.min(1, Number(this.reciprocatorPhase || 0) || 0)); const phase = Math.max(-1, Math.min(1, Number(pb?.scalar?.(this, "railPhase", 0) || 0) || 0));
const reciprocatorDir = Math.sign(Number(this.reciprocatorDirection || 1) || 1) || 1; const reciprocatorDir = Math.sign(Number(pb?.scalar?.(this, "railDir", 1) || 1) || 1) || 1;
const passiveVelocity = Number(this.reciprocatorVelocity || 0) || 0; const passiveVelocity = Number(pb?.scalar?.(this, "slideSpeed", 0) || 0) || 0;
const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(this.reciprocatorSpeed || 0) || 0) : 0; const motorVelocity = powered ? reciprocatorDir * Math.max(0, Number(pb?.scalar?.(this, "railMotorSpeed", 0) || 0) || 0) : 0;
const visualVelocity = motorVelocity + passiveVelocity; const visualVelocity = motorVelocity + passiveVelocity;
const visualDirection = Math.abs(visualVelocity) > 0.8 ? Math.sign(visualVelocity) : reciprocatorDir; const visualDirection = Math.abs(visualVelocity) > 0.8 ? Math.sign(visualVelocity) : reciprocatorDir;
const segments = (world?.rotatorSegments?.(this) || (Array.isArray(this.rotatorSegments) ? this.rotatorSegments : [[-78, 0, 78, 0]])); const segments = (pb?.segments?.(this) || [[-78, 0, 78, 0]]);
ctx.save(); ctx.save();
ctx.rotate(axisAngle); ctx.rotate(axisAngle);
ctx.save(); ctx.save();
@ -631,24 +728,14 @@ draw(ctx, t, lighting = null) {
ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf"; ctx.strokeStyle = styleNight ? "#4f7192" : "#6da6cf";
ctx.lineWidth = thick; ctx.lineWidth = thick;
ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)"; ctx.shadowColor = powered ? (styleNight ? "rgba(86,130,180,0.34)" : "rgba(80,150,205,0.28)") : "rgba(74,82,90,0.20)";
ctx.shadowBlur = Math.max(4, thick * 0.28); ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.16) : Math.max(4, thick * 0.28));
ctx.beginPath(); strokeSegments(ctx, this, segments, "body");
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();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)"; if (tier !== "low") {
ctx.lineWidth = Math.max(1.4, thick * 0.22); ctx.strokeStyle = powered ? (styleNight ? "rgba(220,238,255,0.44)" : "rgba(255,255,255,0.48)") : "rgba(255,255,255,0.28)";
ctx.beginPath(); ctx.lineWidth = Math.max(1.4, thick * 0.22);
for (const seg of segments) { strokeSegments(ctx, this, segments, "body");
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();
ctx.restore(); ctx.restore();
ctx.fillStyle = powered ? (styleNight ? "#2d3c4d" : "#eaf6ff") : (styleNight ? "#34383e" : "#e9edf0"); ctx.fillStyle = powered ? (styleNight ? "#2d3c4d" : "#eaf6ff") : (styleNight ? "#34383e" : "#e9edf0");
ctx.strokeStyle = powered ? (styleNight ? "#7faed0" : "#4d8cba") : "rgba(82,88,94,0.82)"; ctx.strokeStyle = powered ? (styleNight ? "#7faed0" : "#4d8cba") : "rgba(82,88,94,0.82)";
@ -670,8 +757,9 @@ draw(ctx, t, lighting = null) {
} }
} else if (this.type === "rope" || this.type === "rod") { } else if (this.type === "rope" || this.type === "rod") {
const runtime = window.TarinaiLinkRuntime; const runtime = window.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(this.linkA, world); const pb = window.TarinaiPhysicsBodySystem;
const b = runtime?.endpointWorld?.(this.linkB, world); const a = runtime?.endpointWorld?.(pb?.endpoint?.(this, 0), world);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(this, 1), world);
if (a && b) { if (a && b) {
const ax = a.x - this.x, ay = a.y - this.y; const ax = a.x - this.x, ay = a.y - this.y;
const bx = b.x - this.x, by = b.y - this.y; const bx = b.x - this.x, by = b.y - this.y;
@ -692,19 +780,30 @@ draw(ctx, t, lighting = null) {
ctx.lineTo(bx, by); ctx.lineTo(bx, by);
ctx.stroke(); ctx.stroke();
} else { } else {
const mx = Number.isFinite(Number(this.linkMidX)) ? Number(this.linkMidX) - this.x : (ax + bx) * 0.5; const particles = Array.isArray(pb?.particles?.(this)) && pb.particles(this).length >= 2 ? pb.particles(this) : null;
const my = Number.isFinite(Number(this.linkMidY)) ? Number(this.linkMidY) - this.y : (ay + by) * 0.5;
ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)"; ctx.strokeStyle = styleNight ? "rgba(180,150,116,0.88)" : "rgba(132,94,54,0.92)";
ctx.lineWidth = 4.2; ctx.lineWidth = 4.2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ax, ay); if (particles) {
ctx.quadraticCurveTo(mx, my, bx, by); 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);
}
ctx.stroke(); ctx.stroke();
ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)"; ctx.strokeStyle = styleNight ? "rgba(236,214,180,0.56)" : "rgba(255,236,190,0.52)";
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ax, ay); if (particles) {
ctx.quadraticCurveTo(mx, my, bx, by); 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);
}
ctx.stroke(); 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)"); 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)");

View file

@ -4,11 +4,13 @@
function initializeItemTypeState(item, type, x, y) { function initializeItemTypeState(item, type, x, y) {
if (!item) return item; if (!item) return item;
const bodySchemaVersion = globalThis.TarinaiPhysicsBodySystem?.BODY_SCHEMA_VERSION || 4;
const constraintSchemaVersion = globalThis.TarinaiPhysicsBodySystem?.CONSTRAINT_SCHEMA_VERSION || 4;
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true; if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true; if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true; if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true;
if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(type)) item.roles.danger = true; if (["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "splat"].includes(type)) item.roles.danger = true;
if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") item.roles.grassMaterial = true;
if (type === "grass") { if (type === "grass") {
item.grassStage = Math.floor(rand(0, 2.999)); item.grassStage = Math.floor(rand(0, 2.999));
@ -110,12 +112,45 @@ function initializeItemTypeState(item, type, x, y) {
item.gateOpen = false; item.gateOpen = false;
item.gateLastToggleAt = -999; item.gateLastToggleAt = -999;
} }
if (type === "poison_block") {
item.physicsBody = {
schema: bodySchemaVersion,
type: "poison_block",
kind: "passive",
pose: { x, y, angle: Number(item.angle) || 0 },
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: false, speed: 0, direction: 1 },
rail: null,
shape: { model: "segments", thickness: 14, segments: [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]], version: 0 },
collision: { solid: true, hazard: true },
hazard: { kind: "poison", damage: 7 },
sleep: { awakeUntil: 0 },
mass: 4.8,
inertia: 36000,
};
item.prevX = x;
item.prevY = y;
item.prevAngle = item.angle || 0;
item.amount = 999;
item.deletable = true;
item.roles.danger = true;
}
if (type === "rotator") { if (type === "rotator") {
item.rotatorPowered = true; item.physicsBody = {
item.rotatorSpeed = Math.PI * 0.65; schema: bodySchemaVersion,
item.rotatorAngularVelocity = 0; type: "rotator",
item.rotatorThickness = 12; kind: "rotational",
item.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; pose: { x, y, angle: Number(item.angle) || 0 },
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: true, speed: Math.PI * 0.65, direction: 1 },
rail: null,
shape: { model: "segments", thickness: 12, segments: [[-78, 0, 78, 0], [0, -52, 0, 52]], version: 0 },
collision: { solid: true, hazard: false },
hazard: null,
sleep: { awakeUntil: 0 },
};
item.rotatorEditorOpenAt = -999; item.rotatorEditorOpenAt = -999;
item.amount = 999; item.amount = 999;
} }
@ -123,30 +158,39 @@ function initializeItemTypeState(item, type, x, y) {
item.amount = 999; item.amount = 999;
item.r = 48; item.r = 48;
item.deletable = true; item.deletable = true;
item.linkA = null; item.physicsConstraint = {
item.linkB = null; schema: constraintSchemaVersion,
item.linkLength = 80; type,
item.linkMidX = x; kind: type === "rope" ? "flexible-distance" : "rigid-distance",
item.linkMidY = y + 10; endpoints: [null, null],
item.linkMidVX = 0; length: 80,
item.linkMidVY = 0; mid: { x, y: y + 10, vx: 0, vy: 0 },
sleep: { awakeUntil: 0 },
};
} }
if (type === "reciprocator") { if (type === "reciprocator") {
item.reciprocatorPowered = true; item.physicsBody = {
item.reciprocatorSpeed = 92; schema: bodySchemaVersion,
item.reciprocatorTravel = 150; type: "reciprocator",
item.reciprocatorPhase = 0; kind: "linear",
item.reciprocatorDirection = 1; pose: { x, y, angle: Number(item.angle) || 0 },
item.reciprocatorAxisAngle = Number.isFinite(Number(item.angle)) ? item.angle : 0; velocity: { x: 0, y: 0, angular: 0, linear: 0 },
item.reciprocatorVelocity = 0; motor: { powered: true, speed: 92, direction: 1 },
item.reciprocatorAnchorX = x; rail: { axisAngle: Number.isFinite(Number(item.angle)) ? Number(item.angle) : 0, travel: 150, phase: 0, anchorX: x, anchorY: y },
item.reciprocatorAnchorY = y; shape: { model: "segments", thickness: 12, segments: [[-78, 0, 78, 0]], version: 0 },
item.rotatorThickness = 12; collision: { solid: true, hazard: false },
item.rotatorSegments = [[-78, 0, 78, 0]]; hazard: null,
sleep: { awakeUntil: 0 },
};
item.amount = 999; item.amount = 999;
} }
if (globalThis.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) {
globalThis.TarinaiPhysicsBodySystem.purgeLegacyPhysicsStorage?.(item);
globalThis.TarinaiPhysicsBodySystem.invalidateItem?.(item, "item-initialized");
}
if (isServingFoodType(type)) { if (isServingFoodType(type)) {
item.toolSize = "medium"; item.toolSize = "medium";
item.foodServingScale = 1; item.foodServingScale = 1;

View file

@ -5,17 +5,21 @@
// helpers and the item scheduler both read this single policy instead of // helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules. // duplicating item-type timing rules.
(function (global) { (function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "rotator", "reciprocator", "rope", "rod"]); const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([ const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "genkotsu", "firecracker", "pushpin", "oshibyo", "ball", "genkotsu", "firecracker", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food", "plushie", "grass_bed", "zunchi", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "water_bowl", "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", "water_bowl",
]); ]);
function itemUpdateInterval(item) { function itemUpdateInterval(item) {
if (!item || item.dead) return Infinity; if (!item || item.dead) return Infinity;
// Mechanical bodies and links are owned by the central physics step.
// Keeping them out of the per-item scheduler prevents N independent
// collision passes and makes future physics-format changes cheaper.
if (item.type === "rotator" || item.type === "reciprocator" || item.type === "poison_block" || item.type === "rope" || item.type === "rod") return Infinity;
if (REALTIME_ITEM_TYPES.has(item.type)) return 0; if (REALTIME_ITEM_TYPES.has(item.type)) return 0;
if (PIN_ITEM_TYPES.has(item.type)) return 0; if (PIN_ITEM_TYPES.has(item.type)) return 0;
if (item.isStructure && item.type === "plushie") return 0; if (item.isStructure && item.type === "plushie") return 0;

View file

@ -153,12 +153,43 @@
return ran; return ran;
} }
function physicsBudgetFor(worldRef) {
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
const itemCount = (worldRef?.items || []).length || 0;
const activeHint = worldRef?._itemUpdateRealtime?.length || 0;
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 };
}
function run(worldRef, dt) { function run(worldRef, dt) {
const end = global.TarinaiPerf?.begin?.("update.items") || null; const end = global.TarinaiPerf?.begin?.("update.items") || null;
try { try {
const state = ensure(worldRef); const state = ensure(worldRef);
let ran = runRealtime(worldRef, state, dt); const realtimeEnd = global.TarinaiPerf?.begin?.("update.items.realtime") || null;
ran += runDue(worldRef, state); let realtimeRan = runRealtime(worldRef, state, dt);
let ran = realtimeRan;
if (realtimeEnd) realtimeEnd();
const dueEnd = global.TarinaiPerf?.begin?.("update.items.scheduled") || null;
const dueRan = runDue(worldRef, state);
ran += dueRan;
if (dueEnd) dueEnd();
const physicsEnd = global.TarinaiPerf?.begin?.("update.physicsWorld") || null;
const physicsBudget = physicsBudgetFor(worldRef);
const physicsWorld = global.TarinaiPhysicsWorldSystem?.updateWorld
? global.TarinaiPhysicsWorldSystem.updateWorld(worldRef, dt, physicsBudget)
: null;
const mechanicalWorld = physicsWorld?.mechanical || (global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: physicsBudget.maxPairs, maxLinks: 0, skipLinks: true, maxSubsteps: physicsBudget.maxSubsteps }) || null);
const constraintWorld = physicsWorld ? physicsWorld.constraintRan : (global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: Math.min(180, physicsBudget.maxLinks) }) || 0);
const postConstraintPairs = physicsWorld ? physicsWorld.postConstraintPairs : (constraintWorld ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: Math.min(80, physicsBudget.postMaxPairs) }) || 0) : 0);
if (physicsEnd) physicsEnd();
state.signature = schedulerSignature(worldRef); state.signature = schedulerSignature(worldRef);
worldRef._itemUpdateSchedulerStats = { worldRef._itemUpdateSchedulerStats = {
heap: state.heap.length, heap: state.heap.length,
@ -166,8 +197,18 @@
rebuilds: state.rebuilds, rebuilds: state.rebuilds,
lastReason: state.reason, lastReason: state.reason,
lastRan: ran, lastRan: ran,
realtimeRan,
dueRan,
sleeping: state.heap.length,
activeRealtime: state.realtime.length,
physicsBudget,
physics: physicsWorld || mechanicalWorld,
constraints: constraintWorld,
postConstraintPairs,
mechanicalStats: worldRef._mechanicalWorldStats || null,
constraintStats: worldRef._constraintWorldStats || null,
}; };
return ran; return ran + (physicsWorld?.ran || mechanicalWorld?.ran || 0) + constraintWorld;
} finally { } finally {
if (end) end(); if (end) end();
} }

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,18 @@
(function (global) { (function (global) {
const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now()); const nowMs = () => (global.performance?.now ? global.performance.now() : Date.now());
const debugEnabled = (() => {
try {
const params = new URLSearchParams(global.location?.search || "");
return params.get("debug") === "1" || params.get("debug") === "true" || params.get("perf") === "1";
} catch (_) {
return false;
}
})();
const buckets = new Map(); const buckets = new Map();
const stack = [];
const metrics = new Map();
let frameOpen = null; let frameOpen = null;
let frameCount = 0; let frameCount = 0;
let lastQualityCheckAt = 0; let lastQualityCheckAt = 0;
@ -13,20 +24,33 @@
function bucketFor(label) { function bucketFor(label) {
let b = buckets.get(label); let b = buckets.get(label);
if (!b) { if (!b) {
b = { label, ms: 0, avg: 0, max: 0, calls: 0, last: 0 }; b = { label, ms: 0, selfMs: 0, avg: 0, selfAvg: 0, max: 0, calls: 0, last: 0, lastSelf: 0 };
buckets.set(label, b); buckets.set(label, b);
} }
return b; return b;
} }
function begin(label) { function begin(label) {
const start = nowMs(); if (!debugEnabled) return null;
const frame = { label: String(label || "unknown"), start: nowMs(), childMs: 0 };
stack.push(frame);
return () => { return () => {
const elapsed = Math.max(0, nowMs() - start); const top = stack.pop();
const b = bucketFor(label); const active = top === frame ? frame : top || frame;
// If calls ended out of order, keep the profiler alive rather than throwing.
if (top !== frame) {
const idx = stack.lastIndexOf(frame);
if (idx >= 0) stack.splice(idx, 1);
}
const elapsed = Math.max(0, nowMs() - active.start);
const selfElapsed = Math.max(0, elapsed - (active.childMs || 0));
if (stack.length) stack[stack.length - 1].childMs += elapsed;
const b = bucketFor(active.label);
b.ms += elapsed; b.ms += elapsed;
b.selfMs += selfElapsed;
b.calls += 1; b.calls += 1;
b.last = elapsed; b.last = elapsed;
b.lastSelf = selfElapsed;
if (elapsed > b.max) b.max = elapsed; if (elapsed > b.max) b.max = elapsed;
return elapsed; return elapsed;
}; };
@ -34,13 +58,17 @@
function beginFrame() { function beginFrame() {
frameOpen = nowMs(); frameOpen = nowMs();
if (debugEnabled) stack.length = 0;
return frameOpen; return frameOpen;
} }
function smoothBuckets() { function smoothBuckets() {
if (!debugEnabled) return;
for (const b of buckets.values()) { for (const b of buckets.values()) {
b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms; b.avg = b.avg ? b.avg * 0.82 + b.ms * 0.18 : b.ms;
b.selfAvg = b.selfAvg ? b.selfAvg * 0.82 + b.selfMs * 0.18 : b.selfMs;
b.ms = 0; b.ms = 0;
b.selfMs = 0;
b.calls = 0; b.calls = 0;
} }
} }
@ -55,15 +83,18 @@
function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) { function endFrame(rawDt = 0, fps = global.__tarinaiFps || 0) {
frameCount += 1; frameCount += 1;
if (frameOpen != null) { if (frameOpen != null && debugEnabled) {
const b = bucketFor("frame.total");
const elapsed = Math.max(0, nowMs() - frameOpen); const elapsed = Math.max(0, nowMs() - frameOpen);
const b = bucketFor("frame.total");
b.ms += elapsed; b.ms += elapsed;
b.selfMs += Math.max(0, elapsed - stack.reduce((sum, f) => sum + (f.childMs || 0), 0));
b.calls += 1; b.calls += 1;
b.last = elapsed; b.last = elapsed;
b.lastSelf = b.last;
if (elapsed > b.max) b.max = elapsed; if (elapsed > b.max) b.max = elapsed;
frameOpen = null;
} }
frameOpen = null;
if (debugEnabled) stack.length = 0;
const t = nowMs(); const t = nowMs();
if (t - lastQualityCheckAt >= 750) { if (t - lastQualityCheckAt >= 750) {
lastQualityCheckAt = t; lastQualityCheckAt = t;
@ -78,38 +109,56 @@
} }
} }
function renderQualityTier() { function renderQualityTier() { return tier; }
return tier; function dprScaleValue() { return dprScale; }
}
function dprScaleValue() {
return dprScale;
}
function consumeResizeRequest() { function consumeResizeRequest() {
const v = resizeRequested; const v = resizeRequested;
resizeRequested = false; resizeRequested = false;
return v; return v;
} }
function setMetric(name, value) {
if (!debugEnabled) return;
metrics.set(String(name || "metric"), value);
}
function snapshot() { function snapshot() {
const frameAvg = buckets.get("frame.total")?.avg || 0;
const entries = [...buckets.values()].map(b => ({ const entries = [...buckets.values()].map(b => ({
label: b.label, label: b.label,
avg: Number((b.avg || 0).toFixed(2)), avg: Number((b.avg || 0).toFixed(2)),
selfAvg: Number((b.selfAvg || 0).toFixed(2)),
max: Number((b.max || 0).toFixed(2)), max: Number((b.max || 0).toFixed(2)),
last: Number((b.last || 0).toFixed(2)), last: Number((b.last || 0).toFixed(2)),
lastSelf: Number((b.lastSelf || 0).toFixed(2)),
calls: b.calls || 0, calls: b.calls || 0,
})).sort((a, b) => b.avg - a.avg); pct: frameAvg > 0 ? Number((((b.avg || 0) / frameAvg) * 100).toFixed(0)) : 0,
return { tier, dprScale, frameCount, entries }; selfPct: frameAvg > 0 ? Number((((b.selfAvg || 0) / frameAvg) * 100).toFixed(0)) : 0,
}));
const topInclusive = entries.slice().sort((a, b) => b.avg - a.avg).slice(0, 12);
const topSelf = entries.slice().sort((a, b) => b.selfAvg - a.selfAvg).slice(0, 12);
return {
enabled: debugEnabled,
tier,
dprScale,
frameCount,
frameAvg: Number(frameAvg.toFixed(2)),
entries: topInclusive,
topInclusive,
topSelf,
metrics: Object.fromEntries(metrics.entries()),
};
} }
global.TarinaiPerf = Object.freeze({ global.TarinaiPerf = Object.freeze({
enabled: debugEnabled,
begin, begin,
beginFrame, beginFrame,
endFrame, endFrame,
renderQualityTier, renderQualityTier,
dprScale: dprScaleValue, dprScale: dprScaleValue,
consumeResizeRequest, consumeResizeRequest,
setMetric,
snapshot, snapshot,
}); });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

589
js/physics_world_system.js Normal file
View file

@ -0,0 +1,589 @@
"use strict";
// Layer: physics/world
// Central schema, serialization, and orchestration layer for physical tools.
// Physical state lives only in item.physicsBody / item.physicsConstraint.
(function (global) {
const BODY_SCHEMA_VERSION = 4;
const CONSTRAINT_SCHEMA_VERSION = 4;
const BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
const LINK_TYPES = new Set(["rope", "rod"]);
function num(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function bool(value, fallback = true) {
if (value == null) return !!fallback;
return !!value;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, num(value, min)));
}
function isBodyType(typeOrItem) {
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
return BODY_TYPES.has(type);
}
function isConstraintType(typeOrItem) {
const type = typeof typeOrItem === "string" ? typeOrItem : String(typeOrItem?.type || "");
return LINK_TYPES.has(type);
}
function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); }
function defaultSegments(type) {
if (type === "poison_block") return [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]];
if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]];
return [[-78, 0, 78, 0]];
}
function defaultKind(type) {
if (type === "rotator") return "rotational";
if (type === "reciprocator") return "linear";
if (type === "poison_block") return "passive";
return "none";
}
function cloneSegments(segments, fallback) {
const source = Array.isArray(segments) && segments.length ? segments : fallback;
const out = [];
for (const seg of source || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = clamp(seg[0], -420, 420), y1 = clamp(seg[1], -420, 420);
const x2 = clamp(seg[2], -420, 420), y2 = clamp(seg[3], -420, 420);
if (Math.hypot(x2 - x1, y2 - y1) < 4) continue;
out.push([x1, y1, x2, y2]);
if (out.length >= 128) break;
}
return out.length ? out : (fallback || [[-78, 0, 78, 0]]).map(seg => seg.slice());
}
function q(value, scale = 1000) { return Math.round(num(value) * scale); }
function defaultBody(item) {
const type = String(item?.type || "");
const x = num(item?.x), y = num(item?.y), angle = num(item?.angle);
return {
schema: BODY_SCHEMA_VERSION,
type,
kind: defaultKind(type),
pose: { x, y, angle },
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 },
rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null,
shape: { model: "segments", thickness: type === "poison_block" ? 14 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
collision: { solid: true, hazard: type === "poison_block" },
hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null,
sleep: { awakeUntil: 0 },
mass: type === "poison_block" ? 4.8 : 1,
inertia: type === "poison_block" ? 36000 : 22000,
};
}
function normalizeBody(body, item) {
const type = String(item?.type || body?.type || "");
const base = defaultBody({ type, x: item?.x, y: item?.y, angle: item?.angle });
const out = body && typeof body === "object" ? body : base;
out.schema = BODY_SCHEMA_VERSION;
out.type = type;
out.kind = out.kind || base.kind;
out.pose = out.pose && typeof out.pose === "object" ? out.pose : base.pose;
out.velocity = out.velocity && typeof out.velocity === "object" ? out.velocity : base.velocity;
out.motor = out.motor && typeof out.motor === "object" ? out.motor : base.motor;
out.rail = type === "reciprocator" ? ((out.rail && typeof out.rail === "object") ? out.rail : base.rail) : null;
out.shape = out.shape && typeof out.shape === "object" ? out.shape : base.shape;
out.collision = out.collision && typeof out.collision === "object" ? out.collision : base.collision;
out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.hazard = type === "poison_block" ? ((out.hazard && typeof out.hazard === "object") ? out.hazard : base.hazard) : null;
out.mass = Math.max(0.2, num(out.mass, base.mass));
out.inertia = Math.max(1, num(out.inertia, base.inertia));
out.pose.x = num(out.pose.x, item?.x);
out.pose.y = num(out.pose.y, item?.y);
out.pose.angle = num(out.pose.angle, item?.angle);
out.velocity.x = num(out.velocity.x);
out.velocity.y = num(out.velocity.y);
out.velocity.angular = num(out.velocity.angular);
out.velocity.linear = num(out.velocity.linear);
out.motor.powered = type === "poison_block" ? false : out.motor.powered !== false;
out.motor.speed = Math.max(0, num(out.motor.speed, base.motor.speed));
out.motor.direction = Math.sign(num(out.motor.direction, 1)) || 1;
if (out.rail) {
out.rail.axisAngle = num(out.rail.axisAngle, out.pose.angle);
out.rail.travel = Math.max(24, num(out.rail.travel, 150));
out.rail.phase = clamp(out.rail.phase, -1, 1);
out.rail.anchorX = num(out.rail.anchorX, out.pose.x);
out.rail.anchorY = num(out.rail.anchorY, out.pose.y);
}
out.shape.model = "segments";
out.shape.thickness = Math.max(4, Math.min(34, num(out.shape.thickness, base.shape.thickness)));
out.shape.segments = cloneSegments(out.shape.segments, defaultSegments(type));
out.shape.version = Number(out.shape.version || 0) || 0;
out.collision.solid = out.collision.solid !== false;
out.collision.hazard = type === "poison_block" ? out.collision.hazard !== false : false;
if (out.hazard) {
out.hazard.kind = out.hazard.kind || "poison";
out.hazard.damage = Math.max(1, num(out.hazard.damage, 7));
}
out.sleep.awakeUntil = num(out.sleep.awakeUntil);
// During the mechanical solver we intentionally mutate item.x/y/angle as
// a scratch pose, then commit that pose back to physicsBody at the end of
// the step. Do not let scalar()/setScalar() normalization overwrite the
// scratch pose with the old body pose while the step is in progress.
if (item._physicsStepScratch !== true) {
item.x = out.pose.x;
item.y = out.pose.y;
item.angle = out.pose.angle;
}
return out;
}
function itemPoseShouldFeedBody(item, opts = {}) {
return opts.syncFromLegacy === true || item?._physicsStepScratch === true || item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true;
}
function ensureBody(item, worldRef = null, opts = {}) {
if (!item || item.dead || !isBodyType(item)) return null;
item.world = worldRef || item.world || null;
if (!item.physicsBody || typeof item.physicsBody !== "object" || item.physicsBody.type !== item.type || opts.rebuild === true) {
item.physicsBody = defaultBody(item);
markBodyChanged(item, opts.rebuild === true ? "rebuild" : "init");
} else if (itemPoseShouldFeedBody(item, opts)) {
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
item.physicsBody.pose = item.physicsBody.pose || { x: sx, y: sy, angle: sa };
item.physicsBody.pose.x = sx;
item.physicsBody.pose.y = sy;
item.physicsBody.pose.angle = sa;
item._physicsExternalPoseDirty = false;
}
const body = normalizeBody(item.physicsBody, item);
item.physicsBody = body;
return body;
}
function markBodyChanged(item, reason = "physics-body") {
if (!item) return;
item._physicsBodyVersion = (Number(item._physicsBodyVersion || 0) || 0) + 1;
item._physicsBodyDirtyReason = reason;
}
function bodySignature(item) {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return "";
const s = b.shape || {};
const v = b.velocity || {};
const m = b.motor || {};
const r = b.rail || {};
const c = b.collision || {};
const sl = b.sleep || {};
return [
b.type, q(b.pose?.x, 10), q(b.pose?.y, 10), q(b.pose?.angle, 10000),
q(s.thickness, 10), Number(s.version || 0) || 0, Array.isArray(s.segments) ? s.segments.length : 0,
c.solid === false ? 0 : 1, c.hazard === false ? 0 : 1,
m.powered === false ? 0 : 1, q(m.speed, 1000), q(m.direction, 10),
q(v.x, 1000), q(v.y, 1000), q(v.angular, 10000), q(v.linear, 1000),
q(r.axisAngle, 10000), q(r.travel, 10), q(r.phase, 10000), q(r.anchorX, 10), q(r.anchorY, 10),
q(sl.awakeUntil, 1000), q(b.hazard?.damage, 10), q(b.mass, 10), q(b.inertia, 1),
].join(":");
}
function scalar(item, key, fallback = 0) {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return fallback;
switch (key) {
case "thickness": return Math.max(4, Math.min(34, num(b.shape?.thickness, fallback)));
case "motorOn": return b.motor?.powered !== false;
case "motorSpeed": return num(b.motor?.speed, fallback);
case "spin": return num(b.velocity?.angular, fallback);
case "xv": return num(b.velocity?.x, fallback);
case "yv": return num(b.velocity?.y, fallback);
case "slideSpeed": return num(b.velocity?.linear, fallback);
case "solid": return b.collision?.solid !== false;
case "damage": return Math.max(1, num(b.hazard?.damage, fallback));
case "mass": return Math.max(0.2, num(b.mass, fallback));
case "inertia": return Math.max(1, num(b.inertia, fallback));
case "railOn": return b.motor?.powered !== false;
case "railMotorSpeed": return Math.max(0, num(b.motor?.speed, fallback));
case "railTravel": return Math.max(24, num(b.rail?.travel, fallback));
case "railPhase": return clamp(b.rail?.phase, -1, 1);
case "railDir": return Math.sign(num(b.motor?.direction, fallback || 1)) || 1;
case "railAxis": return num(b.rail?.axisAngle, fallback);
case "railAnchorX": return num(b.rail?.anchorX, fallback);
case "railAnchorY": return num(b.rail?.anchorY, fallback);
case "awakeUntil": return num(b.sleep?.awakeUntil, fallback);
default: return fallback;
}
}
function setScalar(item, key, value, reason = "physics-set") {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
b.velocity = b.velocity || {};
b.motor = b.motor || {};
b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null);
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 14 : 12, segments: defaultSegments(item.type), version: 0 };
b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" };
b.sleep = b.sleep || { awakeUntil: 0 };
if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 };
switch (key) {
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 14 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break;
case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break;
case "motorSpeed": b.motor.speed = Math.max(0, num(value)); break;
case "spin": b.velocity.angular = num(value); break;
case "xv": b.velocity.x = num(value); break;
case "yv": b.velocity.y = num(value); break;
case "slideSpeed": b.velocity.linear = num(value); break;
case "solid": b.collision.solid = !!value; break;
case "damage": b.hazard = b.hazard || { kind: "poison", damage: 7 }; b.hazard.damage = Math.max(1, num(value, 7)); break;
case "mass": b.mass = Math.max(0.2, num(value, 4.8)); break;
case "inertia": b.inertia = Math.max(1, num(value, 36000)); break;
case "railOn": b.motor.powered = !!value; break;
case "railMotorSpeed": b.motor.speed = Math.max(0, num(value, 92)); break;
case "railTravel": if (b.rail) b.rail.travel = Math.max(24, num(value, 150)); break;
case "railPhase": if (b.rail) b.rail.phase = clamp(value, -1, 1); break;
case "railDir": b.motor.direction = Math.sign(num(value, 1)) || 1; break;
case "railAxis": if (b.rail) b.rail.axisAngle = num(value, b.pose?.angle); break;
case "railAnchorX": if (b.rail) b.rail.anchorX = num(value, b.pose?.x); break;
case "railAnchorY": if (b.rail) b.rail.anchorY = num(value, b.pose?.y); break;
case "awakeUntil": b.sleep.awakeUntil = num(value); break;
default: return false;
}
normalizeBody(b, item);
markBodyChanged(item, reason);
if (key === "thickness" || key === "solid") global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
return true;
}
function segments(item) { return cloneSegments(ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape?.segments, defaultSegments(item?.type)); }
function setSegments(item, next, reason = "shape-edited") {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
b.shape = b.shape || {};
b.shape.model = "segments";
b.shape.segments = cloneSegments(next, defaultSegments(item.type));
b.shape.version = (Number(b.shape.version || 0) || 0) + 1;
markBodyChanged(item, reason);
global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
return true;
}
function pose(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.pose || null; }
function velocity(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.velocity || null; }
function motor(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.motor || null; }
function rail(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.rail || null; }
function shape(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.shape || null; }
function collision(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.collision || null; }
function hazard(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.hazard || null; }
function sleep(item) { return ensureBody(item, item?.world || null, { syncFromLegacy: false })?.sleep || null; }
function syncPoseFromItem(item) {
if (!item) return null;
// Capture the item pose before ensureBody()/normalizeBody() can write the
// current body pose back to the item. This is required for scratch-solver
// movement and direct player grabbing.
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return null;
b.pose = b.pose || { x: sx, y: sy, angle: sa };
b.pose.x = sx;
b.pose.y = sy;
b.pose.angle = sa;
item.x = sx;
item.y = sy;
item.angle = sa;
return b;
}
function applyPoseToItem(item) {
const b = ensureBody(item, item?.world || null, { syncFromLegacy: false });
if (!b) return false;
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
item.x = num(b.pose?.x, item.x);
item.y = num(b.pose?.y, item.y);
item.angle = num(b.pose?.angle, item.angle);
const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
return before !== after;
}
function endpointToPlain(endpoint) {
if (!endpoint) return null;
return {
kind: endpoint.kind || "item",
id: endpoint.id || "",
type: endpoint.type || "",
label: endpoint.label || "",
liveToken: endpoint.liveToken ?? null,
localX: num(endpoint.localX),
localY: num(endpoint.localY),
center: !!endpoint.center,
x: num(endpoint.x),
y: num(endpoint.y),
attachT: Number.isFinite(Number(endpoint.attachT)) ? num(endpoint.attachT) : null,
fuzzyResolve: endpoint.fuzzyResolve === true,
supportLost: endpoint.supportLost === true,
_ref: endpoint._ref || null,
_supportItemId: endpoint._supportItemId || "",
_supportCheckedVersion: Number(endpoint._supportCheckedVersion || -1) || -1,
_supportAlive: endpoint._supportAlive !== false,
};
}
function defaultConstraint(item) {
const x = num(item?.x), y = num(item?.y);
return {
schema: CONSTRAINT_SCHEMA_VERSION,
type: item?.type,
kind: item?.type === "rope" ? "flexible-distance" : "rigid-distance",
endpoints: [null, null],
length: 80,
mid: { x, y: y + 10, vx: 0, vy: 0 },
sleep: { awakeUntil: 0 },
particles: null,
};
}
function normalizeConstraint(c, item) {
const base = defaultConstraint(item);
const out = c && typeof c === "object" ? c : base;
out.schema = CONSTRAINT_SCHEMA_VERSION;
out.type = item?.type;
out.kind = out.kind || base.kind;
out.endpoints = Array.isArray(out.endpoints) ? out.endpoints : [null, null];
out.endpoints[0] = endpointToPlain(out.endpoints[0]);
out.endpoints[1] = endpointToPlain(out.endpoints[1]);
out.length = Math.max(24, num(out.length, 80));
out.mid = out.mid && typeof out.mid === "object" ? out.mid : base.mid;
out.mid.x = num(out.mid.x, item?.x);
out.mid.y = num(out.mid.y, item?.y);
out.mid.vx = num(out.mid.vx);
out.mid.vy = num(out.mid.vy);
out.sleep = out.sleep && typeof out.sleep === "object" ? out.sleep : base.sleep;
out.sleep.awakeUntil = num(out.sleep.awakeUntil);
out.particles = Array.isArray(out.particles) ? out.particles : null;
item.r = Math.max(18, item.type === "rod" ? out.length * 0.5 : (num(item.r, out.length * 0.5) || out.length * 0.5));
return out;
}
function ensureConstraint(item, worldRef = null, opts = {}) {
if (!item || item.dead || !isConstraintType(item)) return null;
item.world = worldRef || item.world || null;
if (!item.physicsConstraint || typeof item.physicsConstraint !== "object" || item.physicsConstraint.type !== item.type || opts.rebuild === true) {
item.physicsConstraint = defaultConstraint(item);
}
item.physicsConstraint = normalizeConstraint(item.physicsConstraint, item);
return item.physicsConstraint;
}
function endpoint(item, index = 0) { return ensureConstraint(item, item?.world || null, { syncFromLegacy: false })?.endpoints?.[index] || null; }
function setEndpoint(item, index, value) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.endpoints[index] = endpointToPlain(value);
return true;
}
function linkScalar(item, key, fallback = 0) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return fallback;
switch (key) {
case "len": return Math.max(24, num(c.length, fallback));
case "midX": return num(c.mid?.x, fallback);
case "midY": return num(c.mid?.y, fallback);
case "midVx": return num(c.mid?.vx, fallback);
case "midVy": return num(c.mid?.vy, fallback);
case "awakeUntil": return num(c.sleep?.awakeUntil, fallback);
default: return fallback;
}
}
function setLinkScalar(item, key, value) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.mid = c.mid || { x: num(item.x), y: num(item.y), vx: 0, vy: 0 };
c.sleep = c.sleep || { awakeUntil: 0 };
switch (key) {
case "len": c.length = Math.max(24, num(value, 80)); item.r = Math.max(18, c.length * 0.5); break;
case "midX": c.mid.x = num(value, item.x); break;
case "midY": c.mid.y = num(value, item.y); break;
case "midVx": c.mid.vx = num(value); break;
case "midVy": c.mid.vy = num(value); break;
case "awakeUntil": c.sleep.awakeUntil = num(value); break;
default: return false;
}
return true;
}
function particles(item) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return null;
return c.particles;
}
function setParticles(item, list) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return false;
c.particles = Array.isArray(list) ? list : null;
return true;
}
function constraintSignature(item) {
const c = ensureConstraint(item, item?.world || null, { syncFromLegacy: false });
if (!c) return "";
const epSig = ep => !ep ? "null" : [ep.kind || "item", ep.id || "", ep.type || "", q(ep.localX, 10), q(ep.localY, 10), ep.center ? 1 : 0, q(ep.x, 10), q(ep.y, 10), ep.attachT == null ? "" : q(ep.attachT, 1000)].join("/");
return [item.type || "", q(c.length, 10), q(c.mid?.x, 10), q(c.mid?.y, 10), q(c.mid?.vx, 1000), q(c.mid?.vy, 1000), q(c.sleep?.awakeUntil, 1000), epSig(c.endpoints?.[0]), epSig(c.endpoints?.[1])].join(":");
}
function collectPhysicsItems(worldRef) {
const bodies = [];
const constraints = [];
if (!worldRef?.itemsOfType) return { bodies, constraints };
worldRef.ensureItemBuckets?.("physics-world-system");
for (const type of BODY_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) bodies.push(item);
for (const type of LINK_TYPES) for (const item of worldRef.itemsOfType(type) || []) if (item && !item.dead) constraints.push(item);
return { bodies, constraints };
}
function prepareWorld(worldRef, opts = {}) {
const { bodies, constraints } = collectPhysicsItems(worldRef);
for (const item of bodies) ensureBody(item, worldRef, { syncFromLegacy: item?.playerHeld === true || item?._heldByPlayer === true || item?._physicsExternalPoseDirty === true });
for (const item of constraints) ensureConstraint(item, worldRef, { syncFromLegacy: false });
const frame = { id: (worldRef._physicsFrameSeq = (Number(worldRef._physicsFrameSeq || 0) || 0) + 1), time: num(worldRef?.time), bodies, constraints, bodyCount: bodies.length, constraintCount: constraints.length, sync: { bodyReads: 0, bodyWrites: 0, constraintReads: 0, constraintWrites: 0 } };
worldRef._physicsWorldFrame = frame;
return frame;
}
function commitWorld(worldRef, frame = worldRef?._physicsWorldFrame, opts = {}) {
if (!frame) return { bodies: 0, constraints: 0, moved: 0 };
let moved = 0;
for (const item of frame.bodies || []) {
const before = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
ensureBody(item, worldRef, { syncFromLegacy: false });
applyPoseToItem(item);
const after = `${num(item.x).toFixed(3)}:${num(item.y).toFixed(3)}:${num(item.angle).toFixed(5)}`;
if (before !== after) moved += 1;
}
for (const item of frame.constraints || []) ensureConstraint(item, worldRef, { syncFromLegacy: false });
if (opts.markDirty && moved) worldRef?.markSpatialDirty?.("physics-world-commit");
return { bodies: frame.bodies?.length || 0, constraints: frame.constraints?.length || 0, moved };
}
function updateWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return { ran: 0, bodies: 0, constraints: 0 };
const profiler = global.TarinaiPerf;
const end = profiler?.begin?.("update.physicsWorld.central") || null;
try {
const frame = prepareWorld(worldRef, opts);
const mechanicalStats = global.TarinaiMechanicalSystem?.updateWorld?.(worldRef, dt, { maxPairs: opts.maxPairs || 320, maxLinks: 0, skipLinks: true, maxSubsteps: opts.maxSubsteps || 5, focusThreshold: opts.focusThreshold || 44 }) || { ran: 0, active: 0 };
const constraintRan = global.TarinaiConstraintSystem?.updateWorld?.(worldRef, dt, { maxLinks: opts.maxLinks || 220 }) || 0;
const postConstraintPairs = constraintRan ? (global.TarinaiMechanicalSystem?.resolveMechanicalPairs?.(worldRef, dt, { maxPairs: opts.postMaxPairs || 110 }) || 0) : 0;
const committed = commitWorld(worldRef, frame);
const stats = { ran: (mechanicalStats?.ran || 0) + constraintRan, bodies: frame.bodyCount, constraints: frame.constraintCount, mechanical: mechanicalStats, constraintRan, postConstraintPairs, committed, sync: frame.sync || null, schema: BODY_SCHEMA_VERSION };
worldRef._physicsWorldStats = stats;
return stats;
} finally { if (end) end(); }
}
function compactItemExtra(item, tarinaiIndex = new Map(), itemIndex = new Map()) {
if (!item || !isPhysicsType(item)) return null;
if (isBodyType(item)) {
const b = ensureBody(item, item.world || null);
return { pf: 2, body: {
type: b.type,
kind: b.kind,
pose: [Math.round(num(b.pose.x)), Math.round(num(b.pose.y)), Math.round(num(b.pose.angle) * 1000)],
vel: [Math.round(num(b.velocity.x) * 10), Math.round(num(b.velocity.y) * 10), Math.round(num(b.velocity.angular) * 1000), Math.round(num(b.velocity.linear) * 10)],
motor: [b.motor.powered ? 1 : 0, Math.round(num(b.motor.speed) * 10), Math.round(num(b.motor.direction || 1))],
rail: b.rail ? [Math.round(num(b.rail.axisAngle) * 1000), Math.round(num(b.rail.travel) * 10), Math.round(num(b.rail.phase) * 1000), Math.round(num(b.rail.anchorX)), Math.round(num(b.rail.anchorY))] : null,
shape: [Math.round(num(b.shape.thickness) * 10), cloneSegments(b.shape.segments, defaultSegments(item.type)).map(seg => seg.map(v => Math.round(num(v))))],
collision: [b.collision.solid ? 1 : 0, b.collision.hazard ? 1 : 0],
hazard: b.hazard ? [b.hazard.kind || "", Math.round(num(b.hazard.damage, 7) * 10)] : null,
mass: Math.round(num(b.mass, 1) * 10),
inertia: Math.round(num(b.inertia, 1)),
} };
}
const c = ensureConstraint(item, item.world || null);
const epToSave = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
return { kind, refIdx, id: ep.id || "", type: ep.type || "", label: ep.label || "", token: ep.liveToken ?? null, lx: Math.round(num(ep.localX)), ly: Math.round(num(ep.localY)), center: ep.center ? 1 : 0, x: Math.round(num(ep.x)), y: Math.round(num(ep.y)), t: Number.isFinite(Number(ep.attachT)) ? Math.round(num(ep.attachT) * 1000) : null };
};
return { pf: 2, constraint: { type: c.type, kind: c.kind, endpoints: [epToSave(c.endpoints?.[0]), epToSave(c.endpoints?.[1])], length: Math.round(num(c.length, 80)), mid: [Math.round(num(c.mid?.x)), Math.round(num(c.mid?.y)), Math.round(num(c.mid?.vx) * 10), Math.round(num(c.mid?.vy) * 10)] } };
}
function restoreBodyExtra(item, extra) {
const p = extra?.body;
if (!item || !p || !isBodyType(item)) return false;
const poseArr = Array.isArray(p.pose) ? p.pose : [];
const velArr = Array.isArray(p.vel) ? p.vel : [];
const motorArr = Array.isArray(p.motor) ? p.motor : [];
const railArr = Array.isArray(p.rail) ? p.rail : null;
const shapeArr = Array.isArray(p.shape) ? p.shape : [];
const collArr = Array.isArray(p.collision) ? p.collision : [];
const hazardArr = Array.isArray(p.hazard) ? p.hazard : null;
const body = defaultBody(item);
body.kind = p.kind || body.kind;
body.pose = { x: num(poseArr[0], item.x), y: num(poseArr[1], item.y), angle: num(poseArr[2]) / 1000 };
body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 };
body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 };
body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null;
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 140 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 };
body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null;
body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10);
body.inertia = Math.max(1, num(p.inertia, body.inertia));
item.physicsBody = normalizeBody(body, item);
applyPoseToItem(item);
return true;
}
function restoreConstraintExtra(item, extra, tarinaiList = [], itemList = []) {
const p = extra?.constraint;
if (!item || !p || !isConstraintType(item)) return false;
const epFromSave = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = Number(ep.refIdx);
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0 ? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx]) : null;
return endpointToPlain({ kind, id: indexedTarget?.id || ep.id || "", type: ep.type || indexedTarget?.type || "", label: ep.label || indexedTarget?.name || "", liveToken: ep.token ?? indexedTarget?.liveToken ?? null, _ref: indexedTarget || null, localX: num(ep.lx), localY: num(ep.ly), center: !!ep.center, x: num(ep.x, item.x || 0), y: num(ep.y, item.y || 0), attachT: ep.t == null ? null : clamp(num(ep.t) / 1000, 0, 1), fuzzyResolve: !indexedTarget && !(Number.isInteger(refIdx) && refIdx >= 0) });
};
const midArr = Array.isArray(p.mid) ? p.mid : [];
item.physicsConstraint = normalizeConstraint({ schema: CONSTRAINT_SCHEMA_VERSION, type: item.type, kind: p.kind || (item.type === "rope" ? "flexible-distance" : "rigid-distance"), endpoints: [epFromSave(p.endpoints?.[0]), epFromSave(p.endpoints?.[1])], length: Math.max(24, num(p.length, 80)), mid: { x: num(midArr[0], item.x || 0), y: num(midArr[1], item.y || 0), vx: num(midArr[2]) / 10, vy: num(midArr[3]) / 10 }, sleep: { awakeUntil: 0 }, particles: null }, item);
return true;
}
function applyCompactExtra(item, extra, tarinaiList = [], itemList = []) {
if (!item || !extra || typeof extra !== "object" || extra.pf !== 2) return false;
if (isBodyType(item)) return restoreBodyExtra(item, extra);
if (isConstraintType(item)) return restoreConstraintExtra(item, extra, tarinaiList, itemList);
return false;
}
function invalidateItem(item, reason = "physics-edited") {
if (!item) return false;
if (isBodyType(item)) {
ensureBody(item, item.world || null, { rebuild: false, syncFromLegacy: false });
global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
markBodyChanged(item, reason);
return true;
}
if (isConstraintType(item)) {
ensureConstraint(item, item.world || null, { rebuild: false, syncFromLegacy: false });
item._linkLastStamp = "";
return true;
}
return false;
}
function purgeLegacyPhysicsStorage(item) {
if (!item || typeof item !== "object") return false;
// Physical state is schema-only now; remove any data fields left by older experiments.
const bodyKeys = ["rotator" + "Thickness", "rotator" + "Segments", "rotator" + "Powered", "rotator" + "Speed", "rotator" + "AngularVelocity", "poison" + "CollisionEnabled", "poison" + "Damage", "poison" + "Mass", "poison" + "Inertia", "reciprocator" + "Powered", "reciprocator" + "Speed", "reciprocator" + "Travel", "reciprocator" + "Phase", "reciprocator" + "Direction", "reciprocator" + "AxisAngle", "reciprocator" + "Velocity", "reciprocator" + "AnchorX", "reciprocator" + "AnchorY", "_physics" + "AwakeUntil"];
const linkKeys = ["link" + "A", "link" + "B", "link" + "Length", "link" + "MidX", "link" + "MidY", "link" + "MidVX", "link" + "MidVY", "_link" + "AwakeUntil", "rope" + "Particles"];
for (const key of bodyKeys.concat(linkKeys)) { try { delete item[key]; } catch (_) {} }
if (isBodyType(item)) ensureBody(item, item.world || null, { syncFromLegacy: false });
if (isConstraintType(item)) ensureConstraint(item, item.world || null, { syncFromLegacy: false });
return true;
}
// Schema normalization entry points.
const normalizeBodyState = (item, body = item?.physicsBody) => { if (item && body) { item.physicsBody = normalizeBody(body, item); return item.physicsBody; } return null; };
const applyBodyState = (item, body = item?.physicsBody, opts = {}) => { if (item && body) { item.physicsBody = normalizeBody(body, item); if (opts.invalidateShape === true) global.TarinaiMechanicalSystem?.invalidateGeometry?.(item); return applyPoseToItem(item); } return false; };
const normalizeConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return item.physicsConstraint; } return null; };
const applyConstraintState = (item, c = item?.physicsConstraint) => { if (item && c) { item.physicsConstraint = normalizeConstraint(c, item); return true; } return false; };
const bodyApi = Object.freeze({
BODY_SCHEMA_VERSION, CONSTRAINT_SCHEMA_VERSION, BODY_TYPES, LINK_TYPES,
isBodyType, isConstraintType, isPhysicsType,
bodySignature, constraintSignature,
ensureBody, ensureConstraint,
normalizeBodyState, applyBodyState, markBodyChanged, normalizeConstraintState, applyConstraintState,
prepareWorld, commitWorld, compactItemExtra, applyCompactExtra, purgeLegacyPhysicsStorage,
defaultSegments, cloneSegments, invalidateItem,
pose, velocity, motor, rail, shape, collision, hazard, sleep,
scalar, setScalar, segments, setSegments, syncPoseFromItem, applyPoseToItem,
endpoint, setEndpoint, linkScalar, setLinkScalar, particles, setParticles, endpointToPlain,
});
global.TarinaiPhysicsBodySystem = bodyApi;
global.TarinaiPhysicsWorldSystem = Object.freeze({ updateWorld, prepareWorld, commitWorld, collectPhysicsItems, bodyApi });
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,5 +1,125 @@
"use strict"; "use strict";
const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {}); const TARINAI_RENDER_GLOBAL = typeof globalThis !== "undefined" ? globalThis : (typeof window !== "undefined" ? window : {});
const toolCursorIconCache = new Map();
const toolCursorIconImages = new Map();
function toolCursorIconDefinition(world) {
const tool = String(world?.tool || "");
if (!tool || tool === "observe" || tool === "undo" || tool === "redo") return null;
const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null;
if (!def) return null;
return { tool, def, type: def.itemType || (def.placeable ? tool : "") };
}
function staticVersionedAssetPath(path) {
const raw = String(path || "");
if (!raw) return "";
const app = TARINAI_RENDER_GLOBAL.TARINAI_APP;
if (app?.withVersion) return app.withVersion(raw);
return raw;
}
function cachedToolCursorIconImage(path) {
const src = staticVersionedAssetPath(path);
if (!src) return null;
let img = toolCursorIconImages.get(src);
if (!img && typeof Image !== "undefined") {
img = new Image();
img.decoding = "async";
img.src = src;
toolCursorIconImages.set(src, img);
}
return img || null;
}
function drawToolCursorIconFallback(ctx, icon, size) {
const value = String(icon || "");
if (!value) return false;
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${Math.round(size * 0.58)}px ui-rounded, sans-serif`;
ctx.fillStyle = "rgba(44, 36, 28, 0.78)";
ctx.fillText(value, size / 2, size / 2 + size * 0.02);
ctx.restore();
return true;
}
function buildToolCursorIconCanvas(tool, def, type, size) {
const key = `${tool}:${type || "-"}:${size}`;
const cached = toolCursorIconCache.get(key);
if (cached) return cached;
const ownerDocument = typeof document !== "undefined" ? document : null;
const canvas = ownerDocument?.createElement?.("canvas") || null;
if (!canvas) return null;
canvas.width = size;
canvas.height = size;
const c = canvas.getContext("2d");
if (!c) return null;
c.clearRect(0, 0, size, size);
let drawn = false;
if (type && typeof TARINAI_RENDER_GLOBAL.drawToolItemPreview === "function") {
try { drawn = TARINAI_RENDER_GLOBAL.drawToolItemPreview(c, type, { width: size, height: size, compact: true, watermark: false }) !== false; }
catch (_) { drawn = false; }
}
if (!drawn && def?.icon) {
const img = cachedToolCursorIconImage(def.icon);
if (img && img.complete && (img.naturalWidth || img.width)) {
const pad = Math.max(4, size * 0.12);
c.drawImage(img, pad, pad, size - pad * 2, size - pad * 2);
drawn = true;
} else {
// Do not cache a label fallback while the real icon is still loading.
// Otherwise tools such as "new" and "delete" can remain as kanji text
// until a hard reload even after the image becomes available.
return null;
}
}
if (!drawn) drawn = drawToolCursorIconFallback(c, def?.iconText || def?.label || tool, size);
if (!drawn) return null;
toolCursorIconCache.set(key, canvas);
while (toolCursorIconCache.size > 64) toolCursorIconCache.delete(toolCursorIconCache.keys().next().value);
return canvas;
}
function drawSelectedToolCursorIcon(ctx, world) {
const p = world?.pointer;
if (!p?.inside) return;
const info = toolCursorIconDefinition(world);
if (!info) return;
const activeUi = typeof uiCache !== "undefined" ? uiCache : null;
if (activeUi?.panning || activeUi?.grabbing || activeUi?.hosing || activeUi?.areaDeleting) return;
const screen = world.worldToScreen ? world.worldToScreen(p.x, p.y) : { x: p.x, y: p.y };
const canvasW = world.viewportW || world.w || ctx.canvas?.width || 1;
const canvasH = world.viewportH || world.h || ctx.canvas?.height || 1;
const size = Math.max(30, Math.min(46, Math.round(Math.min(canvasW, canvasH) * 0.055)));
const offset = Math.max(6, Math.round(size * 0.16));
let x = screen.x + offset;
let y = screen.y + offset;
x = clamp(x, 8, canvasW - size - 8);
y = clamp(y, 8, canvasH - size - 8);
let icon = buildToolCursorIconCanvas(info.tool, info.def, info.type, 96);
if (!icon && info.def?.icon) {
const img = cachedToolCursorIconImage(info.def.icon);
if (img && img.complete && (img.naturalWidth || img.width)) icon = img;
}
if (!icon) {
// Icon image may still be loading; request one more frame once it arrives.
cachedToolCursorIconImage(info.def?.icon || "");
return;
}
ctx.save();
ctx.globalAlpha = 0.52;
ctx.shadowColor = "rgba(255,255,255,0.36)";
ctx.shadowBlur = 5;
ctx.drawImage(icon, x, y, size, size);
ctx.restore();
}
function randSeed(seed, min, max) { function randSeed(seed, min, max) {
const s = Math.sin(seed * 12.9898) * 43758.5453; const s = Math.sin(seed * 12.9898) * 43758.5453;
return min + (s - Math.floor(s)) * (max - min); return min + (s - Math.floor(s)) * (max - min);
@ -150,31 +270,49 @@ function placementPreviewFor(world) {
let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false }; let tmp = { type, x, y, r, toolSize: sizeName, foodServingScale: sizeScale, dead: false };
if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0); if (typeof isRotatableItemType === "function" && isRotatableItemType(type)) tmp.angle = world?.toolAngleFor ? world.toolAngleFor(type) : (typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0);
if (type === "reciprocator") { if (type === "reciprocator") {
tmp.reciprocatorAxisAngle = tmp.angle; const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
pb?.setScalar?.(tmp, "railAxis", tmp.angle, "preview-axis");
tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0; tmp.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(type) : 0;
pb?.syncPoseFromItem?.(tmp);
} }
if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) { if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) {
const rawRect = world.fenceRect(tmp); const rawRect = world.fenceRect(tmp);
return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) }; return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
} }
if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.rotatorSpeed = Math.PI * 0.65; const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
tmp.rotatorPowered = true; pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
tmp.rotatorThickness = 12; pb?.setScalar?.(tmp, "motorSpeed", Math.PI * 0.65, "preview");
tmp.rotatorSegments = [[-78, 0, 78, 0], [0, -52, 0, 52]]; pb?.setScalar?.(tmp, "motorOn", true, "preview");
pb?.setScalar?.(tmp, "thickness", 12, "preview");
pb?.setSegments?.(tmp, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
}
if (type === "poison_block" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
// Use the actual default poison-block physics footprint for placement
// feedback. Previously it fell through to the generic oval preview, which
// did not match the placed shape.
pb?.ensureBody?.(tmp, null, { syncFromLegacy: false });
tmp.world = null;
const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || []; const rects = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.obstacleRects(tmp) || [];
const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null; const bounds = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.boundsAabb(tmp) || null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
} }
if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { if (type === "reciprocator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) {
tmp.reciprocatorPowered = true; const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem;
tmp.reciprocatorSpeed = 92; pb?.ensureBody?.(tmp, world, { syncFromLegacy: false });
tmp.reciprocatorTravel = 150; pb?.setScalar?.(tmp, "railOn", true, "preview");
tmp.reciprocatorPhase = 0; pb?.setScalar?.(tmp, "railMotorSpeed", 92, "preview");
tmp.reciprocatorAnchorX = x; pb?.setScalar?.(tmp, "railTravel", 150, "preview");
tmp.reciprocatorAnchorY = y; pb?.setScalar?.(tmp, "railPhase", 0, "preview");
tmp.rotatorThickness = 12; pb?.setScalar?.(tmp, "railAnchorX", x, "preview");
tmp.rotatorSegments = [[-78, 0, 78, 0]]; pb?.setScalar?.(tmp, "railAnchorY", y, "preview");
pb?.setScalar?.(tmp, "thickness", 12, "preview");
pb?.setSegments?.(tmp, [[-78, 0, 78, 0]], "preview");
const rects = world.reciprocatorObstacleRects(tmp) || []; const rects = world.reciprocatorObstacleRects(tmp) || [];
const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null; const bounds = rects.length ? { left: Math.min(...rects.map(rr => rr.left)), right: Math.max(...rects.map(rr => rr.right)), top: Math.min(...rects.map(rr => rr.top)), bottom: Math.max(...rects.map(rr => rr.bottom)) } : null;
return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) }; return { type, x, y, r, rects, blocked: (bounds ? rectOutside(bounds) : circleOutside(x, y, r)) || world.placementBlocked?.(tmp) };
@ -603,7 +741,7 @@ function renderRadiusForEntity(entity) {
if (!entity) return 40; if (!entity) return 40;
if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2); if (entity.type === "genkotsu") return Math.max(420, (entity.r || 88) * 6.2);
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2); if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2);
if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (entity.reciprocatorTravel || 150))); if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem?.scalar?.(entity, "railTravel", 150) || 150)));
if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2); if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0); if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42; if (entity.kind === "queen") return 42;
@ -1175,6 +1313,14 @@ function weatherLabel(weather) {
return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c"; return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
} }
function renderPerfBegin(label) {
return window.TarinaiPerf?.begin?.(label) || null;
}
function renderPerfEnd(end) {
if (end) end();
}
function fillScreenFallback(ctx, w, h, lighting) { function fillScreenFallback(ctx, w, h, lighting) {
const light = lighting?.light ?? 0.7; const light = lighting?.light ?? 0.7;
ctx.save(); ctx.save();
@ -1203,10 +1349,12 @@ function render() {
const cameraY = world.cameraY || 0; const cameraY = world.cameraY || 0;
const viewScale = world.viewScale ? world.viewScale() : 1; const viewScale = world.viewScale ? world.viewScale() : 1;
const endSetup = renderPerfBegin("render.setup");
const lighting = getLightingState(world); const lighting = getLightingState(world);
fillScreenFallback(ctx, screenW, screenH, lighting); fillScreenFallback(ctx, screenW, screenH, lighting);
const light = lighting.light; const light = lighting.light;
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting); const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
renderPerfEnd(endSetup);
const beginFieldTransform = () => { const beginFieldTransform = () => {
ctx.save(); ctx.save();
@ -1215,6 +1363,7 @@ function render() {
ctx.translate(-cameraX, -cameraY); ctx.translate(-cameraX, -cameraY);
}; };
const endBackground = renderPerfBegin("render.background");
beginFieldTransform(); beginFieldTransform();
if (cachedBackground) { if (cachedBackground) {
ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH); ctx.drawImage(cachedBackground, 0, 0, sceneW, sceneH);
@ -1223,28 +1372,39 @@ function render() {
drawGardenBed(sceneW, sceneH, lighting); drawGardenBed(sceneW, sceneH, lighting);
} }
ctx.restore(); ctx.restore();
drawLightRays(ctx, screenW, screenH, lighting); drawLightRays(ctx, screenW, screenH, lighting);
renderPerfEnd(endBackground);
const visibleRect = visibleWorldRect(world, 180); const visibleRect = visibleWorldRect(world, 180);
beginFieldTransform(); beginFieldTransform();
const endPreviews = renderPerfBegin("render.previews");
drawPlacementPreview(ctx, world); drawPlacementPreview(ctx, world);
drawOperationToolPreview(ctx, world); drawOperationToolPreview(ctx, world);
renderPerfEnd(endPreviews);
drawTerrainLayer(ctx, world, lighting, visibleRect); drawTerrainLayer(ctx, world, lighting, visibleRect);
// Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap. // Render only entities that live in visible spatial cells; this keeps large offscreen colonies cheap.
const renderStack = collectVisibleRenderStack(world, visibleRect); const renderStack = collectVisibleRenderStack(world, visibleRect);
window.TarinaiPerf?.setMetric?.("render.visibleBackItems", renderStack.backItems.length);
window.TarinaiPerf?.setMetric?.("render.visibleLayered", renderStack.layered.length);
window.TarinaiPerf?.setMetric?.("render.visibleEffects", (world.effects || []).length);
const endBackItems = renderPerfBegin("render.draw.backItems");
for (const it of renderStack.backItems) { for (const it of renderStack.backItems) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
} }
renderPerfEnd(endBackItems);
// Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai. // Parent-follow guide lines intentionally disabled; they looked like stray lines between Tarinai.
const endLayered = renderPerfBegin("render.draw.layered");
for (const entry of renderStack.layered) { for (const entry of renderStack.layered) {
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting); if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
} }
renderPerfEnd(endLayered);
const endAttachments = renderPerfBegin("render.draw.attachments");
for (const it of renderStack.carriedPlushies) { for (const it of renderStack.carriedPlushies) {
syncCarriedPlushieToOwner(it, world); syncCarriedPlushieToOwner(it, world);
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
@ -1252,14 +1412,20 @@ function render() {
for (const it of renderStack.lodgedPins) { for (const it of renderStack.lodgedPins) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
} }
renderPerfEnd(endAttachments);
const endEffects = renderPerfBegin("render.draw.effects");
for (const ef of world.effects) { for (const ef of world.effects) {
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
} }
renderPerfEnd(endEffects);
ctx.restore(); ctx.restore();
// Light affecting the whole visible scene, including characters and ground. // Light affecting the whole visible scene, including characters and ground.
const endLighting = renderPerfBegin("render.lightingWeather");
drawAtmosphericLighting(ctx, screenW, screenH, lighting); drawAtmosphericLighting(ctx, screenW, screenH, lighting);
if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
renderPerfEnd(endLighting);
const drawAtScreenPosition = (entity, drawFn) => { const drawAtScreenPosition = (entity, drawFn) => {
if (!entity) return; if (!entity) return;
@ -1275,9 +1441,13 @@ function render() {
entity.y = oy; entity.y = oy;
} }
}; };
const endUiOverlay = renderPerfBegin("render.uiOverlay");
drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting));
drawPointerItemTooltip(ctx, world); drawPointerItemTooltip(ctx, world);
drawSelectedToolCursorIcon(ctx, world);
renderPerfEnd(endUiOverlay);
const endHud = renderPerfBegin("render.hud");
// Time HUD // Time HUD
ctx.save(); ctx.save();
const hudW = 150; const hudW = 150;
@ -1305,6 +1475,7 @@ function render() {
ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)"; ctx.fillStyle = light > 0.42 ? "rgba(84,72,55,0.42)" : "rgba(230,235,255,0.48)";
ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12); ctx.fillText(`${window.__tarinaiFps || 0} fps`, w - 14, h - 12);
ctx.restore(); ctx.restore();
renderPerfEnd(endHud);
} }

View file

@ -16,7 +16,6 @@
} }
function syncRestoreDependents(worldRef = global.world, context = {}) { function syncRestoreDependents(worldRef = global.world, context = {}) {
global.TarinaiFreezeSystem?.afterWorldRestored?.(worldRef, context);
worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" }); worldRef?.emit?.("ui:restore-sync", { source: context.source || "restore" });
renderWorldAfterRestore(worldRef); renderWorldAfterRestore(worldRef);
} }

View file

@ -571,7 +571,7 @@
if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) { if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
try { try {
const parsed = JSON.parse(reader.str() || "[]"); const parsed = JSON.parse(reader.str() || "[]");
return Array.isArray(parsed) ? parsed : []; return (Array.isArray(parsed) || (parsed && typeof parsed === "object")) ? parsed : [];
} catch (_) { } catch (_) {
return []; return [];
} }

View file

@ -4,8 +4,8 @@
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js. // Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
// Current-format save identifiers only. // Current-format save identifiers only.
(function (global) { (function (global) {
const SNAPSHOT_VERSION = 7; const SNAPSHOT_VERSION = 11;
const BINARY_SCHEMA_VERSION = 10; const BINARY_SCHEMA_VERSION = 14;
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]);
@ -21,7 +21,7 @@
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod" "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block"
]); ]);
function typeIds(names) { function typeIds(names) {
@ -32,10 +32,10 @@
const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]); const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]);
const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]); const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]);
const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]); const FENCE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence"]);
const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator"]); const MECHANICAL_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator"]);
const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]); const LINK_ITEM_TYPE_IDS = typeIds(["rope", "rod"]);
const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "reciprocator", "rope", "rod"]); const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator"]); const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]);
function enumIndex(list, value, fallback = 0) { function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? "")); const i = list.indexOf(String(value ?? ""));

View file

@ -64,6 +64,58 @@ function stableUnit(seed, salt = "") {
return ((h >>> 0) % 100000) / 100000; return ((h >>> 0) % 100000) / 100000;
} }
function stableKeyPart(value) {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
}
function deterministicFrame(worldRef = null, hz = 60) {
const t = Number(worldRef?.time || 0) || 0;
return Math.floor(t * Math.max(1, Number(hz) || 60) + 1e-6);
}
function deterministicUnit(worldRef = null, salt = "", ...parts) {
const seed = String(worldRef?.worldSeed || "tarinai-world");
const frame = deterministicFrame(worldRef, 60);
const serial = Number(worldRef?._deterministicEpoch || 0) || 0;
const key = [salt, frame, serial, ...parts.map(stableKeyPart)].join("|");
return stableUnit(seed, key);
}
function deterministicRange(worldRef = null, salt = "", min = 0, max = 1, ...parts) {
const a = Number(min) || 0;
const b = Number(max) || 0;
return a + deterministicUnit(worldRef, salt, ...parts) * (b - a);
}
function deterministicSigned(worldRef = null, salt = "", ...parts) {
return deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1;
}
function deterministicChance(worldRef = null, salt = "", chance = 0, ...parts) {
const p = clamp(Number(chance) || 0, 0, 1);
if (p <= 0) return false;
if (p >= 1) return true;
return deterministicUnit(worldRef, salt, ...parts) < p;
}
function deterministicAngle(worldRef = null, salt = "", ...parts) {
return deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts);
}
if (typeof window !== "undefined") {
window.TarinaiDeterminism = { stableKeyPart, deterministicFrame, deterministicUnit, deterministicRange, deterministicSigned, deterministicChance, deterministicAngle };
}
const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"]; const GENETIC_KEYS = ["lifeSpanMul", "attackMul", "speedMul", "sizeMul"];
const GENETIC_LIMITS = Object.freeze({ const GENETIC_LIMITS = Object.freeze({
lifeSpanMul: [0.82, 1.24], lifeSpanMul: [0.82, 1.24],
@ -653,10 +705,79 @@ class SpatialGrid {
bucket.push(entity); bucket.push(entity);
} }
addAabb(map, entity, bounds) {
if (!entity || !bounds) return this.add(map, entity);
const left = Number(bounds.left);
const right = Number(bounds.right);
const top = Number(bounds.top);
const bottom = Number(bounds.bottom);
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) return this.add(map, entity);
const minX = Math.floor(Math.min(left, right) / this.cellSize);
const maxX = Math.floor(Math.max(left, right) / this.cellSize);
const minY = Math.floor(Math.min(top, bottom) / this.cellSize);
const maxY = Math.floor(Math.max(top, bottom) / this.cellSize);
const cellCount = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1));
if (cellCount > 144) return this.add(map, entity);
for (let cy = minY; cy <= maxY; cy += 1) {
for (let cx = minX; cx <= maxX; cx += 1) {
const key = cx + cy * 100000;
let bucket = map.get(key);
if (!bucket) {
bucket = [];
map.set(key, bucket);
}
bucket.push(entity);
}
}
}
boundsForTrait(it, trait) {
const mech = (typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem;
if (mech?.isMechanicalType?.(it?.type)) {
if (trait === "hazard" && it.type === "poison_block") return mech.hazardBoundsAabb?.(it) || mech.boundsAabb?.(it);
return mech.boundsAabb?.(it);
}
const ropePath = it?.type === "rope" ? ((typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.particles?.(it) || null) : null;
if (ropePath && ropePath.length) {
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
for (const p of ropePath) {
left = Math.min(left, (Number(p.x) || 0) - 8);
right = Math.max(right, (Number(p.x) || 0) + 8);
top = Math.min(top, (Number(p.y) || 0) - 8);
bottom = Math.max(bottom, (Number(p.y) || 0) + 8);
}
if (Number.isFinite(left)) return { left, right, top, bottom, item: it, type: it.type };
}
const r = Math.max(Number(it?.r || it?.radius || 0) || 0, 12);
if (r > this.cellSize * 0.72) return { left: (it.x || 0) - r, right: (it.x || 0) + r, top: (it.y || 0) - r, bottom: (it.y || 0) + r, item: it, type: it?.type };
return null;
}
addTrait(map, it, trait) {
const bounds = this.boundsForTrait(it, trait);
if (bounds) this.addAabb(map, it, bounds);
else this.add(map, it);
}
isDynamicItem(it) { isDynamicItem(it) {
if (!it || it.dead) return false; if (!it || it.dead) return false;
const type = it.type || ""; const type = it.type || "";
if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo" || (type === "rotator" && Math.abs(Number(it.rotatorSpeed || 0)) > 0.001)) return true; if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (type === "rope" || type === "rod") return true;
const globalRef = (typeof window !== "undefined" ? window : globalThis);
const physics = globalRef.TarinaiPhysicsBodySystem;
const body = physics?.isBodyType?.(it) ? physics.ensureBody?.(it, null, { syncFromLegacy: false }) : null;
if (type === "rotator") {
const motor = body?.motor || {};
const velocity = body?.velocity || {};
return Math.abs(Number(motor.speed || 0)) > 0.001 || Math.abs(Number(velocity.angular || 0)) > 0.001 || motor.powered !== false;
}
if (type === "reciprocator") {
const motor = body?.motor || {};
const velocity = body?.velocity || {};
return motor.powered !== false || Math.abs(Number(velocity.linear || 0)) > 0.05;
}
if (type === "poison_block") return Boolean(globalRef.TarinaiMechanicalSystem?.passiveItemAwake?.(it));
if ((it.dropTimer || 0) > 0) return true; if ((it.dropTimer || 0) > 0) return true;
if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true; if (Math.hypot(it.vx || 0, it.vy || 0) > 0.05) return true;
if (it.isStructure && it.type === "plushie" && it.carriedById) return true; if (it.isStructure && it.type === "plushie" && it.carriedById) return true;
@ -668,9 +789,19 @@ class SpatialGrid {
const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells; const obstacleMap = dynamic ? this.dynamicObstacleCells : this.staticObstacleCells;
const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells; const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells;
const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells; const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells;
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v")) this.add(obstacleMap, it); const hasObstacle = typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v");
if (typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)))) this.add(foodMap, it); const hasFood = typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)));
if (typeof itemHasTrait === "function" && itemHasTrait(type, "hazard")) this.add(hazardMap, it); const hasHazard = typeof itemHasTrait === "function" && itemHasTrait(type, "hazard");
if (type === "poison_block") {
const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.ensureBody?.(it, null, { syncFromLegacy: false });
const solid = body?.collision ? body.collision.solid !== false : true;
if (solid && hasObstacle) this.addTrait(obstacleMap, it, "obstacle");
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
return;
}
if (hasObstacle) this.addTrait(obstacleMap, it, "obstacle");
if (hasFood) this.addTrait(foodMap, it, "food_interest");
if (hasHazard) this.addTrait(hazardMap, it, "hazard");
} }
classifyItem(it) { classifyItem(it) {
@ -763,6 +894,7 @@ class SpatialGrid {
nearbyInto(map, x, y, radius, out = [], filterDistance = false) { nearbyInto(map, x, y, radius, out = [], filterDistance = false) {
const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12); const r = Number.isFinite(radius) ? radius : Math.max(1200, this.cellSize * 12);
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
const minX = Math.floor((x - r) / this.cellSize); const minX = Math.floor((x - r) / this.cellSize);
const maxX = Math.floor((x + r) / this.cellSize); const maxX = Math.floor((x + r) / this.cellSize);
const minY = Math.floor((y - r) / this.cellSize); const minY = Math.floor((y - r) / this.cellSize);
@ -772,6 +904,8 @@ class SpatialGrid {
const bucket = map.get(cx + cy * 100000); const bucket = map.get(cx + cy * 100000);
if (!bucket) continue; if (!bucket) continue;
for (const entity of bucket) { for (const entity of bucket) {
if (!entity || entity._spatialQueryStamp === stamp) continue;
entity._spatialQueryStamp = stamp;
if (filterDistance) { if (filterDistance) {
const dx = (entity.x || 0) - x; const dx = (entity.x || 0) - x;
const dy = (entity.y || 0) - y; const dy = (entity.y || 0) - y;
@ -794,6 +928,7 @@ class SpatialGrid {
nearbyRectInto(map, rect, out = []) { nearbyRectInto(map, rect, out = []) {
if (!rect) return out; if (!rect) return out;
const stamp = (this._spatialQueryStamp = (this._spatialQueryStamp || 0) + 1);
const minX = Math.floor((rect.left || 0) / this.cellSize); const minX = Math.floor((rect.left || 0) / this.cellSize);
const maxX = Math.floor((rect.right || 0) / this.cellSize); const maxX = Math.floor((rect.right || 0) / this.cellSize);
const minY = Math.floor((rect.top || 0) / this.cellSize); const minY = Math.floor((rect.top || 0) / this.cellSize);
@ -802,7 +937,11 @@ class SpatialGrid {
for (let cx = minX; cx <= maxX; cx++) { for (let cx = minX; cx <= maxX; cx++) {
const bucket = map.get(cx + cy * 100000); const bucket = map.get(cx + cy * 100000);
if (!bucket) continue; if (!bucket) continue;
for (const entity of bucket) out.push(entity); for (const entity of bucket) {
if (!entity || entity._spatialQueryStamp === stamp) continue;
entity._spatialQueryStamp = stamp;
out.push(entity);
}
} }
} }
return out; return out;

View file

@ -7,36 +7,99 @@
const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; const helpers = () => global.TarinaiSimulationRuntime?.helpers || {};
const updatePolicy = () => global.TarinaiCreatureUpdatePolicy || {}; const updatePolicy = () => global.TarinaiCreatureUpdatePolicy || {};
function inc(map, key, n = 1) {
const k = String(key || "none");
map[k] = (map[k] || 0) + n;
}
function updateCreatures(worldRef, dt) { function updateCreatures(worldRef, dt) {
const h = helpers(); const h = helpers();
const end = global.TarinaiPerf?.begin?.("update.tarinai") || null; const end = global.TarinaiPerf?.begin?.("update.tarinai.individual") || null;
const visibleRect = h.updateVisibleWorldRect?.(worldRef, 160) || { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; const visibleRect = h.updateVisibleWorldRect?.(worldRef, 160) || { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
const now = worldRef.time || 0;
const stats = {
total: 0,
full: 0,
realtime: 0,
smooth: 0,
skipped: 0,
selected: 0,
urgent: 0,
visible: 0,
near: 0,
far: 0,
lanes: {},
states: {},
smoothStates: {},
skippedStates: {},
sleepPhysical: 0,
passivePhysical: 0,
};
const prevSpatialDeferMode = worldRef.deferSpatialRebuildMode || "";
worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1;
// Smooth-motion Tarinai pass may read obstacle/item grids after Tarinai and
// constraint movement have dirtied mobile buckets. Use the last coherent
// grid during this phase and collapse mobile invalidations into one rebuild
// after all visible bodies have been advanced.
worldRef.deferSpatialRebuildMode = "mobile";
try { try {
for (const t of worldRef.tarinai) { for (const t of worldRef.tarinai) {
if (!t || t.dead) continue; if (!t || t.dead) continue;
const interval = updatePolicy().updateInterval?.(worldRef, t, visibleRect) ?? 0; stats.total += 1;
const behaviorId = updatePolicy().behaviorId?.(t) || t.state || "none";
inc(stats.states, behaviorId);
const cadence = updatePolicy().updateCadence?.(worldRef, t, visibleRect) || { interval: 0, lane: "legacy", smoothMotion: false, visible: true, urgent: false };
const interval = Number.isFinite(cadence.interval) ? cadence.interval : Infinity;
inc(stats.lanes, cadence.lane || "unknown");
if (worldRef.selected === t) stats.selected += 1;
if (cadence.urgent) stats.urgent += 1;
if (cadence.sleepPhysical) stats.sleepPhysical += 1;
if (cadence.passivePhysicsNeed) stats.passivePhysical += 1;
if (cadence.visible) stats.visible += 1;
else if (interval < 0.75) stats.near += 1;
else stats.far += 1;
if (!Number.isFinite(interval)) continue; if (!Number.isFinite(interval)) continue;
const context = { cadence, motionDt: dt };
if (interval <= 0) { if (interval <= 0) {
const runDt = Math.min(1.4, (t._updateAccum || 0) + dt); const runDt = Math.min(1.4, (t._updateAccum || 0) + dt);
t._updateAccum = 0; t._updateAccum = 0;
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); stats.full += 1;
stats.realtime += 1;
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
else t.update(runDt); else t.update(runDt);
continue; continue;
} }
t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt); t._updateAccum = Math.min(2.4, (t._updateAccum || 0) + dt);
t._nextLowFreqUpdateAt = Number.isFinite(t._nextLowFreqUpdateAt) ? t._nextLowFreqUpdateAt : (worldRef.time || 0) + interval * (0.65 + Math.random() * 0.7); if (!Number.isFinite(t._nextLowFreqUpdateAt)) {
if ((worldRef.time || 0) >= t._nextLowFreqUpdateAt) { const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || Math.random(), "creature-cadence") : Math.random();
t._nextLowFreqUpdateAt = now + interval * (0.65 + jitter * 0.70);
}
if (now >= t._nextLowFreqUpdateAt) {
const runDt = Math.max(dt, t._updateAccum || dt); const runDt = Math.max(dt, t._updateAccum || dt);
const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || Math.random(), `creature-cadence:${Math.floor(now * 2)}`) : Math.random();
t._updateAccum = 0; t._updateAccum = 0;
t._nextLowFreqUpdateAt = (worldRef.time || 0) + interval; t._nextLowFreqUpdateAt = now + interval * (0.92 + jitter * 0.22);
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt); stats.full += 1;
if (global.TarinaiCreatureRuntime?.updateOne) global.TarinaiCreatureRuntime.updateOne(t, runDt, { context });
else t.update(runDt); else t.update(runDt);
} else if (cadence.smoothMotion && global.TarinaiCreatureRuntime?.updateMotionOnly) {
if (global.TarinaiCreatureRuntime.updateMotionOnly(t, dt, { context })) {
stats.smooth += 1;
inc(stats.smoothStates, behaviorId);
}
} else {
stats.skipped += 1;
inc(stats.skippedStates, behaviorId);
} }
} }
} finally { } finally {
worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1);
worldRef.deferSpatialRebuildMode = prevSpatialDeferMode;
worldRef._creatureUpdateStats = stats;
if (end) end(); if (end) end();
} }
const now = worldRef.time || 0;
const bedConflictInterval = 3.2; const bedConflictInterval = 3.2;
if (now >= (worldRef.nextBedConflictCheckAt || 0)) { if (now >= (worldRef.nextBedConflictCheckAt || 0)) {
worldRef.nextBedConflictCheckAt = now + bedConflictInterval; worldRef.nextBedConflictCheckAt = now + bedConflictInterval;
@ -45,15 +108,29 @@
} else { } else {
worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1); worldRef.workStats && (worldRef.workStats.bedConflictSkips = (worldRef.workStats.bedConflictSkips || 0) + 1);
} }
h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25); const endSpatial = global.TarinaiPerf?.begin?.("update.tarinai.spatial") || null;
worldRef.ensureSpatial?.("post-tarinai-update"); const tarinaiMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.tarinai, "tarinai-moved", 0.25) || false;
if (tarinaiMoved || worldRef.spatialDirty) {
if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-tarinai-update");
else worldRef.ensureSpatial?.("post-tarinai-update");
worldRef.deferredSpatialDirtyReason = "";
}
if (endSpatial) endSpatial();
const endBallInteractions = global.TarinaiPerf?.begin?.("update.ballInteractions") || null;
worldRef.limitBallChasers(); worldRef.limitBallChasers();
worldRef.resolveBallInteractions(dt); worldRef.resolveBallInteractions(dt);
const collisionInterval = 1.8; if (endBallInteractions) endBallInteractions();
if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) { const hasFastTarinai = (worldRef.tarinai || []).some(t => {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) return false;
return Math.hypot(t.vx || 0, t.vy || 0) >= 150;
});
const collisionInterval = hasFastTarinai ? 0 : 0.55;
if (hasFastTarinai || now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval; worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval;
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1); worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
worldRef.resolveTarinaiHighSpeedCollisions(dt); const endCollision = global.TarinaiPerf?.begin?.("update.tarinai.collisions") || null;
try { worldRef.resolveTarinaiHighSpeedCollisions(dt); }
finally { if (endCollision) endCollision(); }
} else { } else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1); worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
} }

View file

@ -42,17 +42,34 @@
function updateItemsAndAnts(worldRef, dt) { function updateItemsAndAnts(worldRef, dt) {
const h = helpers(); const h = helpers();
const itemCountBefore = worldRef.items.length; const itemCountBefore = worldRef.items.length;
h.updateItemsScheduled?.(worldRef, dt); const prevMode = worldRef.deferSpatialRebuildMode || "";
updateRandomGrassGrowth(worldRef, dt); worldRef.deferSpatialRebuildDepth = (worldRef.deferSpatialRebuildDepth || 0) + 1;
const itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false; worldRef.deferSpatialRebuildMode = "mobile";
if (worldRef.itemCounts?.ball) { let itemsMoved = false;
worldRef.ensureSpatial?.("ball-ball-collisions"); let antsMoved = false;
worldRef.resolveBallBallCollisions(dt); try {
worldRef.markSpatialDirty?.("ball-ball-collisions"); h.updateItemsScheduled?.(worldRef, dt);
updateRandomGrassGrowth(worldRef, dt);
itemsMoved = h.markScheduledItemsMoved?.(worldRef, "items-moved", 0.25) || false;
if (worldRef.itemCounts?.ball) {
worldRef.ensureSpatial?.("ball-ball-collisions");
worldRef.resolveBallBallCollisions(dt);
worldRef.markSpatialDirty?.("ball-ball-collisions");
}
worldRef.updateAnts?.(dt);
antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false;
} finally {
worldRef.deferSpatialRebuildDepth = Math.max(0, (worldRef.deferSpatialRebuildDepth || 1) - 1);
worldRef.deferSpatialRebuildMode = prevMode;
}
if (itemsMoved || antsMoved || worldRef.spatialDirty) {
// Mobile items/ants are obstacle and contact sources for the next phase.
// Force one fresh grid after the mobile phase; reads inside the phase are
// allowed to use the frame-start grid to avoid rebuild ping-pong.
if (worldRef.rebuildSpatial) worldRef.rebuildSpatial(true, worldRef.deferredSpatialDirtyReason || "post-mobile-item-update");
else worldRef.ensureSpatial?.("post-mobile-item-update");
worldRef.deferredSpatialDirtyReason = "";
} }
worldRef.updateAnts?.(dt);
const antsMoved = h.markSpatialDirtyIfMoved?.(worldRef, worldRef.ants || [], "ants-moved", 0.25) || false;
if (itemsMoved || antsMoved || worldRef.spatialDirty) worldRef.ensureSpatial?.("post-mobile-item-update");
return { itemCountBefore, itemsMoved, antsMoved }; return { itemCountBefore, itemsMoved, antsMoved };
} }

View file

@ -322,50 +322,11 @@
if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0]; if (type === "grass") return [q(item.growth, 100), q(item.health, 100), q(item.fertilityBoost, 100), item.manualGrass || item.placedByPlayer ? 1 : 0];
if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)]; if (type === "zunchi") return [enumIndex(STAGE_IDS, item.stage || "fresh"), q(item.freshness, 100), q(item.fertility, 100)];
if (type === "signboard") return [item.text || ""]; if (type === "signboard") return [item.text || ""];
if (type === "rotator") return [ if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) {
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), const packed = global.TarinaiPhysicsBodySystem.compactItemExtra?.(item, tarinaiIndex, itemIndex);
q(item.rotatorSpeed, 1000), if (packed) return packed;
q(item.rotatorThickness || 12, 10),
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)]),
item.rotatorPowered === false ? 0 : 1,
q(item.rotatorAngularVelocity || 0, 1000)
];
if (type === "reciprocator") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
q(Number.isFinite(Number(item.reciprocatorAxisAngle)) ? item.reciprocatorAxisAngle : item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
item.reciprocatorPowered === false ? 0 : 1,
q(item.reciprocatorSpeed || 92, 10),
q(item.reciprocatorTravel || 150, 10),
q(item.reciprocatorPhase || 0, 1000),
q(item.reciprocatorVelocity || 0, 10),
q(item.reciprocatorAnchorX || item.x || 0, 1),
q(item.reciprocatorAnchorY || item.y || 0, 1),
q(item.reciprocatorDirection || 1, 1),
q(item.rotatorThickness || 12, 10),
(Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]]).map(seg => [q(seg[0], 1), q(seg[1], 1), q(seg[2], 1), q(seg[3], 1)])
];
if (type === "rope" || type === "rod") {
const cloneEndpoint = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = kind === "tarinai" ? (tarinaiIndex.get(ep.id) ?? -1) : (itemIndex.get(ep.id) ?? -1);
return {
kind,
id: ep.id || "",
refIdx,
type: ep.type || "",
label: ep.label || "",
localX: q(ep.localX || 0, 1),
localY: q(ep.localY || 0, 1),
center: ep.center ? 1 : 0,
x: q(ep.x || 0, 1),
y: q(ep.y || 0, 1),
attachT: Number.isFinite(Number(ep.attachT)) ? q(ep.attachT, 1000) : null,
};
};
return [cloneEndpoint(item.linkA), cloneEndpoint(item.linkB), q(item.linkLength || item.r * 2 || 80, 1), q(item.linkMidX || item.x || 0, 1), q(item.linkMidY || item.y || 0, 1), q(item.linkMidVX || 0, 10), q(item.linkMidVY || 0, 10)];
} }
if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) return { pf: 2, missing: true };
if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)]; if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)]; if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)];
if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)];
@ -411,67 +372,13 @@
item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1); item.stage = enumValue(STAGE_IDS, extra[0], "fresh"); item.freshness = u(extra[1], 100, item.freshness || 1); item.fertility = u(extra[2], 100, item.fertility || 1);
} else if (type === "signboard") { } else if (type === "signboard") {
item.text = String(extra[0] || ""); item.text = String(extra[0] || "");
} else if (type === "rotator") { } else if (global.TarinaiPhysicsBodySystem?.applyCompactExtra?.(item, extra, tarinaiList, itemList)) {
const e = Array.isArray(extra) ? extra : []; return;
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; } else if (global.TarinaiPhysicsBodySystem?.isPhysicsType?.(type)) {
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback); // Physics items only accept the normalized pf:2 payload from this schema.
item.rotatorSpeed = u(e[1], 1000, item.rotatorSpeed || 0); // Old array payloads are intentionally unsupported.
item.rotatorThickness = Math.max(4, Math.min(34, u(e[2], 10, item.rotatorThickness || 12))); global.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "physics-extra-missing");
item.rotatorSegments = (Array.isArray(e[3]) ? e[3] : [[-78,0,78,0],[0,-52,0,52]]).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4); return;
item.rotatorPowered = e.length >= 5 ? !!e[4] : item.rotatorPowered !== false;
item.rotatorAngularVelocity = u(e[5], 1000, item.rotatorAngularVelocity || 0);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "reciprocator") {
const e = Array.isArray(extra) ? extra : [];
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[0], 1000, fallback), fallback) : u(e[0], 1000, fallback);
item.reciprocatorAxisAngle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(e[1], 1000, item.angle), item.angle) : u(e[1], 1000, item.angle);
item.reciprocatorPowered = e.length >= 3 ? !!e[2] : item.reciprocatorPowered !== false;
item.reciprocatorSpeed = Math.max(0, u(e[3], 10, item.reciprocatorSpeed || 92));
item.reciprocatorTravel = Math.max(24, u(e[4], 10, item.reciprocatorTravel || 150));
item.reciprocatorPhase = clamp(u(e[5], 1000, item.reciprocatorPhase || 0), -1, 1);
item.reciprocatorVelocity = u(e[6], 10, item.reciprocatorVelocity || 0);
item.reciprocatorAnchorX = u(e[7], 1, item.reciprocatorAnchorX || item.x || 0);
item.reciprocatorAnchorY = u(e[8], 1, item.reciprocatorAnchorY || item.y || 0);
item.reciprocatorDirection = u(e[9], 1, item.reciprocatorDirection || 1) || 1;
item.rotatorThickness = Math.max(4, Math.min(34, u(e[10], 10, item.rotatorThickness || 12)));
item.rotatorSegments = (Array.isArray(e[11]) ? e[11] : (Array.isArray(item.rotatorSegments) ? item.rotatorSegments : [[-78,0,78,0]])).map(seg => [u(seg[0],1), u(seg[1],1), u(seg[2],1), u(seg[3],1)]).filter(seg => Math.hypot(seg[2]-seg[0], seg[3]-seg[1]) >= 4);
if (!item.rotatorSegments.length) item.rotatorSegments = [[-78,0,78,0]];
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
} else if (type === "rope" || type === "rod") {
const e = Array.isArray(extra) ? extra : [];
const normalizeEndpoint = ep => {
if (!ep) return null;
const kind = ep.kind || "item";
const refIdx = Number(ep.refIdx);
const indexedTarget = Number.isInteger(refIdx) && refIdx >= 0
? (kind === "tarinai" ? tarinaiList[refIdx] : itemList[refIdx])
: null;
return {
kind,
id: indexedTarget?.id || ep.id || "",
type: ep.type || indexedTarget?.type || "",
label: ep.label || indexedTarget?.name || "",
localX: u(ep.localX, 1, 0),
localY: u(ep.localY, 1, 0),
center: !!ep.center,
x: u(ep.x, 1, item.x || 0),
y: u(ep.y, 1, item.y || 0),
attachT: ep.attachT == null ? null : clamp(u(ep.attachT, 1000, 0.5), 0, 1),
fuzzyResolve: !indexedTarget && !Number.isInteger(refIdx),
};
};
item.linkA = normalizeEndpoint(e[0]);
item.linkB = normalizeEndpoint(e[1]);
item.linkLength = Math.max(24, u(e[2], 1, item.linkLength || 80));
item.linkMidX = u(e[3], 1, item.linkMidX || item.x || 0);
item.linkMidY = u(e[4], 1, item.linkMidY || item.y || 0);
item.linkMidVX = u(e[5], 10, item.linkMidVX || 0);
item.linkMidVY = u(e[6], 10, item.linkMidVY || 0);
item.r = Math.max(18, item.linkLength * 0.5);
} else if (type === "duplicator") { } else if (type === "duplicator") {
item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType); item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.foodLabel ? global.foodLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball") { } else if (type === "ball") {
@ -564,7 +471,7 @@
const row = itemRows[idx] || []; const row = itemRows[idx] || [];
const type = enumValue(ITEM_TYPE_IDS, row[0], ""); const type = enumValue(ITEM_TYPE_IDS, row[0], "");
if (!type) continue; if (!type) continue;
const extra = Array.isArray(row[4]) ? row[4] : []; const extra = (Array.isArray(row[4]) || (row[4] && typeof row[4] === "object")) ? row[4] : [];
const owner = global.StructureRegistry?.get?.(type) ? worldRef.tarinai[extra[2]] || null : null; const owner = global.StructureRegistry?.get?.(type) ? worldRef.tarinai[extra[2]] || null : null;
const item = global.StructureRegistry?.get?.(type) const item = global.StructureRegistry?.get?.(type)
? global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef) ? global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef)

View file

@ -30,7 +30,15 @@
const frameDt = normalize(worldRef, dt); const frameDt = normalize(worldRef, dt);
if (frameDt === null) return; if (frameDt === null) return;
const ctx = { dt: frameDt, mobile: null }; const ctx = { dt: frameDt, mobile: null };
for (const system of SYSTEM_ORDER) system.run(worldRef, ctx); const profiler = global.TarinaiPerf;
for (const system of SYSTEM_ORDER) {
const end = profiler?.begin?.(`update.phase.${system.id}`) || null;
try {
system.run(worldRef, ctx);
} finally {
if (end) end();
}
}
} }
global.TarinaiSystemOrder = Object.freeze({ global.TarinaiSystemOrder = Object.freeze({

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
function tarinaiRoundRectPath(ctx, x, y, w, h, r) { function tarinaiRoundRectPath(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r || 0, Math.abs(w) / 2, Math.abs(h) / 2)); const rr = Math.max(0, Math.min(r || 0, Math.abs(w) / 2, Math.abs(h) / 2));
@ -182,7 +212,7 @@ class Tarinai { constructor(world, opts = {}) {
this.children = opts.children || []; this.children = opts.children || [];
this.nextEatSound = opts.nextEatSound ?? 0; this.nextEatSound = opts.nextEatSound ?? 0;
this.fightTimer = opts.fightTimer ?? 0; this.fightTimer = opts.fightTimer ?? 0;
this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown); this.fightCooldown = opts.fightCooldown ?? deterministicRange(world, "initial-fight-cooldown", 0, CONFIG.fightCooldown, this.familyKey || this.id || opts.birthSeed);
this.fightTargetId = opts.fightTargetId || null; this.fightTargetId = opts.fightTargetId || null;
this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []); this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []);
this.nextHeadbutt = opts.nextHeadbutt ?? 0; this.nextHeadbutt = opts.nextHeadbutt ?? 0;

View file

@ -37,6 +37,31 @@ function stopFailedBuildPlan(t, world, type = "", reason = "") {
return false; return false;
} }
function forceBuiltStructureVisualRefresh(world, structure, reason = "structure-built") {
if (!world) return;
world.drawListDirty = true;
if (world._renderStack) world._renderStack.signature = "";
if (world._visibleRenderStack) {
world._visibleRenderStack.backItems = [];
world._visibleRenderStack.layered = [];
world._visibleRenderStack.carriedPlushies = [];
world._visibleRenderStack.lodgedPins = [];
}
world.markItemBucketsDirty?.(reason);
world.markSpatialDirty?.(reason);
world.ensureItemBuckets?.(reason);
world.ensureSpatial?.(reason);
if (structure) {
structure.world = world;
structure._builtVisibleAt = Number(world.time || 0) || 0;
}
if (typeof globalThis.render === "function") {
globalThis.render();
globalThis.requestAnimationFrame?.(() => globalThis.render?.());
}
}
function findStructureBuildSpot(t, world, type) { function findStructureBuildSpot(t, world, type) {
const baseX = Number.isFinite(t?.x) ? t.x : 0; const baseX = Number.isFinite(t?.x) ? t.x : 0;
const baseY = Number.isFinite(t?.y) ? t.y : 0; const baseY = Number.isFinite(t?.y) ? t.y : 0;
@ -114,9 +139,12 @@ function continueBuildPlan(t, world, dt) {
structure.x = t.x; structure.x = t.x;
structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28); structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28);
} }
world.addItem?.(structure, `build:${plan.type}`) || world.items.push(structure); const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || (world.items.push(structure), structure);
world.drawListDirty = true; if (!addedStructure) {
return stopFailedBuildPlan(t, world, plan.type, "作る場所が見つからない");
}
structure.use?.(t, world); structure.use?.(t, world);
forceBuiltStructureVisualRefresh(world, structure, `build:${plan.type}:visual-refresh`);
t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false });
applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type); applyNeedSatisfaction(t, { fulfill: plan.type === "plushie" ? 50 : 36, safety: plan.type === "plushie" ? 4 : 8 }, plan.type);
t.buildPlan = null; t.buildPlan = null;

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
(function (global) { (function (global) {
const Tarinai = global.Tarinai; const Tarinai = global.Tarinai;
@ -37,12 +67,12 @@
emitZunchiDiseaseEffect(dt) { emitZunchiDiseaseEffect(dt) {
if (!this.zunchiDisease || !this.world?.effects) return; if (!this.zunchiDisease || !this.world?.effects) return;
if (Math.random() < dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4)) { if (deterministicChance(this.world, "zunchi-disease-miasma", dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4), this)) {
this.world.effects.push(new Effect("zunchi_miasma", this.x + rand(-this.radius * 0.45, this.radius * 0.45), this.y - this.radius * rand(0.05, 0.72), { this.world.effects.push(new Effect("zunchi_miasma", this.x + deterministicRange(this.world, "zunchi-miasma-x", -this.radius * 0.45, this.radius * 0.45, this), this.y - this.radius * deterministicRange(this.world, "zunchi-miasma-y", 0.05, 0.72, this), {
vx: rand(-5, 5), vx: deterministicRange(this.world, "zunchi-miasma-vx", -5, 5, this),
vy: rand(-10, -3), vy: deterministicRange(this.world, "zunchi-miasma-vy", -10, -3, this),
life: rand(1.4, 2.4), life: deterministicRange(this.world, "zunchi-miasma-life", 1.4, 2.4, this),
size: rand(8, 18) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45), size: deterministicRange(this.world, "zunchi-miasma-size", 8, 18, this) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45),
color: "rgba(16,78,28,0.70)", color: "rgba(16,78,28,0.70)",
})); }));
} }
@ -55,7 +85,8 @@
this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100); this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100);
if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006); if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006);
} }
if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045)) { const zunchiSelfInfectChance = this.diseaseChance ? this.diseaseChance(dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) : dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045;
if (!this.zunchiDisease && this.zunchiStain > 82 && deterministicChance(this.world, "zunchi-self-infect", zunchiSelfInfectChance, this)) {
this.infectZunchiDisease(null, true); this.infectZunchiDisease(null, true);
this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] }); this.world?.log?.(`${this.name}\u306f\u305a\u3093\u3061\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "accident", { participants: [this] });
} }
@ -238,8 +269,8 @@
const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b);
this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
this.vx = rand(-8, 8); this.vx = deterministicRange(this.world, "sleep-disease-shiver-vx", -8, 8, this);
this.vy = rand(6, 18); this.vy = deterministicRange(this.world, "sleep-disease-shiver-vy", 6, 18, this);
} }
this.insideNestBoxId = null; this.insideNestBoxId = null;
this.nestFade = 0; this.nestFade = 0;
@ -316,32 +347,32 @@
if (this.explosionDisease) { if (this.explosionDisease) {
this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt);
if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 });
if (Math.random() < dt * 2.1) { if (deterministicChance(this.world, "explosion-disease-spark", dt * 2.1, this)) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this.world, "explosion-disease-spark-angle", this);
this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-rx", 0.2, 0.9, this), this.y + Math.sin(a) * this.radius * deterministicRange(this.world, "explosion-disease-spark-ry", 0.1, 0.8, this), {
vx: Math.cos(a) * rand(10, 38), vy: Math.sin(a) * rand(10, 38) - rand(8, 22), size: rand(5, 12), life: rand(0.18, 0.42), color: "rgba(255,64,48,0.86)" vx: Math.cos(a) * deterministicRange(this.world, "explosion-disease-spark-vx", 10, 38, this), vy: Math.sin(a) * deterministicRange(this.world, "explosion-disease-spark-vy", 10, 38, this) - deterministicRange(this.world, "explosion-disease-spark-up", 8, 22, this), size: deterministicRange(this.world, "explosion-disease-spark-size", 5, 12, this), life: deterministicRange(this.world, "explosion-disease-spark-life", 0.18, 0.42, this), color: "rgba(255,64,48,0.86)"
})); }));
} }
if (this.explosionDiseaseTimer <= 0.01) { if (this.explosionDiseaseTimer <= 0.01) {
this.world?.explodeDiseaseTarinai?.(this); this.world?.explodeDiseaseTarinai?.(this);
return; return;
} }
if (this.explosionDiseaseTimer < 8 && Math.random() < dt * 0.45) this.bubble("!", 1.0, "rgba(178,78,42,0.82)"); if (this.explosionDiseaseTimer < 8 && deterministicChance(this.world, "explosion-disease-bubble", dt * 0.45, this)) this.bubble("!", 1.0, "rgba(178,78,42,0.82)");
} }
const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999); const bleedAge = (this.world?.time || 0) - (this.lastBleedAt || -999);
if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) { if (!this.fightDisease && this.fightDiseaseCooldown <= 0 && bleedAge >= (CONFIG.dayLength || 120) * 5 / 24 && bleedAge < (CONFIG.dayLength || 120) * 0.85) {
if (Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.000375) : dt * 0.000375)) this.infectFightDisease(); if (deterministicChance(this.world, "fight-disease-from-bleed", this.diseaseChance ? this.diseaseChance(dt * 0.000375) : dt * 0.000375, this)) this.infectFightDisease();
} }
if (this.zunchiDisease && Math.random() < dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0)) { if (this.zunchiDisease && deterministicChance(this.world, "zunchi-natural-recover", dt * 0.00055 * (this.energy > 66 ? 1.8 : 1.0), this)) {
if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] });
} }
if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { if (this.fightDisease && deterministicChance(this.world, "fight-disease-natural-recover", dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0), this)) {
this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f");
} }
if (this.fightDisease) { if (this.fightDisease) {
if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 3, safety: dt * 2 }); if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 3, safety: dt * 2 });
if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); if (deterministicChance(this.world, "fight-disease-wander", dt * 0.30, this)) this.wanderAngle += deterministicRange(this.world, "fight-disease-wander-angle", -2.8, 2.8, this);
if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); if (deterministicChance(this.world, "fight-disease-bubble", dt * 0.08, this)) this.bubble("?", 2.6, "rgba(150,78,44,0.80)");
} }
} }
})); }));

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Need planning runtime and Tarinai prototype extensions. // Need planning runtime and Tarinai prototype extensions.
// Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules. // Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules.
@ -600,7 +630,7 @@ function resolveNeedsCore(dt) {
this.fightTargetIds = []; this.fightTargetIds = [];
this.fightTargetId = null; this.fightTargetId = null;
this.counterAttackFromId = null; this.counterAttackFromId = null;
this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this.world, "fight-lost-target-cooldown", 4, 8, this, rival));
if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6); if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6);
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival"); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival");
this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); this.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true });

View file

@ -18,9 +18,21 @@
return true; return true;
} }
function updateOne(tarinai, dt) { function updateOne(tarinai, dt, options = {}) {
if (!tarinai || tarinai.dead || !global.TarinaiUpdatePipeline?.updateOne) return false; if (!tarinai || tarinai.dead || !global.TarinaiUpdatePipeline?.updateOne) return false;
return global.TarinaiUpdatePipeline.updateOne(tarinai, dt) !== false; return global.TarinaiUpdatePipeline.updateOne(tarinai, dt, options) !== false;
}
function updateMotionOnly(tarinai, dt, options = {}) {
if (!tarinai || tarinai.dead || !global.TarinaiMovementUpdateStep?.update) return false;
tarinai.prevX = tarinai.x;
tarinai.prevY = tarinai.y;
const motionDt = Math.max(0.001, Math.min(0.05, Number(options.context?.motionDt ?? dt) || 0.016));
return global.TarinaiMovementUpdateStep.update(tarinai, motionDt, {
...(options.context || {}),
motionOnly: true,
motionDt,
})?.done !== true || !tarinai.dead;
} }
function updateBatch(tarinaiList, dt) { function updateBatch(tarinaiList, dt) {
@ -30,6 +42,6 @@
return ran; return ran;
} }
global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateBatch }); global.TarinaiCreatureRuntime = Object.freeze({ installPrototypeBoundary, updateOne, updateMotionOnly, updateBatch });
installPrototypeBoundary(); installPrototypeBoundary();
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Social, panic, fight, mate behavior runtime ticks. // Social, panic, fight, mate behavior runtime ticks.
@ -77,7 +107,7 @@ function updateFightBehavior(t, world, dt) {
t.fightTargetIds = []; t.fightTargetIds = [];
t.fightTargetId = null; t.fightTargetId = null;
t.counterAttackFromId = null; t.counterAttackFromId = null;
t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(world, "fight-target-lost-cooldown", 4, 8, t, rival));
if (isLiveTarinaiEntity(rival)) world.markFightPairCooldown?.(t, rival, CONFIG.fightCooldown + 6); if (isLiveTarinaiEntity(rival)) world.markFightPairCooldown?.(t, rival, CONFIG.fightCooldown + 6);
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival"); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival");
t.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true }); t.setActionState?.("idle", { target: null, reason: "相手を見失った。", sleeping: false, clearTarget: true });

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
(function (global) { (function (global) {
const Tarinai = global.Tarinai; const Tarinai = global.Tarinai;
@ -35,10 +65,12 @@
} }
} }
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { const zunchiSpreadChanceA = o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1);
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-a", zunchiSpreadChanceA, this, o)) {
if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); if (o.infectZunchiDisease(this)) this.world.log(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c${o.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
} }
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { const zunchiSpreadChanceB = this.diseaseChance ? this.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1);
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && deterministicChance(this.world, "social-zunchi-spread-b", zunchiSpreadChanceB, this, o)) {
if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] }); if (this.infectZunchiDisease(o)) this.world.log(`${o.name}\u306e\u305a\u3093\u3061\u75c5\u304c${this.name}\u306b\u3046\u3064\u3063\u305f\u3002`, "accident", { participants: [o, this] });
} }
@ -58,7 +90,7 @@
nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044";
nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid");
slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid");
if (Math.random() < dt * 0.35) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); if (deterministicChance(this.world, "zunchi-slave-surprise", dt * 0.35, nonSlave, slave)) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18);
continue; continue;
} }
const overlap = this.radius + o.radius - d; const overlap = this.radius + o.radius - d;
@ -66,7 +98,7 @@
const push = (overlap + 6) * (pairBond ? 0.08 : 0.17); const push = (overlap + 6) * (pairBond ? 0.08 : 0.17);
this.vx -= (dx / d) * push; this.vx -= (dx / d) * push;
this.vy -= (dy / d) * push; this.vy -= (dy / d) * push;
if (!pairBond && Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12); if (!pairBond && deterministicChance(this.world, "body-overlap-surprise", dt * 1.2, this, o)) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
} }
if (pairBond && d > 26 && d < 120) { if (pairBond && d > 26 && d < 120) {
this.vx += (dx / d) * dt * 4.8; this.vx += (dx / d) * dt * 4.8;
@ -96,10 +128,10 @@
const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35; const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35;
if (pulse && this.world.time > this.nextHeadbutt) { if (pulse && this.world.time > this.nextHeadbutt) {
const nx = dx / d, ny = dy / d; const nx = dx / d, ny = dy / d;
this.vx -= nx * rand(24, 44); this.vx -= nx * deterministicRange(this.world, "headbutt-self-x", 24, 44, this, o);
this.vy -= ny * rand(24, 44); this.vy -= ny * deterministicRange(this.world, "headbutt-self-y", 24, 44, this, o);
o.vx += nx * rand(34, 64); o.vx += nx * deterministicRange(this.world, "headbutt-other-x", 34, 64, this, o);
o.vy += ny * rand(34, 64); o.vy += ny * deterministicRange(this.world, "headbutt-other-y", 34, 64, this, o);
if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 8, health: 4 }); if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 8, health: 4 });
if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 }); if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 });
const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2;
@ -114,7 +146,7 @@
o.hurtTimer = Math.max(o.hurtTimer, 1.8); o.hurtTimer = Math.max(o.hurtTimer, 1.8);
this.adjustRelation(o, -0.16, 0.12, "fight"); this.adjustRelation(o, -0.16, 0.12, "fight");
o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight"); o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight");
if (Math.random() < 0.22 || o.energy < 24) { if (deterministicChance(this.world, "headbutt-fall", 0.22, this, o) || o.energy < 24) {
o.fallTimer = Math.max(o.fallTimer, 0.58); o.fallTimer = Math.max(o.fallTimer, 0.58);
o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer); o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer);
o.fallDir = nx > 0 ? 1 : -1; o.fallDir = nx > 0 ? 1 : -1;
@ -124,7 +156,7 @@
this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4));
} }
this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5);
this.nextHeadbutt = this.world.time + rand(0.34, 0.55); this.nextHeadbutt = this.world.time + deterministicRange(this.world, "next-headbutt", 0.34, 0.55, this, o);
} }
} }
@ -144,7 +176,8 @@
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false;
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) { const fightStartChance = dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias;
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && deterministicChance(this.world, "social-fight-start", fightStartChance, this, o)) {
this.conflictTargetId = o.id; this.conflictTargetId = o.id;
o.conflictTargetId = this.id; o.conflictTargetId = this.id;
const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0); const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0);
@ -170,7 +203,7 @@
const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId; const selfBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.actionId;
const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId; const otherBehaviorId = typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.actionId;
const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug; const mateIntent = selfBehaviorId === "approach_mate" || otherBehaviorId === "approach_mate" || loveDrug;
if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) { if (mateIntent && deterministicChance(this.world, "birth-ritual-start", dt * (loveDrug ? 0.78 : 0.30), this, o)) {
this.world.startBirthRitual(this, o); this.world.startBirthRitual(this, o);
} }
} }
@ -183,7 +216,7 @@
this.fearTimer = Math.max(this.fearTimer, 0.18); this.fearTimer = Math.max(this.fearTimer, 0.18);
} }
if (this.loneliness < 12 && Math.random() < dt * 0.002) { if (this.loneliness < 12 && deterministicChance(this.world, "nearby-calm-log", dt * 0.002, this)) {
this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] }); this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`, null, { participants: [this] });
} }
}, },
@ -221,49 +254,97 @@
this.updateNestBoxPresence(dt); this.updateNestBoxPresence(dt);
this.vx = 0; this.vx = 0;
this.vy = 0; this.vy = 0;
this.impulseVx = 0;
this.impulseVy = 0;
return; return;
} }
const marker = window.TarinaiMovementUpdateStep;
const externalActive = marker?.markSleepExternalMotion?.(this, dt, "sleep-full-update") || marker?.sleepExternalMotionActive?.(this);
if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) {
const spot = this.sleepSpotFor(this.target); const spot = this.sleepSpotFor(this.target);
const dx = spot.x - this.x; const dx = spot.x - this.x;
const dy = spot.y - this.y; const dy = spot.y - this.y;
const d = Math.hypot(dx, dy); const d = Math.hypot(dx, dy);
if (d > 2.2) { if (d > 2.2) {
const step = Math.min(d, dt * (12 + bedComfort * 14)); const restore = externalActive ? (2.0 + bedComfort * 2.0) : (12 + bedComfort * 14);
this.x += dx / d * step; const maxRestore = externalActive ? Math.min(d, 3.2) : d;
this.y += dy / d * step; const step = Math.min(maxRestore, dt * restore);
} else { const sx = dx / d * step;
const sy = dy / d * step;
if (this.world?.moveTarinaiWithCollision) this.world.moveTarinaiWithCollision(this, sx, sy, { dt, reason: externalActive ? "tarinai-sleep-restore-weak" : "tarinai-sleep-move" });
else { this.x += sx; this.y += sy; }
} else if (!externalActive && Math.hypot((this.vx || 0) + (this.impulseVx || 0), (this.vy || 0) + (this.impulseVy || 0)) < 0.35) {
this.x = spot.x; this.x = spot.x;
this.y = spot.y; this.y = spot.y;
} }
} }
const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0;
const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0;
const moveDx = ((Number.isFinite(this.vx) ? this.vx : 0) + impulseVx) * dt;
const moveDy = ((Number.isFinite(this.vy) ? this.vy : 0) + impulseVy) * dt;
if (Math.hypot(moveDx, moveDy) > 0.001) {
if (this.world?.moveTarinaiWithCollision) this.world.moveTarinaiWithCollision(this, moveDx, moveDy, { dt, reason: "tarinai-sleep-physical-full", maxChecks: 18, maxStep: 10 });
else { this.x += moveDx; this.y += moveDy; }
}
if (this.world?.resolveSolidObstacleCollision) {
const beforeContactX = Number(this.x || 0) || 0;
const beforeContactY = Number(this.y || 0) || 0;
const pushed = this.world.resolveSolidObstacleCollision(this, { maxPasses: 2, searchRadius: Math.max(72, (Number(this.radius) || 22) + 120), maxChecks: 18, slop: 0.28, reason: "tarinai-sleep-full-contact" });
if (pushed) {
this.lastSolidObstacleCollisionAt = this.world.time || 0;
marker?.markSleepExternalMotion?.(this, dt, "sleep-full-contact");
const cdx = (Number(this.x || 0) || 0) - beforeContactX;
const cdy = (Number(this.y || 0) || 0) - beforeContactY;
if (Math.hypot(cdx, cdy) > 0.05) {
this.vx = clamp((Number(this.vx) || 0) + cdx / Math.max(0.016, dt) * 0.085, -170, 170);
this.vy = clamp((Number(this.vy) || 0) + cdy / Math.max(0.016, dt) * 0.085, -170, 170);
}
}
}
this.updateNestBoxPresence(dt); this.updateNestBoxPresence(dt);
this.vx *= Math.pow(0.55, dt * 60); const bodyDrag = externalActive ? 0.86 : 0.55;
this.vy *= Math.pow(0.55, dt * 60); this.vx *= Math.pow(bodyDrag, dt * 60);
this.vy *= Math.pow(bodyDrag, dt * 60);
const impulseDrag = Math.pow(0.58, dt * 3.2);
this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag;
this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag;
return; return;
} }
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") { if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") {
const partner = this.world.liveTarinaiById?.(this.birthPartnerId); const pendingImpulse = Math.hypot(Number.isFinite(this.impulseVx) ? this.impulseVx : 0, Number.isFinite(this.impulseVy) ? this.impulseVy : 0);
if (partner) { const canceledByForce = this.world?.cancelBirthRitualOnForce?.(this, pendingImpulse, {
const midX = (this.x + partner.x) / 2; reason: "強い衝撃で繁殖が中断された",
const midY = (this.y + partner.y) / 2; target: this.target || null,
const pdx = partner.x - this.x; fear: 1.0,
const pdy = partner.y - this.y; cause: "stored_impulse",
const plen = Math.max(0.001, Math.hypot(pdx, pdy)); panic: true,
const nx = -pdy / plen; silentLog: true,
const ny = pdx / plen; });
const desiredGap = this.radius * 0.36; if (!canceledByForce) {
const side = this.birthRitualRole || 1; const partner = this.world.liveTarinaiById?.(this.birthPartnerId);
const tx = midX + nx * desiredGap * side; if (partner) {
const ty = midY + ny * desiredGap * side; const midX = (this.x + partner.x) / 2;
this.vx += (tx - this.x) * dt * 3.8; const midY = (this.y + partner.y) / 2;
this.vy += (ty - this.y) * dt * 3.8; const pdx = partner.x - this.x;
const pdy = partner.y - this.y;
const plen = Math.max(0.001, Math.hypot(pdx, pdy));
const nx = -pdy / plen;
const ny = pdx / plen;
const desiredGap = this.radius * 0.36;
const side = this.birthRitualRole || 1;
const tx = midX + nx * desiredGap * side;
const ty = midY + ny * desiredGap * side;
this.vx += (tx - this.x) * dt * 3.8;
this.vy += (ty - this.y) * dt * 3.8;
}
this.vx *= Math.pow(0.48, dt * 60);
this.vy *= Math.pow(0.48, dt * 60);
this.x += Math.sin(this.world.time * 26 + this.age) * 0.20;
return;
} }
this.vx *= Math.pow(0.48, dt * 60);
this.vy *= Math.pow(0.48, dt * 60);
this.x += Math.sin(this.world.time * 26 + this.age) * 0.20;
return;
} }
if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") { if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") {
@ -349,22 +430,22 @@
if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4))); if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (this.outgoingDamage ? this.outgoingDamage(4.6 + aggression * 3.4) : (4.6 + aggression * 3.4)));
this.fearTimer = Math.max(this.fearTimer, 0.12); this.fearTimer = Math.max(this.fearTimer, 0.12);
this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b"; this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)"); if (deterministicChance(this.world, "ant-attack-bubble", 0.55, this, this.target)) this.bubble("!", 0.8, "rgba(145,68,52,0.76)");
} }
} }
} }
ax += (dx / d) * baseSpeed * sign * strength; ax += (dx / d) * baseSpeed * sign * strength;
ay += (dy / d) * baseSpeed * sign * strength; ay += (dy / d) * baseSpeed * sign * strength;
} else if (this.state === "panic") { } else if (this.state === "panic") {
this.wanderAngle += rand(-3.8, 3.8) * dt; this.wanderAngle += deterministicRange(this.world, "panic-wander", -3.8, 3.8, this) * dt;
ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5; ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5;
ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5; ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5;
} else if (this.state === "zunchi_sick") { } else if (this.state === "zunchi_sick") {
this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8; this.wanderAngle += deterministicRange(this.world, "zunchi-sick-wander", -2.4, 2.4, this) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8;
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72; ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72;
} else if (this.state === "fight_sick") { } else if (this.state === "fight_sick") {
this.wanderAngle += rand(-4.2, 4.2) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6; this.wanderAngle += deterministicRange(this.world, "fight-sick-wander", -4.2, 4.2, this) * dt + Math.sin(this.world.time * 3.2 + this.age) * dt * 1.6;
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88; ax += Math.cos(this.wanderAngle) * baseSpeed * 0.88;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88; ay += Math.sin(this.wanderAngle) * baseSpeed * 0.88;
} else { } else {
@ -388,8 +469,18 @@
const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0;
const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0;
this.x += (this.vx + impulseVx) * dt; const moveDx = (this.vx + impulseVx) * dt;
this.y += (this.vy + impulseVy) * dt; const moveDy = (this.vy + impulseVy) * dt;
if (this.world?.moveTarinaiWithCollision) {
const motionOnlyPass = Boolean(this._motionOnlyPhysicsPass);
this.world.moveTarinaiWithCollision(this, moveDx, moveDy, motionOnlyPass
? { dt, reason: "tarinai-move-smooth", light: true, maxChecks: this.state === "panic" ? 18 : 14, maxStep: this.state === "panic" ? 12 : 14 }
: { dt, reason: "tarinai-move" });
}
else {
this.x += moveDx;
this.y += moveDy;
}
const impulseDrag = Math.pow(0.58, dt * 3.2); const impulseDrag = Math.pow(0.58, dt * 3.2);
this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag;
this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag;
@ -403,10 +494,10 @@
const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding; const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding;
const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding; const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding;
const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding; const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding;
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; } if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-left", -0.8, 0.8, this); this.surpriseTimer = 0.15; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; } if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-top", 0.3, 1.8, this); this.surpriseTimer = 0.15; }
if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; } if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-right", 2.5, 3.8, this); this.surpriseTimer = 0.15; }
if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; } if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = deterministicRange(this.world, "wall-bounce-bottom", -2.2, -0.7, this); this.surpriseTimer = 0.15; }
}, },
checkLife(dt = 0.016) { checkLife(dt = 0.016) {
@ -441,7 +532,7 @@
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup"); this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y)); this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y)); this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
if (Math.random() < 0.55) { if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 }); const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) { if (spot) {
const deathGrass = new Item("grass", spot.x, spot.y); const deathGrass = new Item("grass", spot.x, spot.y);

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Layer: entity-runtime/tarinai/legacy // Layer: entity-runtime/tarinai/legacy
// Compatibility owner for the former monolithic Tarinai.prototype.update body. // Compatibility owner for the former monolithic Tarinai.prototype.update body.
@ -75,7 +105,7 @@
this.defeatedTimer = Math.max(0, this.defeatedTimer - dt); this.defeatedTimer = Math.max(0, this.defeatedTimer - dt);
if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) { if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) {
const winner = this.world.liveTarinaiById?.(this.defeatedById) || null; const winner = this.world.liveTarinaiById?.(this.defeatedById) || null;
this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2)); this.defeatedTimer = Math.max(this.defeatedTimer, deterministicRange(this.world, "defeated-recover-timer", 3.4, 5.2, this));
this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear); this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear);
this.hurtTimer = Math.max(this.hurtTimer, 1.8); this.hurtTimer = Math.max(this.hurtTimer, 1.8);
this.fallTimer = Math.max(this.fallTimer, 1.15); this.fallTimer = Math.max(this.fallTimer, 1.15);
@ -95,27 +125,27 @@
&& (this.fallTimer || 0) <= 0.04 && (this.fallTimer || 0) <= 0.04
&& !this.dead && !this.dead
&& !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || "")) && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(this.state || ""))
&& Math.random() < dt * 0.055) { && deterministicChance(this.world, "foot-massage-stumble", dt * 0.055, this)) {
const dir = Math.random() < 0.5 ? -1 : 1; const dir = deterministicSigned(this.world, "foot-massage-stumble-dir", this);
this.groundStumbleCooldown = rand(8.5, 16.0); this.groundStumbleCooldown = deterministicRange(this.world, "foot-massage-stumble-cooldown", 8.5, 16.0, this);
this.fallTimer = Math.max(this.fallTimer || 0, rand(0.86, 1.18)); this.fallTimer = Math.max(this.fallTimer || 0, deterministicRange(this.world, "foot-massage-stumble-fall", 0.86, 1.18, this));
this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer); this.fallMax = Math.max(this.fallMax || 1.05, this.fallTimer);
this.fallDir = dir; this.fallDir = dir;
this.vx += dir * rand(26, 52); this.vx += dir * deterministicRange(this.world, "foot-massage-stumble-vx", 26, 52, this);
this.vy += rand(-18, 18); this.vy += deterministicRange(this.world, "foot-massage-stumble-vy", -18, 18, this);
this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45); this.world?.spawnFallEffect?.(this.x, this.y + this.radius * 0.45);
this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); this.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false });
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)");
} }
if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) { if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && deterministicChance(this.world, "stress-breath-bubble", dt * 0.050, this)) {
this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)"); this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
} }
if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) { if (this.state === "idle" && this.stress < 78 && deterministicChance(this.world, "idle-bubble", dt * 0.018, this)) {
this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)"); this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)");
} }
if (this.hurtTimer > 0 && Math.random() < dt * 5.2) { if (this.hurtTimer > 0 && deterministicChance(this.world, "hurt-bleed-effect", dt * 5.2, this)) {
const side = this.facingDir(); const side = this.facingDir();
this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34)); this.world.spawnBleedEffect(this.x - side * this.radius * deterministicRange(this.world, "hurt-bleed-x", 0.15, 0.42, this), this.y - this.radius * deterministicRange(this.world, "hurt-bleed-y", 0.08, 0.34, this));
} }
this.tempTimer += dt; this.tempTimer += dt;

View file

@ -1,21 +1,121 @@
"use strict"; "use strict";
// Layer: entity-runtime/tarinai/policy // Layer: entity-runtime/tarinai/policy
// Owns Tarinai update cadence decisions. The simulation creature system asks // Owns Tarinai update cadence decisions. The cadence throttles expensive
// this policy when an individual should run at realtime vs low-frequency cadence. // thinking/environment work, while visible movement can still be integrated
// every frame via the smooth-motion pass.
(function (global) { (function (global) {
const helpers = () => global.TarinaiSimulationRuntime?.helpers || {}; const helpers = () => global.TarinaiSimulationRuntime?.helpers || {};
function updateInterval(worldRef, tarinai, visibleRect) { function behaviorOf(tarinai) {
if (!tarinai || tarinai.dead) return Infinity; return typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(tarinai) : tarinai?.behavior;
if (worldRef.selected === tarinai) return 0; }
if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.pokeFlashTimer || 0) > 0.03 || (tarinai.entryTimer || 0) > 0.01) return 0;
if (helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect)) return 0; function behaviorId(tarinai) {
const count = (worldRef.tarinai || []).length; const behavior = behaviorOf(tarinai);
return String(behavior?.actionId || tarinai?.state || "none");
}
function visible(worldRef, tarinai, visibleRect) {
return Boolean(helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, visibleRect));
}
function hasMotionNeed(tarinai) {
if (!tarinai || tarinai.dead) return false;
if (tarinai.target && !tarinai.target.dead) return true;
const speed = Math.hypot((tarinai.vx || 0) + (tarinai.impulseVx || 0), (tarinai.vy || 0) + (tarinai.impulseVy || 0));
if (speed > 1.8) return true;
const id = behaviorId(tarinai);
return /idle|wander|approach|follow|seek|panic|fight|birth_ritual|sunbath|zunchi_sick|fight_sick|cursor_/i.test(id);
}
function hasPassivePhysicsNeed(tarinai, worldRef = null, isVisible = false) {
if (!tarinai || tarinai.dead) return false;
const vx = Number.isFinite(tarinai.vx) ? tarinai.vx : 0;
const vy = Number.isFinite(tarinai.vy) ? tarinai.vy : 0;
const ivx = Number.isFinite(tarinai.impulseVx) ? tarinai.impulseVx : 0;
const ivy = Number.isFinite(tarinai.impulseVy) ? tarinai.impulseVy : 0;
if (Math.hypot(vx + ivx, vy + ivy) > 0.35) return true;
const sleeping = tarinai.state === "sleep" || tarinai.sleeping;
if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true;
return false;
}
function cadence(worldRef, tarinai, visibleRect) {
if (!tarinai || tarinai.dead) return { interval: Infinity, lane: "dead", smoothMotion: false, visible: false, urgent: false };
const isSelected = worldRef.selected === tarinai;
if (isSelected) return { interval: 0, lane: "selected", smoothMotion: false, visible: true, urgent: true };
const id = behaviorId(tarinai);
const state = String(tarinai.state || id || "");
const tier = global.TarinaiPerf?.renderQualityTier?.() || "high"; const tier = global.TarinaiPerf?.renderQualityTier?.() || "high";
if (tier === "high" && count <= 36) return 0; const count = (worldRef.tarinai || []).length;
const farRect = helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect; const isVisible = visible(worldRef, tarinai, visibleRect);
return helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect) ? 0.22 : 0.70; const farRect = isVisible ? visibleRect : (helpers().updateVisibleWorldRect?.(worldRef, 520) || visibleRect);
const isNear = isVisible || Boolean(helpers().pointInRect?.(tarinai.x || 0, tarinai.y || 0, farRect));
const motionNeed = hasMotionNeed(tarinai);
const hardRealtimeUrgent =
(tarinai.hurtTimer || 0) > 0.04 ||
(tarinai.pokeFlashTimer || 0) > 0.03 ||
(tarinai.entryTimer || 0) > 0.01 ||
(tarinai.fallTimer || 0) > 0.01 ||
/fight|knockback|danger|hurt|ant_attack|cursor_enemy|explosion/i.test(id);
if (hardRealtimeUrgent) return { interval: 0, lane: "urgent", smoothMotion: false, visible: isVisible, urgent: true };
// Panic movement must stay visually continuous, but the full creature
// pipeline does not need to run every rendered frame. Treat panic/flee as
// a high-priority motion lane: AI/timers are quantized, motion-only still
// runs on visible skipped frames.
const panicUrgent = (tarinai.fearTimer || 0) > 0.20 || /panic|flee/i.test(id);
if (panicUrgent) {
const interval = isVisible ? (tier === "low" ? 0.12 : 0.08) : (isNear ? 0.18 : 0.36);
return { interval, lane: "panic", smoothMotion: Boolean(isVisible && !worldRef.isTarinaiHiddenInNestBox?.(tarinai)), visible: isVisible, urgent: true };
}
let interval;
let lane = "calm";
if (/birth_ritual/i.test(id) || state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) {
lane = "birth";
interval = isVisible ? (tier === "low" ? 0.16 : 0.12) : 0.24;
} else if (/approach_parent_or_child|follow_parent|approach_mate|seek_bed|play_ball|seek_material|build/i.test(id) || /follow_parent|seek_bed|play_ball/.test(state)) {
lane = "moving";
interval = isVisible ? (tier === "low" ? 0.14 : 0.10) : (isNear ? 0.22 : 0.55);
} else if (/eat_food|eat/i.test(id) || state === "eat" || (tarinai.eatTimer || 0) > 0.04) {
lane = "eat";
interval = isVisible ? 0.22 : 0.48;
} else if (/sleep|sleep_in_bed/.test(id) || state === "sleep" || tarinai.sleeping) {
lane = "sleep";
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));
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)));
} else {
lane = isNear ? "near" : "far";
interval = isNear ? (tier === "low" ? 0.40 : 0.30) : (tier === "low" ? 1.20 : 0.90);
}
const passivePhysicsNeed = hasPassivePhysicsNeed(tarinai, worldRef, isVisible);
const sleepPhysical = lane === "sleep" && passivePhysicsNeed;
const eatPhysical = lane === "eat" && passivePhysicsNeed;
const smoothAllowed = lane !== "eat" && lane !== "sleep";
const smoothMotion = Boolean(isVisible
&& Number.isFinite(interval)
&& interval > 0
&& interval <= 0.75
&& !worldRef.isTarinaiHiddenInNestBox?.(tarinai)
&& ((smoothAllowed && motionNeed) || sleepPhysical || eatPhysical));
return { interval, lane, smoothMotion, visible: isVisible, urgent: false, sleepPhysical, passivePhysicsNeed };
}
function updateInterval(worldRef, tarinai, visibleRect) {
return cadence(worldRef, tarinai, visibleRect).interval;
} }
function shouldRunRealtime(worldRef, tarinai, visibleRect) { function shouldRunRealtime(worldRef, tarinai, visibleRect) {
@ -24,6 +124,8 @@
global.TarinaiCreatureUpdatePolicy = Object.freeze({ global.TarinaiCreatureUpdatePolicy = Object.freeze({
updateInterval, updateInterval,
updateCadence: cadence,
behaviorId,
shouldRunRealtime, shouldRunRealtime,
}); });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
// Layer: entity-runtime/tarinai/update-step // Layer: entity-runtime/tarinai/update-step
// Owns per-frame timers, passive metabolism, disease/effect ticks, colony mood // Owns per-frame timers, passive metabolism, disease/effect ticks, colony mood
@ -76,7 +106,7 @@
t.defeatedTimer = Math.max(0, t.defeatedTimer - dt); t.defeatedTimer = Math.max(0, t.defeatedTimer - dt);
if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) { if (wasFighting && t.fightTimer <= 0.04 && t.defeatedById) {
const winner = t.world.liveTarinaiById?.(t.defeatedById) || null; const winner = t.world.liveTarinaiById?.(t.defeatedById) || null;
t.defeatedTimer = Math.max(t.defeatedTimer, rand(3.4, 5.2)); t.defeatedTimer = Math.max(t.defeatedTimer, deterministicRange(t.world, "defeated-recover-timer", 3.4, 5.2, t));
t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear); t.fearTimer = Math.max(t.fearTimer, 1.0 * t.personalityProfile().fear);
t.hurtTimer = Math.max(t.hurtTimer, 1.8); t.hurtTimer = Math.max(t.hurtTimer, 1.8);
t.fallTimer = Math.max(t.fallTimer, 1.15); t.fallTimer = Math.max(t.fallTimer, 1.15);
@ -96,27 +126,27 @@
&& (t.fallTimer || 0) <= 0.04 && (t.fallTimer || 0) <= 0.04
&& !t.dead && !t.dead
&& !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || "")) && !["sleep", "fight", "panic", "birth_ritual", "frozen"].includes(String(t.state || ""))
&& Math.random() < dt * 0.055) { && deterministicChance(t.world, "foot-massage-stumble", dt * 0.055, t)) {
const dir = Math.random() < 0.5 ? -1 : 1; const dir = deterministicSigned(t.world, "foot-massage-stumble-dir", t);
t.groundStumbleCooldown = rand(8.5, 16.0); t.groundStumbleCooldown = deterministicRange(t.world, "foot-massage-stumble-cooldown", 8.5, 16.0, t);
t.fallTimer = Math.max(t.fallTimer || 0, rand(0.86, 1.18)); t.fallTimer = Math.max(t.fallTimer || 0, deterministicRange(t.world, "foot-massage-stumble-fall", 0.86, 1.18, t));
t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer); t.fallMax = Math.max(t.fallMax || 1.05, t.fallTimer);
t.fallDir = dir; t.fallDir = dir;
t.vx += dir * rand(26, 52); t.vx += dir * deterministicRange(t.world, "foot-massage-stumble-vx", 26, 52, t);
t.vy += rand(-18, 18); t.vy += deterministicRange(t.world, "foot-massage-stumble-vy", -18, 18, t);
t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45); t.world?.spawnFallEffect?.(t.x, t.y + t.radius * 0.45);
t.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false }); t.setActionState?.("idle", { target: null, reason: "足つぼの突起につまづいた。", sleeping: false, clearTarget: false });
if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)"); if ((t.world?.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("うっ", 1.6, "rgba(86,70,96,0.78)");
} }
if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && Math.random() < dt * 0.050) { if (t.stress > 92 && t.state !== "sleep" && t.state !== "fight" && t.state !== "eat" && deterministicChance(t.world, "stress-breath-bubble", dt * 0.050, t)) {
t.bubble("はっ...はっ...", 3.4, "rgba(58,48,63,0.80)"); t.bubble("はっ...はっ...", 3.4, "rgba(58,48,63,0.80)");
} }
if (t.state === "idle" && t.stress < 78 && Math.random() < dt * 0.018) { if (t.state === "idle" && t.stress < 78 && deterministicChance(t.world, "idle-bubble", dt * 0.018, t)) {
t.bubble(pick(["はう", "はうぅ", "はうっ", "はぅ"]), 4.2, "rgba(62,84,45,0.76)"); t.bubble(pick(["はう", "はうぅ", "はうっ", "はぅ"]), 4.2, "rgba(62,84,45,0.76)");
} }
if (t.hurtTimer > 0 && Math.random() < dt * 5.2) { if (t.hurtTimer > 0 && deterministicChance(t.world, "hurt-bleed-effect", dt * 5.2, t)) {
const side = t.facingDir(); const side = t.facingDir();
t.world.spawnBleedEffect(t.x - side * t.radius * rand(0.15, 0.42), t.y - t.radius * rand(0.08, 0.34)); t.world.spawnBleedEffect(t.x - side * t.radius * deterministicRange(t.world, "hurt-bleed-x", 0.15, 0.42, t), t.y - t.radius * deterministicRange(t.world, "hurt-bleed-y", 0.08, 0.34, t));
} }
t.tempTimer += dt; t.tempTimer += dt;

View file

@ -37,22 +37,204 @@
return true; return true;
} }
function update(t, dt) {
function numeric(v, fallback = 0) {
return Number.isFinite(Number(v)) ? Number(v) : fallback;
}
function markSleepExternalMotion(t, dt, reason = "sleep-external-motion") {
if (!t || !(t.state === "sleep" || t.sleeping)) return false;
const world = t.world || null;
const now = Number(world?.time || 0) || 0;
const vx = numeric(t.vx);
const vy = numeric(t.vy);
const ivx = numeric(t.impulseVx);
const ivy = numeric(t.impulseVy);
const speed = Math.hypot(vx + ivx, vy + ivy);
const recentlyHit = now - Number(t.lastSolidObstacleCollisionAt || t.lastPhysicalCollisionDamageAt || -999) < 0.35;
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);
}
function sleepExternalMotionActive(t) {
const now = Number(t?.world?.time || 0) || 0;
return Boolean(t && (t.state === "sleep" || t.sleeping) && now < Number(t._sleepExternalMotionUntil || 0));
}
function passivePhysicalMotion(t, dt, context = {}) {
if (!t || t.dead) return { done: true }; if (!t || t.dead) return { done: true };
const world = t.world;
const beforeX = numeric(t.x);
const beforeY = numeric(t.y);
const beforeVelocity = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: combinedVelocity(t);
// Nest boxes intentionally hide and pin sleepers. Visible beds, eating, and
// other locked states still need a passive physics shell so external
// impulses/mechanical contacts are not delayed until the next low-frequency
// full update.
if (world?.isTarinaiHiddenInNestBox?.(t)) {
t.vx = 0;
t.vy = 0;
t.impulseVx = 0;
t.impulseVy = 0;
t.updateNestBoxPresence?.(dt);
return { done: false, passive: true };
}
let moveX = 0;
let moveY = 0;
const state = String(t.state || "");
const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical");
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && t.target.type !== "nest_box") {
const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72;
const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target;
const dx = numeric(spot?.x, beforeX) - beforeX;
const dy = numeric(spot?.y, beforeY) - beforeY;
const d = Math.hypot(dx, dy);
const strongExternal = sleepExternal && Math.hypot(numeric(t.vx) + numeric(t.impulseVx), numeric(t.vy) + numeric(t.impulseVy)) > 1.2;
// Beds should gently keep a sleeper nearby, but must not pin the body.
// Under external force the restoring pull is deliberately weak so a
// sleeping Tarinai behaves like a physical object instead of snapping back
// to the sleep spot every smooth/full update.
if (d > 1.2) {
const restore = strongExternal ? (2.0 + comfort * 2.0) : (12 + comfort * 14);
const maxRestore = strongExternal ? Math.min(d, 2.8) : d;
const step = Math.min(maxRestore, dt * restore);
moveX += dx / d * step;
moveY += dy / d * step;
} else if (!strongExternal && d > 0.05 && Math.hypot(t.vx || 0, t.vy || 0, t.impulseVx || 0, t.impulseVy || 0) < 0.6) {
moveX += dx;
moveY += dy;
}
}
const vx = numeric(t.vx);
const vy = numeric(t.vy);
const impulseVx = numeric(t.impulseVx);
const impulseVy = numeric(t.impulseVy);
moveX += (vx + impulseVx) * dt;
moveY += (vy + impulseVy) * dt;
const movedDistance = Math.hypot(moveX, moveY);
if (movedDistance > 0.001) {
if (world?.moveTarinaiWithCollision) {
world.moveTarinaiWithCollision(t, moveX, moveY, {
dt,
reason: state === "sleep" ? "tarinai-sleep-physical" : "tarinai-passive-physical",
maxChecks: context.cadence?.lane === "sleep" ? 18 : 22,
maxStep: 10,
});
} else {
t.x = beforeX + moveX;
t.y = beforeY + moveY;
}
}
const bodyDrag = state === "sleep" ? (sleepExternalMotionActive(t) ? 0.86 : 0.55) : (state === "eat" ? 0.42 : 0.66);
t.vx = Math.abs(numeric(t.vx) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vx) * Math.pow(bodyDrag, dt * 60);
t.vy = Math.abs(numeric(t.vy) * Math.pow(bodyDrag, dt * 60)) < 0.025 ? 0 : numeric(t.vy) * Math.pow(bodyDrag, dt * 60);
const impulseDrag = Math.pow(0.58, dt * 3.2);
t.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag;
t.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag;
applyVelocityShockDamage(t, beforeVelocity, dt);
t.updateNestBoxPresence?.(dt);
const dx = numeric(t.x) - beforeX;
const dy = numeric(t.y) - beforeY;
if (world?.resolveSolidObstacleCollision && !world.isTarinaiHiddenInNestBox?.(t) && (state === "sleep" || sleepExternalMotionActive(t))) {
// A moving mechanical item can hit a sleeping Tarinai while the Tarinai's
// own velocity is zero. In that case swept movement will never run, so
// do a small contact pass every passive-physics tick.
const beforeContactX = numeric(t.x);
const beforeContactY = numeric(t.y);
const pushed = world.resolveSolidObstacleCollision(t, {
maxPasses: 2,
searchRadius: Math.max(72, (numeric(t.radius, 22) || 22) + 120),
maxChecks: context.cadence?.lane === "sleep" ? 18 : 22,
slop: 0.28,
reason: "tarinai-sleep-passive-contact",
});
if (pushed) {
t.lastSolidObstacleCollisionAt = world.time || 0;
markSleepExternalMotion(t, dt, "sleep-contact-push");
const cdx = numeric(t.x) - beforeContactX;
const cdy = numeric(t.y) - beforeContactY;
if (Math.hypot(cdx, cdy) > 0.05) {
const pushCarry = state === "sleep" ? 0.085 : 0.040;
t.vx = clamp(numeric(t.vx) + cdx / Math.max(0.016, dt) * pushCarry, -170, 170);
t.vy = clamp(numeric(t.vy) + cdy / Math.max(0.016, dt) * pushCarry, -170, 170);
}
}
} else if (dx * dx + dy * dy <= 0.25 && world?.resolveFenceCollision && !world.isTarinaiHiddenInNestBox?.(t)) {
const now = world.time || 0;
if (now >= (t._nextPassiveSolidRelaxAt || 0)) {
const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || 0, "passive-solid-relax") * 0.55 : Math.random() * 0.55;
t._nextPassiveSolidRelaxAt = now + 1.20 + jitter;
world.resolveFenceCollision(t);
}
}
return { done: false, passive: true };
}
function update(t, dt, context = {}) {
if (!t || t.dead) return { done: true };
const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016));
if (context.motionOnly && /^(sleep|eat|intimidate|ant_intimidate)$/.test(String(t.state || ""))) {
return passivePhysicalMotion(t, motionDt, context);
}
const beforeX = Number.isFinite(t.x) ? t.x : 0;
const beforeY = Number.isFinite(t.y) ? t.y : 0;
const current = combinedVelocity(t); const current = combinedVelocity(t);
const before = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY) const before = Number.isFinite(t._lastPhysicalVelocityX) && Number.isFinite(t._lastPhysicalVelocityY)
? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY } ? { vx: t._lastPhysicalVelocityX, vy: t._lastPhysicalVelocityY }
: current; : current;
if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, dt); if (global.TarinaiSunbathSystem?.updateOne) global.TarinaiSunbathSystem.updateOne(t, motionDt);
else t.updateSunbath?.(dt); else t.updateSunbath?.(motionDt);
t.maintainTargetProgress(dt); t.maintainTargetProgress(motionDt);
t.move(dt); const prevMotionOnlyFlag = t._motionOnlyPhysicsPass;
if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(dt); if (context.motionOnly) t._motionOnlyPhysicsPass = true;
applyVelocityShockDamage(t, before, dt); try {
t.updateNestBoxPresence(dt); t.move(motionDt);
if (t.world.resolveFenceCollision) t.world.resolveFenceCollision(t); } finally {
if (context.motionOnly) t._motionOnlyPhysicsPass = prevMotionOnlyFlag;
}
if (t.ammoCollisionKnockback) t.ammoCollisionKnockback(motionDt);
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.
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)) {
const now = world.time || 0;
if (now >= (t._nextSolidRelaxAt || 0)) {
const jitter = typeof stableUnit === "function" ? stableUnit(t.id || t.familyKey || 0, "solid-relax") * 0.45 : Math.random() * 0.45;
t._nextSolidRelaxAt = now + 0.85 + jitter;
world.resolveFenceCollision(t);
}
}
}
return { done: false }; return { done: false };
} }
global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage }); global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage, passivePhysicalMotion, markSleepExternalMotion, sleepExternalMotionActive });
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -9,7 +9,6 @@ function bindUI() {
window.bindGroundUI?.(world); window.bindGroundUI?.(world);
renderEcologyCards(); renderEcologyCards();
window.TarinaiSaveSystem?.bindSaveSystem?.(); window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
function isActiveToolMode() { function isActiveToolMode() {
return Boolean(world?.tool && world.tool !== "observe"); return Boolean(world?.tool && world.tool !== "observe");

View file

@ -131,6 +131,17 @@
return false; return false;
} }
function syncGrabbedPhysicsItem(target, reason = "pinch-move") {
if (!target || target.dead) return false;
const pb = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem;
if (!pb?.isBodyType?.(target.type)) return false;
pb.syncPoseFromItem?.(target);
pb.setScalar?.(target, "awakeUntil", Math.max(Number(pb.scalar?.(target, "awakeUntil", 0) || 0), (world.time || 0) + 0.65), reason);
pb.markBodyChanged?.(target, reason);
(typeof window !== "undefined" ? window : globalThis).TarinaiMechanicalSystem?.invalidateGeometry?.(target);
return true;
}
function prepareGrabTarget(target) { function prepareGrabTarget(target) {
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id); const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
@ -168,6 +179,7 @@
target.playerHeld = true; target.playerHeld = true;
target._heldByPlayer = true; target._heldByPlayer = true;
target._playerGrabStartedAt = world.time || 0; target._playerGrabStartedAt = world.time || 0;
if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-start");
if (target.type === "duplicator") { if (target.type === "duplicator") {
target.duplicatorLoadSuppressedUntil = Math.max(target.duplicatorLoadSuppressedUntil || 0, (world.time || 0) + 1.0); target.duplicatorLoadSuppressedUntil = Math.max(target.duplicatorLoadSuppressedUntil || 0, (world.time || 0) + 1.0);
target._duplicatorCandidateKey = ""; target._duplicatorCandidateKey = "";
@ -206,11 +218,13 @@
const movedX = (Number(target.x || 0) || 0) - beforeX; const movedX = (Number(target.x || 0) || 0) - beforeX;
const movedY = (Number(target.y || 0) || 0) - beforeY; const movedY = (Number(target.y || 0) || 0) - beforeY;
if (uiCache.grabKind === "item" && target.type === "reciprocator") { if (uiCache.grabKind === "item" && target.type === "reciprocator") {
target.reciprocatorAnchorX = (Number(target.reciprocatorAnchorX || beforeX) || beforeX) + movedX; const pb = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem;
target.reciprocatorAnchorY = (Number(target.reciprocatorAnchorY || beforeY) || beforeY) + movedY; pb?.setScalar?.(target, "railAnchorX", (Number(pb?.scalar?.(target, "railAnchorX", beforeX)) || beforeX) + movedX, "grab-move");
pb?.setScalar?.(target, "railAnchorY", (Number(pb?.scalar?.(target, "railAnchorY", beforeY)) || beforeY) + movedY, "grab-move");
target.prevX = target.x; target.prevX = target.x;
target.prevY = target.y; target.prevY = target.y;
} }
if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-move");
target.vx = clamp(dx * 16, -160, 160); target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160); target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") { if (uiCache.grabKind === "tarinai") {
@ -245,6 +259,7 @@
target.prevX = target.x; target.prevX = target.x;
target.prevY = target.y; target.prevY = target.y;
} }
if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-release");
target.playerHeld = false; target.playerHeld = false;
target._heldByPlayer = false; target._heldByPlayer = false;
target._lastPlayerReleasedAt = world.time || 0; target._lastPlayerReleasedAt = world.time || 0;

View file

@ -1,8 +1,8 @@
"use strict"; "use strict";
(function () { (function () {
const APP_VERSION = "39.00.00"; const APP_VERSION = "39.15.18";
const APP_BUILD = "lab-ground-wash-and-freezer-removal-v38"; const APP_BUILD = "tarinai-nearby-constraint-reduce-v39-15-18";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -14,7 +14,6 @@ class World { constructor() {
this.toolSizes = {}; this.toolSizes = {};
this.toolAngles = {}; this.toolAngles = {};
this.tarinai = []; this.tarinai = [];
this.frozenTarinai = [];
this.items = []; this.items = [];
this.ants = []; this.ants = [];
this.effects = []; this.effects = [];

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
(function (global) { (function (global) {
const World = global.World; const World = global.World;
@ -29,7 +59,7 @@
let nx = (ant.x - x) / d; let nx = (ant.x - x) / d;
let ny = (ant.y - y) / d; let ny = (ant.y - y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "ant-push-overlap", ant, x, y);
nx = Math.cos(a); nx = Math.cos(a);
ny = Math.sin(a); ny = Math.sin(a);
} }
@ -58,13 +88,13 @@
} }
this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1); this.blastZunchiFrom(x, y, blastRadius * 0.92, it.blastScale || 1);
for (let i = 0; i < 34; i++) { for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); const a = Math.PI * 2 * i / 34 + deterministicRange(this, "firecracker-spark-angle", -0.10, 0.10, it, i);
const speed = rand(90, 260); const speed = deterministicRange(this, "firecracker-spark-speed", 90, 260, it, i);
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22), y + Math.sin(a) * rand(2, 22), { this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "firecracker-spark-x", 2, 22, it, i), y + Math.sin(a) * deterministicRange(this, "firecracker-spark-y", 2, 22, it, i), {
vx: Math.cos(a) * speed, vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed, vy: Math.sin(a) * speed,
size: rand(8, i % 3 === 0 ? 24 : 20), size: deterministicRange(this, "firecracker-spark-size", 8, i % 3 === 0 ? 24 : 20, it, i),
life: rand(0.26, 0.72), life: deterministicRange(this, "firecracker-spark-life", 0.26, 0.72, it, i),
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
})); }));
} }
@ -76,7 +106,7 @@
if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" });
else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); }
} }
if (t.addStress) t.addStress(rand(8, 15), { threshold: 8 }); if (t.addStress) t.addStress(deterministicRange(this, "firecracker-startle-stress", 8, 15, it, t), { threshold: 8 });
t.fearTimer = Math.max(t.fearTimer, 0.58); t.fearTimer = Math.max(t.fearTimer, 0.58);
const dx = t.x - x; const dx = t.x - x;
const dy = t.y - y; const dy = t.y - y;
@ -92,13 +122,13 @@
let nx = dx / d; let nx = dx / d;
let ny = dy / d; let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t);
nx = Math.cos(a); nx = Math.cos(a);
ny = Math.sin(a); ny = Math.sin(a);
} }
const distanceBoost = p * p * 0.55 + p * 0.45; const distanceBoost = p * p * 0.55 + p * 0.45;
const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); const power = (260 + distanceBoost * 920) * deterministicRange(this, "firecracker-tarinai-power", 0.78, 1.24, it, t) * (it.blastScale || 1);
this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { this.applyImpulse(t, nx * power + deterministicRange(this, "firecracker-tarinai-jitter-x", -95, 95, it, t) * (0.6 + p), ny * power + deterministicRange(this, "firecracker-tarinai-jitter-y", -95, 95, it, t) * (0.6 + p), {
panic: true, panic: true,
target: { x, y }, target: { x, y },
fearTimer: 1.7 + p, fearTimer: 1.7 + p,
@ -106,13 +136,13 @@
}); });
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); t.fallDir = (dx >= 0 ? 1 : -1) * deterministicSigned(this, "firecracker-fall-dir", it, t);
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70); t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer); t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p); this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15;
if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) t.infectExplosionDisease?.(it); if (!t.dead && p > 0.22 && deterministicChance(this, "firecracker-explosion-disease", t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance, it, t)) t.infectExplosionDisease?.(it);
if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); if (!t.dead && deterministicChance(this, "firecracker-panic-bubble", 0.45, it, t)) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
} }
if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it); if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it);
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
@ -127,13 +157,13 @@
if (d > blastRadius * 1.18) continue; if (d > blastRadius * 1.18) continue;
const p = clamp(1 - d / (blastRadius * 1.18), 0, 1); const p = clamp(1 - d / (blastRadius * 1.18), 0, 1);
if (d < 0.001) { if (d < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "firecracker-ball-overlap", it, ball);
dx = Math.cos(a); dy = Math.sin(a); d = 1; dx = Math.cos(a); dy = Math.sin(a); d = 1;
} }
const blast = 430 + p * 960; const blast = 430 + p * 960;
this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); this.applyImpulse(ball, dx / d * blast + deterministicRange(this, "firecracker-ball-jitter-x", -80, 80, it, ball), dy / d * blast + deterministicRange(this, "firecracker-ball-jitter-y", -80, 80, it, ball));
const speed = Math.hypot(ball.vx || 0, ball.vy || 0); const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38);
ball.lastPokedAt = this.time || 0; ball.lastPokedAt = this.time || 0;
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
@ -157,13 +187,13 @@
} }
this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale); this.blastZunchiFrom(x, y, blastRadius * 0.92, blastScale);
for (let i = 0; i < 34; i++) { for (let i = 0; i < 34; i++) {
const a = Math.PI * 2 * i / 34 + rand(-0.10, 0.10); const a = Math.PI * 2 * i / 34 + deterministicRange(this, "disease-explosion-spark-angle", -0.10, 0.10, t, i);
const speed = rand(90, 260) * blastScale; const speed = deterministicRange(this, "disease-explosion-spark-speed", 90, 260, t, i) * blastScale;
this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * rand(2, 22) * blastScale, y + Math.sin(a) * rand(2, 22) * blastScale, { this.effects.push(new Effect(i % 3 === 0 ? "explosion" : "fight", x + Math.cos(a) * deterministicRange(this, "disease-explosion-spark-x", 2, 22, t, i) * blastScale, y + Math.sin(a) * deterministicRange(this, "disease-explosion-spark-y", 2, 22, t, i) * blastScale, {
vx: Math.cos(a) * speed, vx: Math.cos(a) * speed,
vy: Math.sin(a) * speed, vy: Math.sin(a) * speed,
size: rand(8, i % 3 === 0 ? 24 : 20) * blastScale, size: deterministicRange(this, "disease-explosion-spark-size", 8, i % 3 === 0 ? 24 : 20, t, i) * blastScale,
life: rand(0.26, 0.72), life: deterministicRange(this, "disease-explosion-spark-life", 0.26, 0.72, t, i),
color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)", color: i % 3 === 0 ? "rgba(255,218,70,0.78)" : "rgba(112,72,42,0.72)",
})); }));
} }
@ -179,13 +209,13 @@
let nx = dx / d; let nx = dx / d;
let ny = dy / d; let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "disease-explosion-overlap-normal", t, o);
nx = Math.cos(a); nx = Math.cos(a);
ny = Math.sin(a); ny = Math.sin(a);
} }
const distanceBoost = q * q * 0.55 + q * 0.45; const distanceBoost = q * q * 0.55 + q * 0.45;
const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * blastScale; const power = (260 + distanceBoost * 920) * deterministicRange(this, "disease-explosion-tarinai-power", 0.78, 1.24, t, o) * blastScale;
this.applyImpulse(o, nx * power + rand(-95, 95) * (0.6 + q), ny * power + rand(-95, 95) * (0.6 + q), { this.applyImpulse(o, nx * power + deterministicRange(this, "disease-explosion-tarinai-jitter-x", -95, 95, t, o) * (0.6 + q), ny * power + deterministicRange(this, "disease-explosion-tarinai-jitter-y", -95, 95, t, o) * (0.6 + q), {
panic: true, panic: true,
target: { x, y }, target: { x, y },
fearTimer: 1.7 + q, fearTimer: 1.7 + q,
@ -197,13 +227,13 @@
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); } else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); }
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85); o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer); o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); o.fallDir = (dx >= 0 ? 1 : -1) * deterministicSigned(this, "disease-explosion-fall-dir", t, o);
o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70); o.blastSpinTimer = Math.max(o.blastSpinTimer || 0, 1.35 + q * 0.70);
o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer); o.blastSpinMax = Math.max(o.blastSpinMax || 0, o.blastSpinTimer);
this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q); this.spawnFallEffect(o.x, o.y + o.radius * 0.45, 1.1 + q);
const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15; const explosionChance = window.TarinaiDiseaseRegistry?.infectionChance?.("disease_explosion", 0.15) ?? 0.15;
if (!o.dead && q > 0.22 && Math.random() < (o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance)) o.infectExplosionDisease?.(t); if (!o.dead && q > 0.22 && deterministicChance(this, "disease-explosion-spread", o.diseaseChance ? o.diseaseChance(explosionChance) : explosionChance, t, o)) o.infectExplosionDisease?.(t);
if (!o.dead && Math.random() < 0.45) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); if (!o.dead && deterministicChance(this, "disease-explosion-panic-bubble", 0.45, t, o)) this.spawnBubble(o.x, o.y - o.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
} }
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
@ -216,12 +246,12 @@
if (d > blastRadius * 1.18) continue; if (d > blastRadius * 1.18) continue;
const q = clamp(1 - d / (blastRadius * 1.18), 0, 1); const q = clamp(1 - d / (blastRadius * 1.18), 0, 1);
if (d < 0.001) { if (d < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "disease-explosion-ball-overlap", t, ball);
dx = Math.cos(a); dy = Math.sin(a); d = 1; dx = Math.cos(a); dy = Math.sin(a); d = 1;
} }
const blast = 430 + q * 960; const blast = 430 + q * 960;
this.applyImpulse(ball, dx / d * blast * blastScale + rand(-80, 80), dy / d * blast * blastScale + rand(-80, 80)); this.applyImpulse(ball, dx / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-x", -80, 80, t, ball), dy / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-y", -80, 80, t, ball));
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38);
ball.lastPokedAt = this.time || 0; ball.lastPokedAt = this.time || 0;
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5); ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" })); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
@ -271,13 +301,13 @@
const d = distXY(t.x, t.y, x, y); const d = distXY(t.x, t.y, x, y);
if (d > radius + t.radius) continue; if (d > radius + t.radius) continue;
const p = clamp(1 - d / (radius + t.radius), 0, 1); const p = clamp(1 - d / (radius + t.radius), 0, 1);
t.vx += nx * (95 + 165 * p) + rand(-10, 10); t.vx += nx * (95 + 165 * p) + deterministicRange(this, "water-hose-jitter-x", -10, 10, t, x, y);
t.vy += ny * (95 + 165 * p) + rand(-10, 10); t.vy += ny * (95 + 165 * p) + deterministicRange(this, "water-hose-jitter-y", -10, 10, t, x, y);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose"); t.applyWaterEffect?.(dt * (1.0 + p * 1.8), "hose");
} }
if (Math.random() < 0.72) { if (deterministicChance(this, "water-hose-ring", 0.72, x, y)) {
this.effects.push(new Effect("ring", x + rand(-18, 18), y + rand(-12, 12), { size: rand(8, 22), life: rand(0.18, 0.32), color: "rgba(128,204,238,0.60)" })); this.effects.push(new Effect("ring", x + deterministicRange(this, "water-hose-ring-x", -18, 18, x, y), y + deterministicRange(this, "water-hose-ring-y", -12, 12, x, y), { size: deterministicRange(this, "water-hose-ring-size", 8, 22, x, y), life: deterministicRange(this, "water-hose-ring-life", 0.18, 0.32, x, y), color: "rgba(128,204,238,0.60)" }));
} }
if (cleaned > 0.25) { if (cleaned > 0.25) {
this.drawListDirty = true; this.drawListDirty = true;
@ -293,11 +323,11 @@
const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5); const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5);
const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5); const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = -Math.PI * 0.15 + rand(-0.92, 0.92) + (i / Math.max(1, n - 1) - 0.5) * 1.2; const a = -Math.PI * 0.15 + deterministicRange(this, "zunchi-burst-angle", -0.92, 0.92, source, pin, i) + (i / Math.max(1, n - 1) - 0.5) * 1.2;
const it = new Item("zunchi", clamp(sx + rand(-8, 8), 40, this.w - 40), clamp(sy + rand(-8, 8), 40, this.h - 40)); const it = new Item("zunchi", clamp(sx + deterministicRange(this, "zunchi-burst-x", -8, 8, source, pin, i), 40, this.w - 40), clamp(sy + deterministicRange(this, "zunchi-burst-y", -8, 8, source, pin, i), 40, this.h - 40));
it.amount = 240; it.amount = 240;
it.vx = Math.cos(a) * rand(260, 560) + rand(-40, 40); it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i);
it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160); it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i);
it.stage = "fresh"; it.stage = "fresh";
it.burstFromOshibyo = true; it.burstFromOshibyo = true;
this.addItem?.(it, "zunchi-burst") || this.items.push(it); this.addItem?.(it, "zunchi-burst") || this.items.push(it);
@ -325,9 +355,9 @@
if (text === "z") text = "Zzz..."; if (text === "z") text = "Zzz...";
audio.bubble(text); audio.bubble(text);
this.effects.push(new Effect("bubble", x, y, { this.effects.push(new Effect("bubble", x, y, {
vx: rand(-2, 2), vx: deterministicRange(this, "bubble-vx", -2, 2, x, y, text),
vy: rand(-5, -2), vy: deterministicRange(this, "bubble-vy", -5, -2, x, y, text),
life: rand(2.2, 3.2), life: deterministicRange(this, "bubble-life", 2.2, 3.2, x, y, text),
size: 8, size: 8,
text, text,
color, color,
@ -337,45 +367,45 @@
spawnHeadbuttEffect(x, y) { spawnHeadbuttEffect(x, y) {
this.effects.push(new Effect("fight", x, y, { this.effects.push(new Effect("fight", x, y, {
size: rand(10, 16), size: deterministicRange(this, "headbutt-effect-size", 10, 16, x, y),
life: rand(0.20, 0.34), life: deterministicRange(this, "headbutt-effect-life", 0.20, 0.34, x, y),
color: "rgba(105, 62, 34, 0.86)", color: "rgba(105, 62, 34, 0.86)",
})); }));
if (Math.random() < 0.5) audio.fight(); if (deterministicChance(this, "headbutt-audio", 0.5, x, y)) audio.fight();
}, },
spawnFallEffect(x, y, scale = 1) { spawnFallEffect(x, y, scale = 1) {
if ((this.effectCounts.fall || 0) > 18) return; if ((this.effectCounts.fall || 0) > 18) return;
this.effects.push(new Effect("fall", x, y, { this.effects.push(new Effect("fall", x, y, {
vx: rand(-12, 12), vx: deterministicRange(this, "fall-effect-vx", -12, 12, x, y),
vy: rand(-4, 3), vy: deterministicRange(this, "fall-effect-vy", -4, 3, x, y),
size: rand(20, 32) * scale, size: deterministicRange(this, "fall-effect-size", 20, 32, x, y) * scale,
life: rand(0.48, 0.72), life: deterministicRange(this, "fall-effect-life", 0.48, 0.72, x, y),
color: "rgba(154, 124, 80, 0.58)", color: "rgba(154, 124, 80, 0.58)",
})); }));
}, },
spawnEatEffect(x, y, color = "#f1dfb7") { spawnEatEffect(x, y, color = "#f1dfb7") {
if (Math.random() < 0.70) { if (deterministicChance(this, "eat-effect", 0.70, x, y, color)) {
this.effects.push(new Effect("eat", x + rand(-4, 4), y + rand(-4, 4), { this.effects.push(new Effect("eat", x + deterministicRange(this, "eat-effect-x", -4, 4, x, y, color), y + deterministicRange(this, "eat-effect-y", -4, 4, x, y, color), {
vx: rand(-10, 10), vx: deterministicRange(this, "eat-effect-vx", -10, 10, x, y, color),
vy: rand(-18, -3), vy: deterministicRange(this, "eat-effect-vy", -18, -3, x, y, color),
size: rand(2.2, 4.2), size: deterministicRange(this, "eat-effect-size", 2.2, 4.2, x, y, color),
life: rand(0.18, 0.32), life: deterministicRange(this, "eat-effect-life", 0.18, 0.32, x, y, color),
color, color,
})); }));
} }
if (Math.random() < 0.08) { if (deterministicChance(this, "eat-ring", 0.08, x, y, color)) {
this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color })); this.effects.push(new Effect("ring", x, y, { size: 5, life: 0.20, color }));
} }
}, },
spawnBleedEffect(x, y) { spawnBleedEffect(x, y) {
this.effects.push(new Effect("bleed", x + rand(-3, 3), y + rand(-2, 3), { this.effects.push(new Effect("bleed", x + deterministicRange(this, "bleed-effect-x", -3, 3, x, y), y + deterministicRange(this, "bleed-effect-y", -2, 3, x, y), {
vx: rand(-8, 8), vx: deterministicRange(this, "bleed-effect-vx", -8, 8, x, y),
vy: rand(3, 14), vy: deterministicRange(this, "bleed-effect-vy", 3, 14, x, y),
size: rand(2.0, 3.8), size: deterministicRange(this, "bleed-effect-size", 2.0, 3.8, x, y),
life: rand(0.34, 0.62), life: deterministicRange(this, "bleed-effect-life", 0.34, 0.62, x, y),
color: "rgba(80, 172, 55, 0.78)", color: "rgba(80, 172, 55, 0.78)",
})); }));
}, },
@ -419,6 +449,17 @@
applyImpulse(entity, vx = 0, vy = 0, options = {}) { applyImpulse(entity, vx = 0, vy = 0, options = {}) {
if (!entity || entity.dead) return false; if (!entity || entity.dead) return false;
if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false;
const impulseMag = Math.hypot(vx, vy);
if (entity instanceof Tarinai && (entity.birthRitualTimer || 0) > 0.04) {
this.cancelBirthRitualOnForce?.(entity, impulseMag, {
reason: options.birthCancelReason || options.thought || "強い衝撃で繁殖が中断された",
target: options.target || null,
fear: options.fearTimer ?? 1.0,
cause: options.cause || "impulse",
panic: options.panic !== false,
silentLog: !!options.silentBirthCancel,
});
}
if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) {
entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx;
entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy;
@ -435,8 +476,19 @@
applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) {
if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false;
const normalized = options.cause || cause || "\u885d\u7a81"; const normalized = options.cause || cause || "\u885d\u7a81";
const physicalHit = String(normalized).includes("衝突") || String(normalized).includes("collision") || String(cause || "").includes("急激な速度変化") || String(normalized).includes("爆竹") || String(normalized).includes("落ちてきた");
if (target instanceof Tarinai && (target.birthRitualTimer || 0) > 0.04 && physicalHit && amount >= (options.birthCancelDamageThreshold ?? 8)) {
this.cancelBirthRitualOnForce?.(target, Math.max(180, amount * 18), {
reason: "強い衝撃で繁殖が中断された",
target: options.source || null,
fear: 1.0,
cause: "impact_damage",
panic: true,
silentLog: !!options.silentBirthCancel,
});
}
this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null }); this.emit?.(String(normalized).includes("\u55a7\u5629") ? "fight:hit" : "impact:hit", { target, amount, cause: normalized, source: options.source || null });
if (String(normalized).includes("衝突") || String(normalized).includes("collision") || String(cause || "").includes("急激な速度変化")) { if (physicalHit) {
target.lastPhysicalCollisionDamageAt = this.time || 0; target.lastPhysicalCollisionDamageAt = this.time || 0;
} }
if (target.damage) target.damage(amount, normalized); if (target.damage) target.damage(amount, normalized);
@ -454,21 +506,21 @@
const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4); const damageRisk = clamp((damage || 0) / Math.max(2.5, 9 + (victim.energy || 0) * 0.045), 0, 1.4);
const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8); const stressRisk = clamp(((victim.stress || 0) - 70) / 70, 0, 0.8);
const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78); const chance = clamp(0.025 + damageRisk * 0.23 + healthRisk * 0.34 + stressRisk * 0.10 - aggressionHold * 0.12, 0.015, 0.78);
if (Math.random() >= chance) return false; if (!deterministicChance(this, "fight-defeat", chance, victim, attacker, damage)) return false;
victim.defeatedById = attacker.id; victim.defeatedById = attacker.id;
victim.fightWinnerId = attacker.id; victim.fightWinnerId = attacker.id;
attacker.fightWinnerId = attacker.id; attacker.fightWinnerId = attacker.id;
attacker.defeatedById = null; attacker.defeatedById = null;
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42)); victim.fightTimer = Math.min(victim.fightTimer || 0, deterministicRange(this, "fight-defeat-victim-timer", 0.20, 0.42, victim, attacker));
attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42)); attacker.fightTimer = Math.min(attacker.fightTimer || 0, deterministicRange(this, "fight-defeat-attacker-timer", 0.20, 0.42, victim, attacker));
this.emit?.("fight:lost", { winner: attacker, loser: victim, damage }); this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" }); if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); } else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
const dx = victim.x - attacker.x; const dx = victim.x - attacker.x;
const dy = victim.y - attacker.y; const dy = victim.y - attacker.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
victim.vx += dx / d * rand(38, 76); victim.vx += dx / d * deterministicRange(this, "fight-defeat-knockback-x", 38, 76, victim, attacker);
victim.vy += dy / d * rand(22, 56); victim.vy += dy / d * deterministicRange(this, "fight-defeat-knockback-y", 22, 56, victim, attacker);
this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); this.spawnBubble?.(victim.x, victim.y - victim.radius * 1.25, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
return true; return true;
}, },
@ -493,7 +545,7 @@
this.emit?.("fight:ended", { winner, loser }); this.emit?.("fight:ended", { winner, loser });
this.markFightPairCooldown?.(winner, loser, CONFIG.fightCooldown + 8); this.markFightPairCooldown?.(winner, loser, CONFIG.fightCooldown + 8);
for (const t of [winner, loser]) { for (const t of [winner, loser]) {
t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + rand(4, 8)); t.fightCooldown = Math.max(t.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this, "fight-outcome-cooldown", 4, 8, t, winner, loser));
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival"); if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && e.id === "fight_rival");
} }
winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); winner.adjustPersonality?.("aggression", 0.018, "after winning fights.");
@ -503,7 +555,7 @@
loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
winner.affection = clamp(winner.affection + 0.8, 0, 80); winner.affection = clamp(winner.affection + 0.8, 0, 80);
if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -rand(12, 22), fulfill: -rand(8, 16) }); if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -deterministicRange(this, "fight-win-safety-relief", 12, 22, winner, loser), fulfill: -deterministicRange(this, "fight-win-fulfill-relief", 8, 16, winner, loser) });
if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) {
this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] });
} }
@ -545,7 +597,7 @@
let nx = (t.x - it.x) / d; let nx = (t.x - it.x) / d;
let ny = (t.y - it.y) / d; let ny = (t.y - it.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const a = rand(0, Math.PI * 2); const a = deterministicAngle(this, "drop-impact-overlap-normal", it, t);
nx = Math.cos(a); nx = Math.cos(a);
ny = Math.sin(a); ny = Math.sin(a);
} }
@ -574,7 +626,7 @@
if (isGenkotsu) { if (isGenkotsu) {
this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75); this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75);
for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" })); for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" }));
for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + rand(-38, 38), it.y + rand(-22, 28), { size: rand(12, 22), life: rand(0.25, 0.48), color: "rgba(255,205,46,0.82)" })); for (let i = 0; i < 12; i++) this.effects.push(new Effect("fight", it.x + deterministicRange(this, "genkotsu-impact-effect-x", -38, 38, it, i), it.y + deterministicRange(this, "genkotsu-impact-effect-y", -22, 28, it, i), { size: deterministicRange(this, "genkotsu-impact-effect-size", 12, 22, it, i), life: deterministicRange(this, "genkotsu-impact-effect-life", 0.25, 0.48, it, i), color: "rgba(255,205,46,0.82)" }));
} else { } else {
this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" })); this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
} }
@ -609,7 +661,7 @@
}); });
for (const t of arr.slice(5)) { for (const t of arr.slice(5)) {
t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"); t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b");
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45); t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + deterministicRange(this, "ball-chaser-release-angle", -0.45, 0.45, t, ball);
} }
} }
}, },
@ -621,7 +673,7 @@
let dy = ball.y - y; let dy = ball.y - y;
let d = Math.hypot(dx, dy); let d = Math.hypot(dx, dy);
if (d < 0.001) { if (d < 0.001) {
const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55); const a = (ball.lastPokeAngle ?? deterministicAngle(this, "ball-poke-overlap-base", ball, x, y)) + deterministicRange(this, "ball-poke-overlap-jitter", -0.55, 0.55, ball, x, y);
dx = Math.cos(a); dx = Math.cos(a);
dy = Math.sin(a); dy = Math.sin(a);
d = 1; d = 1;
@ -703,7 +755,7 @@
const rvy = (b.vy || 0) - (a.vy || 0); const rvy = (b.vy || 0) - (a.vy || 0);
const rs = Math.hypot(rvx, rvy); const rs = Math.hypot(rvx, rvy);
if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; } if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; }
else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; } else { const ang = deterministicAngle(this, "ball-ball-overlap-normal", a, b); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; }
} }
const nx = dx / d; const nx = dx / d;
const ny = dy / d; const ny = dy / d;
@ -770,7 +822,7 @@
ny = (ball.vy || 0) / ballSpeed; ny = (ball.vy || 0) / ballSpeed;
} else { } else {
nx = t.facingDir ? t.facingDir() : 1; nx = t.facingDir ? t.facingDir() : 1;
ny = rand(-0.22, 0.22); ny = deterministicRange(this, "ball-interaction-fallback-ny", -0.22, 0.22, ball, t);
} }
} }
@ -834,7 +886,7 @@
} }
t.goodMode = playIntent || playful ? "smile" : t.goodMode; t.goodMode = playIntent || playful ? "smile" : t.goodMode;
if (playIntent && now > (t.nextPlayBubbleAt || 0)) { if (playIntent && now > (t.nextPlayBubbleAt || 0)) {
t.nextPlayBubbleAt = now + rand(2.0, 3.8); t.nextPlayBubbleAt = now + deterministicRange(this, "ball-play-bubble-delay", 2.0, 3.8, ball, t);
this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
} }
if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
@ -847,20 +899,26 @@
const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD; const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD;
const now = this.time || 0; const now = this.time || 0;
if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map();
for (const a of this.tarinai || []) { const live = Array.from(this.tarinai || [])
if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; .filter(t => t && !t.dead && !this.isTarinaiHiddenInNestBox(t))
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")));
const candidates = [];
const maxCandidates = Math.max(24, Math.min(320, live.length * 6));
for (let i = 0; i < live.length; i += 1) {
const a = live[i];
const avx = a.vx || 0; const avx = a.vx || 0;
const avy = a.vy || 0; const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy); const aSpeed = Math.hypot(avx, avy);
if (aSpeed < threshold) continue; if (aSpeed < threshold) continue;
const px = Number.isFinite(a.prevX) ? a.prevX : a.x; const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x;
const py = Number.isFinite(a.prevY) ? a.prevY : a.y; const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y;
const sx = a.x - px; const ax1 = Number.isFinite(a.x) ? a.x : ax0;
const sy = a.y - py; const ay1 = Number.isFinite(a.y) ? a.y : ay0;
const segLenSq = sx * sx + sy * sy; const asx = ax1 - ax0;
const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; const asy = ay1 - ay0;
for (const b of this.nearbyTarinai(a.x, a.y, range)) { for (let j = 0; j < live.length; j += 1) {
if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; const b = live[j];
if (!b || b === a) continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
const bvx = b.vx || 0; const bvx = b.vx || 0;
@ -869,54 +927,93 @@
const relVy = avy - bvy; const relVy = avy - bvy;
const relSpeed = Math.hypot(relVx, relVy); const relSpeed = Math.hypot(relVx, relVy);
if (relSpeed < threshold) continue; if (relSpeed < threshold) continue;
let hitX = a.x; const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x;
let hitY = a.y; const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y;
if (segLenSq > 1) { const bx1 = Number.isFinite(b.x) ? b.x : bx0;
const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1); const by1 = Number.isFinite(b.y) ? b.y : by0;
hitX = px + sx * u; const bsx = bx1 - bx0;
hitY = py + sy * u; const bsy = by1 - by0;
} const rx0 = ax0 - bx0;
const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y)); const ry0 = ay0 - by0;
const rvx = asx - bsx;
const rvy = asy - bsy;
const relMoveSq = rvx * rvx + rvy * rvy;
const u = relMoveSq > 0.001 ? clamp(-(rx0 * rvx + ry0 * rvy) / relMoveSq, 0, 1) : 1;
const cx = rx0 + rvx * u;
const cy = ry0 + rvy * u;
const d = Math.max(0.001, Math.hypot(cx, cy));
const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74; const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74;
if (d > hitDistance) continue; if (d > hitDistance) continue;
this.tarinaiCollisionMemo.set(key, now); const score = (1 - u) * 4 + Math.max(0, hitDistance - d) * 0.12 + Math.min(5, relSpeed / 120);
const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b; candidates.push({ a, b, key, relSpeed, u, cx, cy, d, hitDistance, score });
const target = impactor === a ? b : a; if (candidates.length >= maxCandidates) break;
const ivx = impactor.vx || 0;
const ivy = impactor.vy || 0;
const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1;
let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const aa = rand(0, Math.PI * 2);
nx = Math.cos(aa);
ny = Math.sin(aa);
}
const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 });
this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
panic: true,
target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55,
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
});
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
this.applyImpactDamage(target, damage, "\u885d\u7a81");
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) {
if (!t || t.dead) continue;
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = nx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
}
this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident", { participants: [target, impactor] });
} }
if (candidates.length >= maxCandidates) break;
} }
candidates.sort((p, q) => (p.u - q.u) || (q.score - p.score) || String(p.key).localeCompare(String(q.key)));
const maxImpacts = Math.max(8, Math.min(64, Number(this.maxTarinaiCollisionImpacts || 32) || 32));
let solved = 0;
for (const c of candidates) {
if (solved >= maxImpacts) break;
const { a, b, key, relSpeed, hitDistance } = c;
if (!a || !b || a.dead || b.dead) continue;
if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
this.tarinaiCollisionMemo.set(key, now);
solved += 1;
const as = Math.hypot(a.vx || 0, a.vy || 0);
const bs = Math.hypot(b.vx || 0, b.vy || 0);
const impactor = as >= bs ? a : b;
const target = impactor === a ? b : a;
const ivx = impactor.vx || 0;
const ivy = impactor.vy || 0;
const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1;
let nx = impactSpeed > 0.001 ? ivx / impactSpeed : 0;
let ny = impactSpeed > 0.001 ? ivy / impactSpeed : 0;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
const dx = target.x - impactor.x;
const dy = target.y - impactor.y;
const dd = Math.hypot(dx, dy);
if (dd > 0.001) { nx = dx / dd; ny = dy / dd; }
else {
const angle = (typeof stableUnit === "function" ? stableUnit(key, "tarinai-collision-normal") : 0.5) * Math.PI * 2;
nx = Math.cos(angle);
ny = Math.sin(angle);
}
}
const overlap = Math.max(0, hitDistance - Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)));
if (overlap > 0.5) {
const sep = Math.min(18, overlap * 0.45 + 0.8);
impactor.x = clamp((impactor.x || 0) - nx * sep * 0.45, CONFIG.worldPadding, this.w - CONFIG.worldPadding);
impactor.y = clamp((impactor.y || 0) - ny * sep * 0.45, CONFIG.worldPadding, this.h - CONFIG.worldPadding);
target.x = clamp((target.x || 0) + nx * sep * 0.55, CONFIG.worldPadding, this.w - CONFIG.worldPadding);
target.y = clamp((target.y || 0) + ny * sep * 0.55, CONFIG.worldPadding, this.h - CONFIG.worldPadding);
this.markSpatialDirty?.("tarinai-highspeed-separation");
}
const damage = physicalDamageFromImpactSpeed(relSpeed, { min: 1.2, max: 22, scale: 26 });
this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
panic: true,
target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55,
thought: "高速でぶつかって混乱している",
});
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
this.applyImpactDamage(target, damage, "衝突");
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "衝突");
for (const t of [target, impactor]) {
if (!t || t.dead) continue;
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true });
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = nx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
}
this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}は高速の${impactor.name}に衝突して弾き飛ばされた。`, "accident", { participants: [target, impactor] });
}
if (solved && this.spatialDirty) this.rebuildSpatial?.(true, "post-tarinai-highspeed-collision");
} }
})); }));
})(typeof window !== "undefined" ? window : globalThis); })(typeof window !== "undefined" ? window : globalThis);

View file

@ -28,10 +28,60 @@
return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s }; return { left: cx - ex, right: cx + ex, top: cy - ey, bottom: cy + ey, cos: c, sin: s };
} }
function obstacleQueryDistanceLocal(x, y, it) {
if (!it) return Infinity;
const type = String(it.type || "");
let reach = Math.max(32, Number(it.r || it.radius || 32) || 32);
if (type === "fence" || type === "fence_v" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9);
else if (type === "nest_box") reach = Math.max(92, reach * 1.9);
else reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, reach * 1.6);
return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach);
}
function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) {
if (!candidates || typeof visitor !== "function") return 0;
const source = candidates;
const n = Number(source.length || 0) || 0;
if (n <= 0) return 0;
const keepMax = Math.max(1, Math.min(n, Math.max(limit + 6, limit * 3)));
const kept = [];
const dists = [];
const include = opts.include || null;
const exclude = opts.exclude || null;
const stamp = opts.stamp;
// Hot path: candidate counts are usually small. Keep a bounded sorted
// prefix without allocating intermediate Array.from()/sort() output. For
// large candidate sets, insertion is limited to the top keepMax entries.
for (let i = 0; i < n; i++) {
const it = source[i];
if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) continue;
if (include && !include(it)) continue;
const d = obstacleQueryDistanceLocal(x, y, it);
if (kept.length >= keepMax && d >= dists[dists.length - 1]) continue;
let pos = kept.length;
while (pos > 0 && d < dists[pos - 1]) pos -= 1;
if (pos >= keepMax) continue;
kept.splice(pos, 0, it);
dists.splice(pos, 0, d);
if (kept.length > keepMax) {
kept.length = keepMax;
dists.length = keepMax;
}
}
let visited = 0;
for (let i = 0; i < kept.length; i++) {
visited += 1;
if (visitor(kept[i]) === true) break;
}
return visited;
}
function sanitizeRotatorSegmentsLocal(item) { function sanitizeRotatorSegmentsLocal(item) {
const raw = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : []; const raw = global.TarinaiPhysicsBodySystem?.segments?.(item) || [];
const out = []; const out = [];
const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0)); const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0));
for (const seg of raw) { for (const seg of raw) {
@ -53,7 +103,7 @@
for (const seg of sanitizeRotatorSegmentsLocal(item)) { for (const seg of sanitizeRotatorSegmentsLocal(item)) {
maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3]));
} }
return Math.min(460, maxD + Math.max(8, Number(item?.rotatorThickness || 12) || 12) + 8); return Math.min(460, maxD + Math.max(8, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12) + 8);
} }
function rotatorWorldSegmentsLocal(item) { function rotatorWorldSegmentsLocal(item) {
@ -74,7 +124,7 @@
const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1));
const angle = Math.atan2(y2 - y1, x2 - x1); const angle = Math.atan2(y2 - y1, x2 - x1);
const halfW = len / 2; const halfW = len / 2;
const halfH = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2; const halfH = Math.max(4, Math.min(34, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12)) / 2;
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle); const aabb = orientedRectAabb(cx, cy, halfW, halfH, angle);
@ -82,7 +132,7 @@
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom, left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
type: "rotator", item, rotator: true, restitution: 0.72, type: "rotator", item, rotator: true, restitution: 0.72,
angularVelocity: (item?.rotatorPowered === false ? 0 : (Number(item?.rotatorSpeed || 0) || 0)) + (Number(item?.rotatorAngularVelocity || 0) || 0), angularVelocity: (global.TarinaiPhysicsBodySystem?.scalar?.(item, "motorOn", true) === false ? 0 : (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "motorSpeed", 0) || 0) || 0)) + (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "spin", 0) || 0) || 0),
centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0, centerX: Number(item?.x) || 0, centerY: Number(item?.y) || 0,
}; };
} }
@ -96,7 +146,7 @@
function reciprocatorAxisLocal(item) { function reciprocatorAxisLocal(item) {
const fallback = angleForItemLocal(item); const fallback = angleForItemLocal(item);
const angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback); const angle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railAxis", fallback), fallback) : (global.TarinaiPhysicsBodySystem?.scalar?.(item, "railAxis", fallback) || fallback);
return { x: Math.cos(angle), y: Math.sin(angle), angle }; return { x: Math.cos(angle), y: Math.sin(angle), angle };
} }
@ -104,16 +154,16 @@
const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1)); const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1));
const segAngle = Math.atan2(y2 - y1, x2 - x1); const segAngle = Math.atan2(y2 - y1, x2 - x1);
const halfW = len / 2; const halfW = len / 2;
const halfH = Math.max(4, Math.min(34, Number(item?.rotatorThickness || 12) || 12)) / 2; const halfH = Math.max(4, Math.min(34, global.TarinaiPhysicsBodySystem?.scalar?.(item, "thickness", 12) || 12)) / 2;
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const aabb = orientedRectAabb(cx, cy, halfW, halfH, segAngle); const aabb = orientedRectAabb(cx, cy, halfW, halfH, segAngle);
const axis = reciprocatorAxisLocal(item); const axis = reciprocatorAxisLocal(item);
const itemX = Number(item?.x || 0) || 0; const itemX = Number(item?.x || 0) || 0;
const itemY = Number(item?.y || 0) || 0; const itemY = Number(item?.y || 0) || 0;
const reciprocatorDirection = Math.sign(Number(item?.reciprocatorDirection || 1) || 1) || 1; const railDirection = Math.sign(Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railDir", 1) || 1) || 1) || 1;
const motorVelocity = item?.reciprocatorPowered === false ? 0 : reciprocatorDirection * Math.max(0, Number(item?.reciprocatorSpeed || 0) || 0); const motorVelocity = global.TarinaiPhysicsBodySystem?.scalar?.(item, "railOn", true) === false ? 0 : railDirection * Math.max(0, Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "railMotorSpeed", 0) || 0) || 0);
const totalVelocity = motorVelocity + (Number(item?.reciprocatorVelocity || 0) || 0); const totalVelocity = motorVelocity + (Number(global.TarinaiPhysicsBodySystem?.scalar?.(item, "slideSpeed", 0) || 0) || 0);
const vx = axis.x * totalVelocity; const vx = axis.x * totalVelocity;
const vy = axis.y * totalVelocity; const vy = axis.y * totalVelocity;
return { return {
@ -145,6 +195,74 @@
return { x: nx * c - ny * s, y: nx * s + ny * c }; return { x: nx * c - ny * s, y: nx * s + ny * c };
} }
function segmentAabbHitLocal(x1, y1, x2, y2, left, top, right, bottom) {
let t0 = 0;
let t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
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;
};
if (!clip(-dx, x1 - left)) return null;
if (!clip(dx, right - x1)) return null;
if (!clip(-dy, y1 - top)) return null;
if (!clip(dy, bottom - y1)) return null;
if (t1 < 0 || t0 > 1) return null;
const t = clamp(t0, 0, 1);
const hx = x1 + dx * t;
const hy = y1 + dy * t;
const dl = Math.abs(hx - left);
const dr = Math.abs(right - hx);
const dt = Math.abs(hy - top);
const db = Math.abs(bottom - hy);
const m = Math.min(dl, dr, dt, db);
let nx = 0, ny = 0;
if (m === dl) nx = -1;
else if (m === dr) nx = 1;
else if (m === dt) ny = -1;
else ny = 1;
return { t, nx, ny };
}
function sweptCircleRectHitLocal(x1, y1, x2, y2, radius, r, padding = 0) {
if (!r) return null;
const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0);
if (r.oriented) {
const a = rectLocalPoint(r, x1, y1);
const b = rectLocalPoint(r, x2, y2);
const hit = segmentAabbHitLocal(a.x, a.y, b.x, b.y, -(r.halfW || 0) - rr, -(r.halfH || 0) - rr, (r.halfW || 0) + rr, (r.halfH || 0) + rr);
if (!hit) return null;
const n = rectWorldNormal(r, hit.nx, hit.ny);
return { t: hit.t, nx: n.x, ny: n.y, rect: r };
}
const hit = segmentAabbHitLocal(x1, y1, x2, y2, (r.left || 0) - rr, (r.top || 0) - rr, (r.right || 0) + rr, (r.bottom || 0) + rr);
return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null;
}
function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) {
if (!worldRef?.nearbySolidObstacleRects) return null;
const midX = (x1 + x2) * 0.5;
const midY = (y1 + y2) * 0.5;
const travel = Math.hypot(x2 - x1, y2 - y1);
const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180);
const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48);
let best = null;
for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks })) {
if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) continue;
const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, 0.5);
if (!hit) continue;
if (!best || hit.t < best.t) best = hit;
}
return best;
}
function segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, padding = 0) { function segmentIntersectsOrientedRectLocal(x1, y1, x2, y2, r, padding = 0) {
const a = rectLocalPoint(r, x1, y1); const a = rectLocalPoint(r, x1, y1);
const b = rectLocalPoint(r, x2, y2); const b = rectLocalPoint(r, x2, y2);
@ -296,7 +414,7 @@
}, },
rotatorSegments(it) { mechanicalSegments(it) {
return global.TarinaiMechanicalSystem?.sanitizeSegments?.(it) || sanitizeRotatorSegmentsLocal(it); return global.TarinaiMechanicalSystem?.sanitizeSegments?.(it) || sanitizeRotatorSegmentsLocal(it);
}, },
@ -359,33 +477,77 @@
}, },
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
const limit = Math.max(1, Number(maxChecks || 0) || 24);
const cacheable = !include && !exclude;
const stats = this.nearbyQueryStatsThisFrame || null;
const q = 48;
const qx = Math.floor((Number(x) || 0) / q);
const qy = Math.floor((Number(y) || 0) / q);
const qcx = (qx + 0.5) * q;
const qcy = (qy + 0.5) * q;
const inflatedRadius = Math.max(1, Number(radius || 0) || 1) + q * 0.82;
const qr = Math.ceil(inflatedRadius / q);
const cacheLimit = Math.min(96, Math.max(limit + 8, Math.ceil(limit * 1.55)));
const cacheKey = cacheable
? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit}`
: "";
if (cacheable) {
if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map();
const cached = this._solidObstacleRectQueryCache.get(cacheKey);
if (cached) {
if (stats) stats.obstacleRectHits = (stats.obstacleRectHits || 0) + 1;
return cached;
}
} else if (stats) {
stats.obstacleRectBypass = (stats.obstacleRectBypass || 0) + 1;
}
const queryX = cacheable ? qcx : x;
const queryY = cacheable ? qcy : y;
const queryRadius = cacheable ? qr * q : radius;
const queryLimit = cacheable ? cacheLimit : limit;
const rects = []; const rects = [];
const seen = new Set(); const stamp = (this._obstacleRectQueryStamp = (this._obstacleRectQueryStamp || 0) + 1);
let checked = 0; let checked = 0;
const addRectsFor = (it) => { const addRectsFor = (it) => {
if (!it || it === exclude || it.dead || seen.has(it)) return false; if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) return false;
if (include && !include(it)) return false; if (include && !include(it)) return false;
const partRects = this.solidObstacleRects(it); const partRects = this.solidObstacleRects(it);
if (!partRects.length) return false; if (!partRects.length) return false;
seen.add(it); it._obstacleRectQueryStamp = stamp;
checked += 1; checked += 1;
for (const rect of partRects) rects.push(rect); for (const rect of partRects) rects.push(rect);
return true; return true;
}; };
for (const it of this.nearbyItems(x, y, radius)) { const candidates = this.nearbyObstacles?.(queryX, queryY, queryRadius, false) || this.nearbyItems?.(queryX, queryY, queryRadius, false) || [];
visitNearestObstaclesLocal(queryX, queryY, candidates, queryLimit, (it) => {
addRectsFor(it); addRectsFor(it);
if (checked >= maxChecks) break; return checked >= queryLimit;
} }, { include, exclude, stamp });
// Large custom rotators can extend well beyond their center cell; scan them if (checked < queryLimit && !this.nearbyObstacles) {
// explicitly so their blades keep colliding even when the hub is far away. const mech = global.TarinaiMechanicalSystem;
if (checked < maxChecks) { const fallback = [];
for (const it of this.items || []) { for (const type of ["rotator", "poison_block", "reciprocator"]) {
if (!it || !global.TarinaiMechanicalSystem?.isMechanicalType?.(it.type) || it.dead || seen.has(it)) continue; for (const it of this.itemsOfType?.(type) || []) {
const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, it.r || 64); if (!it || it.dead || it._obstacleRectQueryStamp === stamp) continue;
if (distXY(x, y, it.x, it.y) > radius + reach + 36) continue; const reach = mech?.reach?.(it) || Math.max(60, it.r || 64);
addRectsFor(it); if (distXY(queryX, queryY, it.x, it.y) > queryRadius + reach + 36) continue;
if (checked >= maxChecks) break; fallback.push(it);
}
} }
visitNearestObstaclesLocal(queryX, queryY, fallback, queryLimit - checked, (it) => {
addRectsFor(it);
return checked >= queryLimit;
}, { include, exclude, stamp });
}
if (stats) {
stats.obstacleRectMisses = (stats.obstacleRectMisses || 0) + 1;
stats.obstacleRectBuilt = (stats.obstacleRectBuilt || 0) + rects.length;
}
if (cacheable) {
const cache = this._solidObstacleRectQueryCache || (this._solidObstacleRectQueryCache = new Map());
if (cache.size > 160) cache.clear();
cache.set(cacheKey, rects);
} }
return rects; return rects;
}, },
@ -394,7 +556,101 @@
return global.TarinaiCollisionFootprints?.pointInRect?.(x, y, r, padding) ?? false; return global.TarinaiCollisionFootprints?.pointInRect?.(x, y, r, padding) ?? false;
}, },
pushTarinaiOutOfRect(t, r, rr) {
nearbyPoisonBlockHazardRects(x, y, radius, { maxChecks = 24 } = {}) {
if ((this.itemCounts?.poison_block || 0) <= 0) return [];
const rects = [];
const stamp = (this._poisonHazardQueryStamp = (this._poisonHazardQueryStamp || 0) + 1);
let checked = 0;
const addRectsFor = (it) => {
if (!it || it.dead || it.type !== "poison_block" || it._poisonHazardQueryStamp === stamp) return false;
const reach = global.TarinaiMechanicalSystem?.reach?.(it) || Math.max(60, it.r || 64);
if (distXY(x, y, it.x, it.y) > radius + reach + 36) return false;
const partRects = global.TarinaiMechanicalSystem?.poisonHazardRects?.(it) || [];
if (!partRects.length) return false;
it._poisonHazardQueryStamp = stamp;
checked += 1;
for (const rect of partRects) rects.push(rect);
return true;
};
const candidates = this.nearbyHazards?.(x, y, radius + 48, false) || [];
for (const it of candidates) {
addRectsFor(it);
if (checked >= maxChecks) break;
}
if (checked < maxChecks && !this.nearbyHazards) {
for (const it of this.itemsOfType?.("poison_block") || []) {
if (addRectsFor(it) && checked >= maxChecks) break;
}
}
return rects;
},
circleOverlapsObstacleRect(x, y, radius, r, padding = 0) {
if (!r) return false;
const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0);
if (r.oriented) {
const local = rectLocalPoint(r, x, y);
const clx = clamp(local.x, -(r.halfW || 0), r.halfW || 0);
const cly = clamp(local.y, -(r.halfH || 0), r.halfH || 0);
return Math.hypot(local.x - clx, local.y - cly) < rr;
}
const cx = clamp(x, r.left, r.right);
const cy = clamp(y, r.top, r.bottom);
return Math.hypot(x - cx, y - cy) < rr;
},
applyPoisonBlockContactDamage(t, rect, rr) {
if (!t || t.dead || !rect?.poisonBlock || !rect.item || rect.item.dead) return false;
if (!this.circleOverlapsObstacleRect(t.x, t.y, rr, rect, 1.0)) return false;
const now = this.time || 0;
const key = rect.item.id || "poison";
t._poisonBlockHitAt = t._poisonBlockHitAt || Object.create(null);
const body = global.TarinaiPhysicsBodySystem?.ensureBody?.(rect.item, this, { syncFromLegacy: false });
const solid = body?.collision ? body.collision.solid !== false : true;
const cooldown = solid ? 0.30 : 0.42;
if ((t._poisonBlockHitAt[key] || -999) + cooldown > now) return false;
t._poisonBlockHitAt[key] = now;
const velocity = body?.velocity || {};
const vx = Number((velocity.x ?? rect.item.vx) || 0) || 0;
const vy = Number((velocity.y ?? rect.item.vy) || 0) || 0;
const omega = Number((velocity.angular ?? global.TarinaiPhysicsBodySystem?.scalar?.(rect.item, "spin", 0)) || 0) || 0;
const speed = Math.hypot(vx, vy) + Math.abs(omega) * Math.max(28, rect.item.r || 64) * 0.35;
const base = Math.max(1, Number((body?.hazard?.damage ?? global.TarinaiPhysicsBodySystem?.scalar?.(rect.item, "damage", 7)) || 7) || 7);
const damage = clamp(base * (solid ? 1.0 : 0.72) + speed * 0.018, 2.0, 18.0);
if (typeof this.applyImpactDamage === "function") this.applyImpactDamage(t, damage, "毒ブロック", { source: rect.item, x: t.x, y: t.y });
else if (typeof t.damage === "function") t.damage(damage, "毒ブロック");
// HPバーは描画側で「ダメージ中」の表示条件を見るため、毒ブロックでも明示的に傷み状態を立てる。
t.showHpBar?.(4.8);
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.82);
t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.28);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24);
if (typeof t.bubble === "function" && now >= (t.lastPoisonBlockBubbleAt || -999) + 0.85) {
t.lastPoisonBlockBubbleAt = now;
t.bubble("!", 0.48, "rgba(94,166,76,0.78)");
}
if (now >= (t.lastPoisonBlockEffectAt || -999) + 0.10) {
t.lastPoisonBlockEffectAt = now;
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(16, rr * 1.05), life: 0.18, color: "rgba(92,188,68,0.45)" }));
}
return true;
},
resolvePoisonBlockContactDamage(t, rr) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false;
if ((this.itemCounts?.poison_block || 0) <= 0) return false;
const now = this.time || 0;
const interval = Math.max(0.045, Math.min(0.10, Number(CONFIG?.poisonBlockProbeInterval || 0.075) || 0.075));
if ((t._nextPoisonBlockProbeAt || 0) > now) return false;
t._nextPoisonBlockProbeAt = now + interval;
let hit = false;
for (const rect of this.nearbyPoisonBlockHazardRects(t.x, t.y, rr + 150, { maxChecks: 32 })) {
hit = this.applyPoisonBlockContactDamage(t, rect, rr) || hit;
}
return hit;
},
pushTarinaiOutOfRect(t, r, rr, opts = {}) {
if (!t || !r) return false; if (!t || !r) return false;
const preVx = Number(t.vx || 0) || 0; const preVx = Number(t.vx || 0) || 0;
const preVy = Number(t.vy || 0) || 0; const preVy = Number(t.vy || 0) || 0;
@ -444,9 +700,12 @@
nx = dx / d; nx = dx / d;
ny = dy / d; ny = dy / d;
} }
const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8;
const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, rr * 2.8) : Math.max(10, rr * 0.92);
const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush);
const push = r.oriented && insidePenetration > 0 const push = r.oriented && insidePenetration > 0
? Math.min(insidePenetration + rr + 0.8, Math.max(18, rr * 2.8)) ? Math.min(insidePenetration + rr + slop, maxPush)
: Math.min(rr - d + 0.8, Math.max(10, rr * 0.92)); : Math.min(rr - d + slop, maxPush);
t.x += nx * push; t.x += nx * push;
t.y += ny * push; t.y += ny * push;
if (r.bounce) { if (r.bounce) {
@ -486,50 +745,122 @@
} }
} else if (r.mechanical) { } else if (r.mechanical) {
global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(t, r, nx, ny, preVx, preVy, { global.TarinaiMechanicalSystem?.applySurfaceVelocityToCircle?.(t, r, nx, ny, preVx, preVy, {
damping: r.rotator ? 0.78 : 0.80, damping: r.rotator ? 0.78 : (r.poisonBlock ? 0.74 : 0.80),
surfaceScale: r.rotator ? 0.52 : 0.64, surfaceScale: r.rotator ? 0.52 : (r.poisonBlock ? 0.38 : 0.64),
normalBoost: r.rotator ? 42 : 36, normalBoost: r.rotator ? 42 : (r.poisonBlock ? 30 : 36),
maxSpeed: r.rotator ? 680 : 620, maxSpeed: r.rotator ? 680 : (r.poisonBlock ? 560 : 620),
impulseVScale: r.rotator ? 0.10 : 0.08, impulseVScale: r.rotator ? 0.10 : (r.poisonBlock ? 0.05 : 0.08),
impulseMax: r.rotator ? 360 : 320, impulseMax: r.rotator ? 360 : (r.poisonBlock ? 260 : 320),
passiveImpulseScale: 0.82, passiveImpulseScale: 0.82,
spinKick: 0, spinKick: 0,
}); });
t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10); t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10);
const field = r.rotator ? "lastRotatorHitEffectAt" : "lastReciprocatorHitEffectAt"; const field = r.rotator ? "lastRotatorHitEffectAt" : (r.poisonBlock ? "lastPoisonBlockPushEffectAt" : "lastReciprocatorHitEffectAt");
if ((this.time || 0) >= (t[field] || -999) + 0.10) { if ((this.time || 0) >= (t[field] || -999) + 0.10) {
t[field] = this.time || 0; t[field] = this.time || 0;
this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : "rgba(90,150,214,0.40)" })); this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(14, rr * 0.95), life: 0.16, color: r.rotator ? "rgba(175,116,230,0.42)" : (r.poisonBlock ? "rgba(92,188,68,0.42)" : "rgba(90,150,214,0.40)") }));
} }
} else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; } else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
else t.vy *= -0.18; else t.vy *= -0.18;
return true; return true;
}, },
resolveSolidObstacleCollision(t) { moveTarinaiWithCollision(t, dx, dy, opts = {}) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; if (!t || t.dead) return false;
const rr = Math.max(8, t.radius * 0.74); const moveX = Number(dx || 0) || 0;
const moveY = Number(dy || 0) || 0;
const dist = Math.hypot(moveX, moveY);
if (dist <= 0.0001) return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: opts.reason || "tarinai-stationary" });
if (this.isTarinaiHiddenInNestBox?.(t)) {
t.x += moveX;
t.y += moveY;
return false;
}
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
const maxStep = Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8));
const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep)));
const stepX = moveX / steps;
const stepY = moveY / steps;
let collided = false;
for (let i = 0; i < steps; i++) {
const sx = t.x;
const sy = t.y;
const ex = sx + stepX;
const ey = sy + stepY;
const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, {
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
});
if (hit && hit.t <= 1) {
const safeT = Math.max(0, hit.t - 0.035);
t.x = sx + stepX * safeT;
t.y = sy + stepY * safeT;
const toward = (t.vx || 0) * hit.nx + (t.vy || 0) * hit.ny;
if (toward < 0) {
t.vx -= toward * hit.nx;
t.vy -= toward * hit.ny;
}
collided = this.resolveSolidObstacleCollision(t, {
maxPasses: 3,
searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170),
maxChecks: Math.max(opts.light ? 10 : 32, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
slop: 0.30,
reason: opts.reason || "tarinai-swept-hit",
}) || true;
continue;
}
t.x = ex;
t.y = ey;
const passHit = this.resolveSolidObstacleCollision(t, {
maxPasses: 2,
searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140),
maxChecks: Math.max(opts.light ? 10 : 28, Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24),
slop: 0.35,
reason: opts.reason || "tarinai-swept-move",
});
collided = passHit || collided;
}
if (collided) {
t.lastSolidObstacleCollisionAt = this.time || 0;
this.markSpatialDirty?.("tarinai-moved-collision");
}
return collided;
},
resolveSolidObstacleCollision(t, opts = {}) {
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false;
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
this.resolvePoisonBlockContactDamage(t, rr);
let pushed = false; let pushed = false;
let bounced = false; let bounced = false;
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2));
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150));
if (this.pushTarinaiOutOfRect(t, rect, rr)) { const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24);
pushed = true; for (let pass = 0; pass < maxPasses; pass++) {
if (rect?.bounce) bounced = true; let passPushed = false;
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks })) {
if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue;
if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) {
pushed = true;
passPushed = true;
if (rect?.bounce) bounced = true;
}
} }
if (!passPushed) break;
} }
if (pushed) { if (pushed) {
const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18); const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18);
t.x = clamp(t.x, pad, this.w - pad); t.x = clamp(t.x, pad, this.w - pad);
t.y = clamp(t.y, pad, this.h - pad); t.y = clamp(t.y, pad, this.h - pad);
const maxV = bounced ? 520 : 130; const maxV = bounced ? 520 : 130;
t.vx = clamp(t.vx || 0, -maxV, maxV); t.vx = clamp(t.vx || 0, -maxV, maxV);
t.vy = clamp(t.vy || 0, -maxV, maxV); t.vy = clamp(t.vy || 0, -maxV, maxV);
} }
return pushed;
}, },
resolveFenceCollision(t) { resolveFenceCollision(t) {
this.resolveSolidObstacleCollision(t); return this.resolveSolidObstacleCollision(t, { maxPasses: 2, reason: "resolve-fence" });
}, },
segmentIntersectsRect(x1, y1, x2, y2, r) { segmentIntersectsRect(x1, y1, x2, y2, r) {

View file

@ -1,4 +1,34 @@
"use strict"; "use strict";
(function ensureDeterministicHelpers(global) {
if (typeof global.deterministicChance === "function" && typeof global.deterministicRange === "function") return;
const clamp01 = v => Math.max(0, Math.min(1, Number(v) || 0));
const keyPart = value => {
if (value == null) return "null";
if (typeof value === "number") return Number.isFinite(value) ? value.toFixed(3) : "nan";
if (typeof value === "string" || typeof value === "boolean") return String(value);
if (typeof value === "object") {
const id = value.familyKey || value.id || value.seed || value.liveToken || value.name;
if (id) return `${value.type || value.constructor?.name || "entity"}:${id}`;
const x = Number.isFinite(value.x) ? value.x.toFixed(1) : "x";
const y = Number.isFinite(value.y) ? value.y.toFixed(1) : "y";
return `${value.type || value.constructor?.name || "entity"}@${x},${y}`;
}
return String(value);
};
const hashUnit = (seed, salt = "") => {
const str = `${seed}:${salt}`;
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
};
global.deterministicFrame = global.deterministicFrame || ((worldRef = null, hz = 60) => Math.floor((Number(worldRef?.time || 0) || 0) * Math.max(1, Number(hz) || 60) + 1e-6));
global.deterministicUnit = global.deterministicUnit || ((worldRef = null, salt = "", ...parts) => hashUnit(String(worldRef?.worldSeed || "tarinai-world"), [salt, global.deterministicFrame(worldRef, 60), Number(worldRef?._deterministicEpoch || 0) || 0, ...parts.map(keyPart)].join("|")));
global.deterministicRange = global.deterministicRange || ((worldRef = null, salt = "", min = 0, max = 1, ...parts) => (Number(min) || 0) + global.deterministicUnit(worldRef, salt, ...parts) * ((Number(max) || 0) - (Number(min) || 0)));
global.deterministicSigned = global.deterministicSigned || ((worldRef = null, salt = "", ...parts) => global.deterministicUnit(worldRef, salt, ...parts) < 0.5 ? -1 : 1);
global.deterministicChance = global.deterministicChance || ((worldRef = null, salt = "", chance = 0, ...parts) => { const p = clamp01(chance); return p >= 1 || (p > 0 && global.deterministicUnit(worldRef, salt, ...parts) < p); });
global.deterministicAngle = global.deterministicAngle || ((worldRef = null, salt = "", ...parts) => global.deterministicRange(worldRef, salt, 0, Math.PI * 2, ...parts));
})(typeof window !== "undefined" ? window : globalThis);
(function (global) { (function (global) {
const World = global.World; const World = global.World;
@ -539,6 +569,61 @@
return true; return true;
}, },
cancelBirthRitual(actor, options = {}) {
const source = actor || null;
const partnerId = source?.birthPartnerId || null;
const partner = partnerId ? this.liveTarinaiById?.(partnerId) : null;
const participants = [];
if (source) participants.push(source);
if (partner && partner !== source) participants.push(partner);
if (!participants.length) return false;
const reason = options.reason || "繁殖が中断された";
let canceled = false;
for (const t of participants) {
if (!t || t.dead) continue;
if ((t.birthRitualTimer || 0) <= 0.04 && t.state !== "birth_ritual") continue;
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
t.birthPartnerId = null;
t.birthRitualLeader = false;
t.birthRitualRole = 0;
t.postBirthPeaceTimer = Math.max(t.postBirthPeaceTimer || 0, 1.4);
t.behaviorLockTimer = Math.max(t.behaviorLockTimer || 0, 0.25);
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(t, { reason });
if (typeof clearForcedBehaviorQueue === "function") clearForcedBehaviorQueue(t, e => e && (e.id === "approach_mate" || e.id === "birth_ritual"));
if (options.panic && t === source) {
if (t.enterPanic) t.enterPanic({ target: options.target || null, reason, fear: options.fear ?? 0.95, wake: true, cause: options.cause || "birth_ritual_interrupted" });
else t.setActionState?.("panic", { target: options.target || null, reason, wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, options.fear ?? 0.95);
} else {
t.goIdle?.(reason);
t.thought = reason;
}
canceled = true;
}
if (canceled && !options.silentLog && this.time - (this.lastBirthCancelLogAt || -999) > 1.2) {
this.log?.(reason, "birth", { participants });
this.lastBirthCancelLogAt = this.time || 0;
}
return canceled;
},
cancelBirthRitualOnForce(tarinai, force = 0, options = {}) {
if (!tarinai || tarinai.dead) return false;
if ((tarinai.birthRitualTimer || 0) <= 0.04 && tarinai.state !== "birth_ritual") return false;
const amount = Number(force) || 0;
const threshold = Number(options.threshold || 180) || 180;
if (amount < threshold) return false;
return this.cancelBirthRitual(tarinai, {
reason: options.reason || "強い衝撃で繁殖が中断された",
target: options.target || null,
fear: options.fear ?? 1.0,
cause: options.cause || "external_force",
panic: options.panic !== false,
silentLog: !!options.silentLog,
});
},
finishBirthRitual(a, b) { finishBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return null; if (!a || !b || a.dead || b.dead) return null;
const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease;
@ -725,10 +810,10 @@
const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b)); const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b));
const target = actor === a ? b : a; const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return false; if (!this.canFightPair(actor, target)) return false;
actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35)); actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, deterministicRange(this, "fight-mochi-intimidate-actor-timer", 0.85, 1.35, actor, target));
actor.intimidateTargetId = target.id; actor.intimidateTargetId = target.id;
actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28);
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.75, 1.25)); target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "fight-mochi-intimidate-target-timer", 0.75, 1.25, actor, target));
target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear); target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * target.personalityProfile().fear);
actor.adjustRelation(target, -0.05, 0.06, "intimidate"); actor.adjustRelation(target, -0.05, 0.06, "intimidate");
target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate"); target.adjustRelation(actor, -0.12, 0.30 * target.personalityProfile().fear, "intimidate");
@ -751,8 +836,8 @@
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false; if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false;
const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-a", -5, 5, a, b);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + deterministicRange(this, "conflict-score-b", -5, 5, a, b);
const actor = scoreA >= scoreB ? a : b; const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a; const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return false; if (!this.canFightPair(actor, target)) return false;
@ -761,7 +846,7 @@
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite(); const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04; const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90); const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
if (canIntimidate && Math.random() < intimidationChance) { if (canIntimidate && deterministicChance(this, "conflict-intimidation-choice", intimidationChance, actor, target)) {
return this.startIntimidation(actor, target, actorScore, targetScore); return this.startIntimidation(actor, target, actorScore, targetScore);
} }
return this.startFight(actor, target, { initiator: actor }); return this.startFight(actor, target, { initiator: actor });
@ -780,13 +865,13 @@
const targetSize = target.effectiveScale ? target.effectiveScale() : (target.scale || 0.28); const targetSize = target.effectiveScale ? target.effectiveScale() : (target.scale || 0.28);
const sizeEdge = clamp((actorSize - targetSize) / 0.16, -1, 1); const sizeEdge = clamp((actorSize - targetSize) / 0.16, -1, 1);
const aggressionEdge = clamp(actorAggression - targetAggression * 0.55, -1, 1); const aggressionEdge = clamp(actorAggression - targetAggression * 0.55, -1, 1);
const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + rand(-5, 5); const resistance = target.energy * 0.30 + target.mood * 0.16 + targetAggression * 8 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + deterministicRange(this, "intimidation-resistance", -5, 5, actor, target);
const successChance = clamp(0.56 + (actorScore - targetScore + fearLoad - resistance) / 82 + aggressionEdge * 0.13 + sizeEdge * 0.16, 0.30, 0.96); const successChance = clamp(0.56 + (actorScore - targetScore + fearLoad - resistance) / 82 + aggressionEdge * 0.13 + sizeEdge * 0.16, 0.30, 0.96);
actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4); actor.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-actor-cooldown", 1, 4, actor, target);
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4); target.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-target-cooldown", 1, 4, actor, target);
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65)); actor.intimidateTimer = Math.max(actor.intimidateTimer, deterministicRange(this, "intimidation-actor-timer", 1.05, 1.65, actor, target));
actor.intimidateTargetId = target.id; actor.intimidateTargetId = target.id;
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); if (typeof setBehaviorText === "function") setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
@ -796,9 +881,9 @@
color: "rgba(145, 106, 55, 0.70)", color: "rgba(145, 106, 55, 0.70)",
})); }));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55)); target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.95, 1.55, actor, target));
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear); target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
if (Math.random() >= successChance) { if (!deterministicChance(this, "intimidation-success", successChance, actor, target)) {
actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation"); actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation");
target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate"); target.adjustRelation(actor, -0.08, 0.22 * target.personalityProfile().fear, "intimidate");
actor.intimidateTimer = 0; actor.intimidateTimer = 0;
@ -814,13 +899,13 @@
actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation"); actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation");
target.defeatedById = actor.id; target.defeatedById = actor.id;
target.fightWinnerId = actor.id; target.fightWinnerId = actor.id;
target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8)); target.defeatedTimer = Math.max(target.defeatedTimer, deterministicRange(this, "intimidation-defeated-timer", 1.8, 2.8, actor, target));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0)); target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-success-timer", 1.2, 2.0, actor, target));
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" }); if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); } else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); }
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54); target.vx += (target.x < actor.x ? -1 : 1) * deterministicRange(this, "intimidation-knockback-x", 26, 54, actor, target);
target.vy += rand(-18, 18); target.vy += deterministicRange(this, "intimidation-knockback-y", -18, 18, actor, target);
actor.adjustRelation(target, -0.08, 0.06, "intimidate"); actor.adjustRelation(target, -0.08, 0.06, "intimidate");
target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate"); target.adjustRelation(actor, -0.22, 0.85 * target.personalityProfile().fear, "intimidate");
if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) { if (this.time - Math.max(actor.lastLog, target.lastLog) > 5.5) {
@ -862,12 +947,12 @@
setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" }); setBehaviorText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" }); setBehaviorText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
} }
a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8)); a.fightTimer = Math.max(a.fightTimer, deterministicRange(this, "fight-start-timer-a", 3.2, 4.8, a, b));
b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8)); b.fightTimer = Math.max(b.fightTimer, deterministicRange(this, "fight-start-timer-b", 3.2, 4.8, a, b));
a.nextHeadbutt = this.time + rand(0.08, 0.18); a.nextHeadbutt = this.time + deterministicRange(this, "fight-start-headbutt", 0.08, 0.18, a, b);
b.nextHeadbutt = a.nextHeadbutt; b.nextHeadbutt = a.nextHeadbutt;
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5); a.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-a", 1, 5, a, b);
b.fightCooldown = CONFIG.fightCooldown + rand(1, 5); b.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "fight-start-cooldown-b", 1, 5, a, b);
if (!a.fightTargetIds) a.fightTargetIds = []; if (!a.fightTargetIds) a.fightTargetIds = [];
if (!b.fightTargetIds) b.fightTargetIds = []; if (!b.fightTargetIds) b.fightTargetIds = [];
if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id); if (!a.fightTargetIds.includes(b.id)) a.fightTargetIds.push(b.id);
@ -876,22 +961,24 @@
b.fightTargetIds = b.fightTargetIds.slice(-4); b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0]; a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0];
if (a.addStress) a.addStress(rand(8, 18), { threshold: 8 }); if (a.addStress) a.addStress(deterministicRange(this, "fight-start-stress-a", 8, 18, a, b), { threshold: 8 });
if (b.addStress) b.addStress(rand(8, 18), { threshold: 8 }); if (b.addStress) b.addStress(deterministicRange(this, "fight-start-stress-b", 8, 18, a, b), { threshold: 8 });
const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); const rawDamageToA = deterministicRange(this, "fight-pulse-damage-a", 2, 7, a, b);
const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); const rawDamageToB = deterministicRange(this, "fight-pulse-damage-b", 2, 7, a, b);
const damageToA = b.outgoingDamage ? b.outgoingDamage(rawDamageToA) : rawDamageToA;
const damageToB = a.outgoingDamage ? a.outgoingDamage(rawDamageToB) : rawDamageToB;
a.damage(damageToA, "\u55a7\u5629"); a.damage(damageToA, "\u55a7\u5629");
b.damage(damageToB, "\u55a7\u5629"); b.damage(damageToB, "\u55a7\u5629");
this.maybeDefeatFromFightDamage?.(a, b, damageToA); this.maybeDefeatFromFightDamage?.(a, b, damageToA);
this.maybeDefeatFromFightDamage?.(b, a, damageToB); this.maybeDefeatFromFightDamage?.(b, a, damageToB);
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
a.vx -= dx / d * rand(32, 62); a.vy -= dy / d * rand(32, 62); a.vx -= dx / d * deterministicRange(this, "fight-pulse-knockback-ax", 32, 62, a, b); a.vy -= dy / d * deterministicRange(this, "fight-pulse-knockback-ay", 32, 62, a, b);
b.vx += dx / d * rand(32, 62); b.vy += dy / d * rand(32, 62); b.vx += dx / d * deterministicRange(this, "fight-pulse-knockback-bx", 32, 62, a, b); b.vy += dy / d * deterministicRange(this, "fight-pulse-knockback-by", 32, 62, a, b);
a.surpriseTimer = 0.24; b.surpriseTimer = 0.24; a.surpriseTimer = 0.24; b.surpriseTimer = 0.24;
this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, { this.effects.push(new Effect("fight", (a.x + b.x) / 2, (a.y + b.y) / 2 - 8, {
size: rand(12, 20), size: deterministicRange(this, "fight-pulse-effect-size", 12, 20, a, b),
life: rand(0.32, 0.52), life: deterministicRange(this, "fight-pulse-effect-life", 0.32, 0.52, a, b),
color: "rgba(116, 73, 38, 0.82)", color: "rgba(116, 73, 38, 0.82)",
})); }));
audio.fight(); audio.fight();

View file

@ -161,9 +161,10 @@
<button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button> <button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div> </div>
<div class="rotator-editor-controls"> <div class="rotator-editor-controls">
<label>速度 <input id="rotatorSpeed" type="range" min="-360" max="360" step="5"><input id="rotatorSpeedNum" type="number" min="-720" max="720" step="5"> /</label> <label>速度 <input id="mechMotorSpeed" type="range" min="-360" max="360" step="5"><input id="mechMotorSpeedNum" type="number" min="-720" max="720" step="5"> /</label>
<label>太さ <input id="rotatorThickness" type="range" min="4" max="30" step="1"><input id="rotatorThicknessNum" type="number" min="4" max="34" step="1"></label> <label>太さ <input id="mechShapeThickness" type="range" min="4" max="30" step="1"><input id="mechShapeThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="rotatorPowered" type="checkbox"> 動力ON</label> <label><input id="mechMotorPowered" type="checkbox"> 動力ON</label>
<label id="poisonCollisionLabel"><input id="poisonSolidEnabled" type="checkbox"> 当たり判定ON</label>
</div> </div>
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法"> <div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
<button type="button" data-rotator-mode="line" class="active">直線</button> <button type="button" data-rotator-mode="line" class="active">直線</button>
@ -186,21 +187,32 @@
return dialog; return dialog;
} }
function physicsApi() { return global.TarinaiPhysicsBodySystem || null; }
function phyScalar(item, key, fallback = 0) { return physicsApi()?.scalar?.(item, key, fallback) ?? fallback; }
function phySet(item, key, value, reason = "editor") { return physicsApi()?.setScalar?.(item, key, value, reason) || false; }
function cloneSegmentsForRotator(item) { function cloneSegmentsForRotator(item) {
const src = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]]; const fallback = item?.type === "poison_block" ? [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]] : [[-78, 0, 78, 0], [0, -52, 0, 52]];
const src = physicsApi()?.segments?.(item) || fallback;
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]); return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
} }
function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 14 : 12)) {
return Math.max(...segments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + Math.max(4, thick) + 8;
}
function openRotatorEditor(item, worldRef) { function openRotatorEditor(item, worldRef) {
if (!item || item.type !== "rotator") return false; if (!item || (item.type !== "rotator" && item.type !== "poison_block")) return false;
const isPoisonBlock = item.type === "poison_block";
const dialog = ensureRotatorEditor(); const dialog = ensureRotatorEditor();
const canvas = dialog.querySelector("#rotatorCanvas"); const canvas = dialog.querySelector("#rotatorCanvas");
const ctx = canvas?.getContext?.("2d"); const ctx = canvas?.getContext?.("2d");
const speed = dialog.querySelector("#rotatorSpeed"); const speed = dialog.querySelector("#mechMotorSpeed");
const speedNum = dialog.querySelector("#rotatorSpeedNum"); const speedNum = dialog.querySelector("#mechMotorSpeedNum");
const thick = dialog.querySelector("#rotatorThickness"); const thick = dialog.querySelector("#mechShapeThickness");
const thickNum = dialog.querySelector("#rotatorThicknessNum"); const thickNum = dialog.querySelector("#mechShapeThicknessNum");
const poweredInput = dialog.querySelector("#rotatorPowered"); const poweredInput = dialog.querySelector("#mechMotorPowered");
const poisonCollisionInput = dialog.querySelector("#poisonSolidEnabled");
const poisonCollisionLabel = dialog.querySelector("#poisonCollisionLabel");
const titleEl = dialog.querySelector("#rotatorEditorTitle");
const apply = dialog.querySelector("#rotatorApply"); const apply = dialog.querySelector("#rotatorApply");
const cancel = dialog.querySelector("#rotatorCancel"); const cancel = dialog.querySelector("#rotatorCancel");
const closeBtn = dialog.querySelector("#rotatorEditorClose"); const closeBtn = dialog.querySelector("#rotatorEditorClose");
@ -213,13 +225,20 @@
let drawing = false; let drawing = false;
const cx = canvas.width / 2; const cx = canvas.width / 2;
const cy = canvas.height / 2; const cy = canvas.height / 2;
const deg = Math.round((Number(item.rotatorSpeed || 0) || 0) * 180 / Math.PI); const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI);
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12)); const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12)));
if (titleEl) titleEl.textContent = isPoisonBlock ? "毒ブロック" : "回転体";
const speedLabel = speed?.closest?.("label");
const poweredLabel = poweredInput?.closest?.("label");
if (speedLabel) speedLabel.style.display = isPoisonBlock ? "none" : "";
if (poweredLabel) poweredLabel.style.display = isPoisonBlock ? "none" : "";
if (poisonCollisionLabel) poisonCollisionLabel.style.display = isPoisonBlock ? "" : "none";
if (poisonCollisionInput) poisonCollisionInput.checked = phyScalar(item, "solid", true) !== false;
if (speed) speed.value = String(Math.max(-360, Math.min(360, deg))); if (speed) speed.value = String(Math.max(-360, Math.min(360, deg)));
if (speedNum) speedNum.value = String(deg); if (speedNum) speedNum.value = String(deg);
if (thick) thick.value = String(initialThickness); if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness); if (thickNum) thickNum.value = String(initialThickness);
if (poweredInput) poweredInput.checked = item.rotatorPowered !== false; if (poweredInput) poweredInput.checked = phyScalar(item, "motorOn", true) !== false;
const toLocal = (e) => { const toLocal = (e) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width); const sx = canvas.width / Math.max(1, rect.width);
@ -255,7 +274,7 @@
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(127,92,190,0.92)"; ctx.strokeStyle = isPoisonBlock ? "rgba(92,172,76,0.92)" : "rgba(127,92,190,0.92)";
ctx.lineWidth = lw; ctx.lineWidth = lw;
ctx.beginPath(); ctx.beginPath();
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
@ -272,8 +291,8 @@
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke(); ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
} }
ctx.fillStyle = "#f5edff"; ctx.fillStyle = isPoisonBlock ? "#eefee7" : "#f5edff";
ctx.strokeStyle = "#6d4ca0"; ctx.strokeStyle = isPoisonBlock ? "#3c8f32" : "#6d4ca0";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.restore(); ctx.restore();
@ -382,20 +401,26 @@
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null; canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
}; };
if (apply) apply.onclick = () => { if (apply) apply.onclick = () => {
global.TarinaiHistory?.capture?.(worldRef, "rotator-edit"); global.TarinaiHistory?.capture?.(worldRef, isPoisonBlock ? "poison-block-edit" : "rotator-edit");
cleanSegments(); cleanSegments();
item.rotatorSegments = segments.map(seg => seg.slice(0, 4)); physicsApi()?.setSegments?.(item, segments.map(seg => seg.slice(0, 4)), isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
item.rotatorSpeed = (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180; phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))), "editor-thickness");
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); if (isPoisonBlock) {
item.rotatorPowered = poweredInput ? Boolean(poweredInput.checked) : item.rotatorPowered !== false; phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
if (item.rotatorPowered) item.rotatorAngularVelocity = 0; phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
} else {
phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed");
phySet(item, "motorOn", poweredInput ? Boolean(poweredInput.checked) : phyScalar(item, "motorOn", true) !== false, "editor-power");
if (phyScalar(item, "motorOn", true)) phySet(item, "spin", 0, "editor-spin-reset");
}
if (!global.TarinaiPhysicsBodySystem?.invalidateItem?.(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
const extent = worldRef?.rotatorExtent?.(item); const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
item.rotatorEditorOpenAt = worldRef?.time || 0; item.rotatorEditorOpenAt = worldRef?.time || 0;
global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "rotator-link-remap"); global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap");
worldRef?.markSpatialDirty?.("rotator-edited"); worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited");
worldRef.drawListDirty = true; worldRef.drawListDirty = true;
worldRef?.log?.("回転体の設計を変更した。", "observe"); worldRef?.log?.(isPoisonBlock ? "毒ブロックの設計を変更した。" : "回転体の設計を変更した。", "observe");
global.render?.(); global.render?.();
close(); close();
}; };
@ -423,10 +448,10 @@
</div> </div>
<div class="rotator-editor-controls"> <div class="rotator-editor-controls">
<label>方向 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> </label> <label>方向 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> </label>
<label>速度 <input id="reciprocatorSpeed" type="range" min="0" max="240" step="5"><input id="reciprocatorSpeedNum" type="number" min="0" max="360" step="5"> px/</label> <label>速度 <input id="railMotorSpeedControl" type="range" min="0" max="240" step="5"><input id="railMotorSpeedControlNum" type="number" min="0" max="360" step="5"> px/</label>
<label>往復幅 <input id="reciprocatorTravel" type="range" min="48" max="360" step="4"><input id="reciprocatorTravelNum" type="number" min="24" max="520" step="4"> px</label> <label>往復幅 <input id="railTravelControl" type="range" min="48" max="360" step="4"><input id="railTravelControlNum" type="number" min="24" max="520" step="4"> px</label>
<label>太さ <input id="reciprocatorThickness" type="range" min="4" max="30" step="1"><input id="reciprocatorThicknessNum" type="number" min="4" max="34" step="1"></label> <label>太さ <input id="reciprocatorThickness" type="range" min="4" max="30" step="1"><input id="reciprocatorThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="reciprocatorPowered" type="checkbox"> 動力ON</label> <label><input id="railMotorPowered" type="checkbox"> 動力ON</label>
</div> </div>
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法"> <div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
<button type="button" data-reciprocator-mode="line" class="active">直線</button> <button type="button" data-reciprocator-mode="line" class="active">直線</button>
@ -456,13 +481,13 @@
const ctx = canvas?.getContext?.("2d"); const ctx = canvas?.getContext?.("2d");
const angle = dialog.querySelector("#reciprocatorAngle"); const angle = dialog.querySelector("#reciprocatorAngle");
const angleNum = dialog.querySelector("#reciprocatorAngleNum"); const angleNum = dialog.querySelector("#reciprocatorAngleNum");
const speed = dialog.querySelector("#reciprocatorSpeed"); const speed = dialog.querySelector("#railMotorSpeedControl");
const speedNum = dialog.querySelector("#reciprocatorSpeedNum"); const speedNum = dialog.querySelector("#railMotorSpeedControlNum");
const travel = dialog.querySelector("#reciprocatorTravel"); const travel = dialog.querySelector("#railTravelControl");
const travelNum = dialog.querySelector("#reciprocatorTravelNum"); const travelNum = dialog.querySelector("#railTravelControlNum");
const thick = dialog.querySelector("#reciprocatorThickness"); const thick = dialog.querySelector("#reciprocatorThickness");
const thickNum = dialog.querySelector("#reciprocatorThicknessNum"); const thickNum = dialog.querySelector("#reciprocatorThicknessNum");
const powered = dialog.querySelector("#reciprocatorPowered"); const powered = dialog.querySelector("#railMotorPowered");
const apply = dialog.querySelector("#reciprocatorApply"); const apply = dialog.querySelector("#reciprocatorApply");
const cancel = dialog.querySelector("#reciprocatorCancel"); const cancel = dialog.querySelector("#reciprocatorCancel");
const closeBtn = dialog.querySelector("#reciprocatorEditorClose"); const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
@ -475,11 +500,11 @@
let drawing = false; let drawing = false;
const cx = canvas.width / 2; const cx = canvas.width / 2;
const cy = canvas.height / 2; const cy = canvas.height / 2;
const axisRad = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0))); const axisRad = window.TarinaiMechanicalSystem?.railAxisAngle?.(item) ?? phyScalar(item, "railAxis", (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360; const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
const speedVal = Math.round(Number(item.reciprocatorSpeed || 92) || 0); const speedVal = Math.round(Number(phyScalar(item, "railMotorSpeed", 92)) || 0);
const travelVal = Math.round(Number(item.reciprocatorTravel || 150) || 150); const travelVal = Math.round(Number(phyScalar(item, "railTravel", 150)) || 150);
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12)); const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", 12) || 12) || 12));
if (angle) angle.value = String(Math.max(0, Math.min(355, deg))); if (angle) angle.value = String(Math.max(0, Math.min(355, deg)));
if (angleNum) angleNum.value = String(deg); if (angleNum) angleNum.value = String(deg);
if (speed) speed.value = String(Math.max(0, Math.min(240, speedVal))); if (speed) speed.value = String(Math.max(0, Math.min(240, speedVal)));
@ -488,7 +513,7 @@
if (travelNum) travelNum.value = String(travelVal); if (travelNum) travelNum.value = String(travelVal);
if (thick) thick.value = String(initialThickness); if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness); if (thickNum) thickNum.value = String(initialThickness);
if (powered) powered.checked = item.reciprocatorPowered !== false; if (powered) powered.checked = phyScalar(item, "railOn", true) !== false;
const toLocal = (e) => { const toLocal = (e) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width); const sx = canvas.width / Math.max(1, rect.width);
@ -660,17 +685,20 @@
global.TarinaiHistory?.capture?.(worldRef, "reciprocator-edit"); global.TarinaiHistory?.capture?.(worldRef, "reciprocator-edit");
cleanSegments(); cleanSegments();
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0)); const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
item.reciprocatorAxisAngle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angleDeg * Math.PI / 180, 0) : angleDeg * Math.PI / 180; const railAxis = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angleDeg * Math.PI / 180, 0) : angleDeg * Math.PI / 180;
item.reciprocatorSpeed = Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)); phySet(item, "railAxis", railAxis, "editor-rail-axis");
item.reciprocatorTravel = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)); phySet(item, "railMotorSpeed", Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)), "editor-rail-speed");
item.reciprocatorPowered = powered ? Boolean(powered.checked) : item.reciprocatorPowered !== false; phySet(item, "railTravel", Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)), "editor-rail-travel");
if (item.reciprocatorPowered) item.reciprocatorVelocity = 0; phySet(item, "railOn", powered ? Boolean(powered.checked) : phyScalar(item, "railOn", true) !== false, "editor-rail-power");
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset");
item.rotatorSegments = segments.map(seg => seg.slice(0, 4)); phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness");
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0))); physicsApi()?.setSegments?.(item, segments.map(seg => seg.slice(0, 4)), "editor-segments");
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5); const sa = window.TarinaiMechanicalSystem?.railAxisAngle?.(item) ?? phyScalar(item, "railAxis", typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0));
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0); const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0); const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "editor-rail-anchor");
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "editor-rail-anchor");
if (!global.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "reciprocator-edited")) global.TarinaiMechanicalSystem?.invalidateGeometry?.(item);
const extent = worldRef?.rotatorExtent?.(item); const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "reciprocator-link-remap"); global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "reciprocator-link-remap");
@ -700,8 +728,9 @@
if (type === "trace" || type === "splat" || type === "ant_corpse") continue; if (type === "trace" || type === "splat" || type === "ant_corpse") continue;
if (type === "rope" || type === "rod") { if (type === "rope" || type === "rod") {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, worldRef); const pb = physicsApi();
const b = runtime?.endpointWorld?.(it.linkB, worldRef); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef);
const d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? Infinity; const d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? Infinity;
const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12; const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12;
if (d <= tolerance && !blockedReason) blockedReason = "link"; if (d <= tolerance && !blockedReason) blockedReason = "link";
@ -754,20 +783,13 @@
if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen); if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; } if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
if (item.type === "rotator") { if (item.type === "rotator") {
data.rotatorPowered = item.rotatorPowered !== false; data.physics = { motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0; }
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12; if (item.type === "poison_block") {
data.rotatorSegments = cloneSegmentsForRotator(item); data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 14) || 14), segments: cloneSegmentsForRotator(item) };
} }
if (item.type === "reciprocator") { if (item.type === "reciprocator") {
data.reciprocatorPowered = item.reciprocatorPowered !== false; data.physics = { railOn: phyScalar(item, "railOn", true) !== false, railMotorSpeed: Number(phyScalar(item, "railMotorSpeed", 92) || 92), railTravel: Number(phyScalar(item, "railTravel", 150) || 150), railPhase: Number(phyScalar(item, "railPhase", 0) || 0), railDir: Number(phyScalar(item, "railDir", 1) || 1), railAxis: Number(phyScalar(item, "railAxis", item.angle || 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
data.reciprocatorSpeed = Number(item.reciprocatorSpeed || 92) || 92;
data.reciprocatorTravel = Number(item.reciprocatorTravel || 150) || 150;
data.reciprocatorPhase = Number(item.reciprocatorPhase || 0) || 0;
data.reciprocatorDirection = Number(item.reciprocatorDirection || 1) || 1;
data.reciprocatorAxisAngle = Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : Number(item.angle || 0) || 0;
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
data.rotatorSegments = cloneSegmentsForRotator(item);
} }
return data; return data;
} }
@ -804,30 +826,46 @@
if (item.roles) item.roles.food = Boolean(item.storedFoodType); if (item.roles) item.roles.food = Boolean(item.storedFoodType);
} }
if (item.type === "rotator") { if (item.type === "rotator") {
item.rotatorPowered = data.rotatorPowered !== false; const ph = data.physics || {};
item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0; phySet(item, "motorOn", ph.motorOn !== false, "copy");
item.rotatorAngularVelocity = 0; phySet(item, "motorSpeed", Number(ph.motorSpeed || 0) || 0, "copy");
item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12)); phySet(item, "spin", 0, "copy");
item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item); phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
item.r = Math.max(item.r || 64, Math.min(460, Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8)); physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
item.r = Math.max(item.r || 64, Math.min(460, physicsShapeExtent(item)));
}
if (item.type === "poison_block") {
const ph = data.physics || {};
phySet(item, "solid", ph.solid !== false, "copy");
phySet(item, "damage", Math.max(1, Number(ph.damage || 7) || 7), "copy");
phySet(item, "spin", 0, "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 14) || 14)), "copy");
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
const extent = physicsShapeExtent(item);
item.r = Math.max(item.r || 64, Math.min(460, extent));
} }
if (item.type === "reciprocator") { if (item.type === "reciprocator") {
item.reciprocatorPowered = data.reciprocatorPowered !== false; const ph = data.physics || {};
item.reciprocatorSpeed = Math.max(0, Number(data.reciprocatorSpeed || 92) || 92); phySet(item, "railOn", ph.railOn !== false, "copy");
item.reciprocatorTravel = Math.max(24, Number(data.reciprocatorTravel || 150) || 150); phySet(item, "railMotorSpeed", Math.max(0, Number(ph.railMotorSpeed || 92) || 92), "copy");
item.reciprocatorPhase = Math.max(-1, Math.min(1, Number(data.reciprocatorPhase || 0) || 0)); phySet(item, "railTravel", Math.max(24, Number(ph.railTravel || 150) || 150), "copy");
item.reciprocatorDirection = Number(data.reciprocatorDirection || 1) || 1; phySet(item, "railPhase", Math.max(-1, Math.min(1, Number(ph.railPhase || 0) || 0)), "copy");
item.reciprocatorAxisAngle = Number.isFinite(Number(data.reciprocatorAxisAngle)) ? Number(data.reciprocatorAxisAngle) : (Number(item.reciprocatorAxisAngle || item.angle || 0) || 0); phySet(item, "railDir", Number(ph.railDir || 1) || 1, "copy");
item.reciprocatorVelocity = 0; phySet(item, "railAxis", Number.isFinite(Number(ph.railAxis)) ? Number(ph.railAxis) : (Number(item.angle || 0) || 0), "copy");
item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12)); phySet(item, "slideSpeed", 0, "copy");
item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item); phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0))); physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5); const sa = window.TarinaiMechanicalSystem?.railAxisAngle?.(item) ?? phyScalar(item, "railAxis", typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0));
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * item.reciprocatorPhase; const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * item.reciprocatorPhase; const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8; phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "copy");
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "copy");
const extent = physicsShapeExtent(item);
item.r = Math.max(item.r || 64, Math.min(460, extent)); item.r = Math.max(item.r || 64, Math.min(460, extent));
} }
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) {
if (!global.TarinaiPhysicsBodySystem?.invalidateItem?.(item, "item-data-restore")) global.TarinaiMechanicalSystem.invalidateGeometry?.(item);
}
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
item.toolSize = data.toolSize || item.toolSize || "medium"; item.toolSize = data.toolSize || item.toolSize || "medium";
item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1; item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1;
@ -855,7 +893,7 @@
function isEditableItem(item) { function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator" || item.type === "reciprocator"); return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator");
} }
function findEditableItemAt(worldRef, x, y) { function findEditableItemAt(worldRef, x, y) {
@ -879,7 +917,7 @@
function openEditableItemEditor(item, worldRef) { function openEditableItemEditor(item, worldRef) {
if (!isEditableItem(item)) return false; if (!isEditableItem(item)) return false;
if (item.type === "signboard") return openSignboardEditor(item, worldRef); if (item.type === "signboard") return openSignboardEditor(item, worldRef);
if (item.type === "rotator") return openRotatorEditor(item, worldRef); if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef); if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "gate_fence") { if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen; item.gateOpen = !item.gateOpen;
@ -960,8 +998,9 @@
const r = softItemRadius(softItem); const r = softItemRadius(softItem);
if (physicsItem.type === "rope" || physicsItem.type === "rod") { if (physicsItem.type === "rope" || physicsItem.type === "rod") {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(physicsItem.linkA, worldRef); const pb = physicsApi();
const b = runtime?.endpointWorld?.(physicsItem.linkB, worldRef); const a = runtime?.endpointWorld?.(pb?.endpoint?.(physicsItem, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(physicsItem, 1), worldRef);
if (a && b) { if (a && b) {
const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y); const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y);
return Number.isFinite(d) && d <= r + (physicsItem.type === "rod" ? 9 : 7); return Number.isFinite(d) && d <= r + (physicsItem.type === "rod" ? 9 : 7);
@ -981,7 +1020,7 @@
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) { if (global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)) {
search = Math.max(search, (global.TarinaiMechanicalSystem?.reach?.(item) || item.r || 80) + 96); search = Math.max(search, (global.TarinaiMechanicalSystem?.reach?.(item) || item.r || 80) + 96);
} else if (item.type === "rope" || item.type === "rod") { } else if (item.type === "rope" || item.type === "rod") {
search = Math.max(search, Number(item.linkLength || (item.r || 40) * 2 || 80) * 0.55 + 96); search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96);
} }
let removed = 0; let removed = 0;
for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) { for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
@ -1330,11 +1369,12 @@
} }
if (mechanismTarget) { if (mechanismTarget) {
if (global.TarinaiMechanicalSystem?.applyPokeImpulse?.(mechanismTarget, x, y, this)) { if (global.TarinaiMechanicalSystem?.applyPokeImpulse?.(mechanismTarget, x, y, this)) {
const label = mechanismTarget.type === "rotator" ? "回転体" : "往復体"; const label = mechanismTarget.type === "rotator" ? "回転体" : (mechanismTarget.type === "poison_block" ? "毒ブロック" : "往復体");
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color: mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : "rgba(90,150,214,0.40)" })); const color = mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : (mechanismTarget.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)");
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color }));
this.drawListDirty = true; this.drawListDirty = true;
audio.poke?.(); audio.poke?.();
this.log(`動力OFFの${label}をつついて動かした。`, "observe", { eventType: "mechanism_poke", hiddenFromObservation: true }); this.log(`${label}をつついて動かした。`, "observe", { eventType: "mechanism_poke", hiddenFromObservation: true });
return; return;
} }
} }
@ -1377,7 +1417,7 @@
const rawItem = this.applyToolSize(new Item(itemType, px, py)); const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (typeof isRotatableItemType === "function" && isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0); if (typeof isRotatableItemType === "function" && isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0);
if (itemType === "reciprocator") { if (itemType === "reciprocator") {
rawItem.reciprocatorAxisAngle = rawItem.angle; global.TarinaiPhysicsBodySystem?.setScalar?.(rawItem, "railAxis", rawItem.angle, "raw-item-axis");
rawItem.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0; rawItem.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0;
} }
// Placement uses the same footprint that the preview validates. // Placement uses the same footprint that the preview validates.

View file

@ -23,6 +23,13 @@
bedConflictRuns: 0, bedConflictRuns: 0,
bedConflictSkips: 0, bedConflictSkips: 0,
}; };
this.spatialDirtyReasonCountsThisFrame = {};
this.spatialRebuildReasonCountsThisFrame = {};
this.deferredSpatialReadsThisFrame = 0;
this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
this.nearbyQueryStatsThisFrame = { obstacleRectHits: 0, obstacleRectMisses: 0, obstacleRectBypass: 0, obstacleRectBuilt: 0 };
this.constraintDirtyStatsThisFrame = { marked: 0, coalesced: 0 };
this._solidObstacleRectQueryCache = new Map();
}, },
spendScheduledWork(kind = "ai", urgent = false) { spendScheduledWork(kind = "ai", urgent = false) {
@ -77,6 +84,9 @@
rebuildsTotal: this.spatialRebuildsTotal || 0, rebuildsTotal: this.spatialRebuildsTotal || 0,
partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0, partialRebuildsTotal: this.spatialPartialRebuildsTotal || 0,
lastRebuildReason: this.lastSpatialRebuildReason || "", lastRebuildReason: this.lastSpatialRebuildReason || "",
dirtyReasons: this.spatialDirtyReasonCountsThisFrame || {},
rebuildReasons: this.spatialRebuildReasonCountsThisFrame || {},
deferredReads: this.deferredSpatialReadsThisFrame || 0,
}, },
render: { render: {
drawList: (this.drawList || []).length, drawList: (this.drawList || []).length,
@ -105,6 +115,10 @@
stats: this.workStats || null, stats: this.workStats || null,
}, },
scheduler: this._itemUpdateSchedulerStats || null, scheduler: this._itemUpdateSchedulerStats || null,
creatures: this._creatureUpdateStats || null,
terrain: this.terrainDirtyStatsThisFrame || null,
nearby: this.nearbyQueryStatsThisFrame || null,
constraintDirty: this.constraintDirtyStatsThisFrame || null,
perf: window.TarinaiPerf?.snapshot?.() || null, perf: window.TarinaiPerf?.snapshot?.() || null,
}; };
}, },
@ -146,10 +160,32 @@
ensureSpatial(reason = "read") { ensureSpatial(reason = "read") {
if (!this.spatial) return false; if (!this.spatial) return false;
if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false; if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false;
// Rebuild at most once per frame. Queries after movement may use the previous
// frame's grid until the next update-start; this is acceptable for this game // During the creature pass, Tarinai movement marks only the Tarinai bucket
// and avoids repeated full-grid rebuilds under dense scenes. // dirty many times. Obstacle/static item cells are still valid, so spatial
if ((this.spatialRebuildsThisFrame || 0) > 0 && (this.spatialVersion || 0) > 0) return false; // reads can use the frame-start Tarinai grid and collapse those invalidations
// into one post-creature rebuild. Do not defer static/dynamic/ant dirtiness:
// those buckets are used by collision and contact queries and need fresh data.
const inDeferredPhase = (this.deferSpatialRebuildDepth || 0) > 0 && (this.spatialVersion || 0) > 0;
const canDeferTarinaiOnly = inDeferredPhase
&& this.spatialTarinaiDirty
&& !this.spatialStaticItemsDirty
&& !this.spatialDynamicItemsDirty
&& !this.spatialAntDirty;
const canDeferMobileOnly = inDeferredPhase
&& String(this.deferSpatialRebuildMode || "") === "mobile"
&& !this.spatialStaticItemsDirty
&& (this.spatialDynamicItemsDirty || this.spatialTarinaiDirty || this.spatialAntDirty);
if (canDeferTarinaiOnly || canDeferMobileOnly) {
this.deferredSpatialDirtyReason = reason;
this.deferredSpatialReadsThisFrame = (this.deferredSpatialReadsThisFrame || 0) + 1;
return false;
}
// Collision/contact queries must not read a previous-grid position after
// moving static/dynamic obstacles in the same frame. Partial rebuilds are
// already split by static/dynamic/tarinai/ant buckets, so keep those reads
// correct here and let dirty flags decide how much work is required.
return this.rebuildSpatial(true, reason); return this.rebuildSpatial(true, reason);
}, },
@ -231,6 +267,8 @@
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1; this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1; this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
this.lastSpatialRebuildReason = reason; this.lastSpatialRebuildReason = reason;
if (!this.spatialRebuildReasonCountsThisFrame) this.spatialRebuildReasonCountsThisFrame = {};
this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] = (this.spatialRebuildReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
return true; return true;
}, },

View file

@ -33,8 +33,9 @@
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") { if (it.type === "rope" || it.type === "rod") {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this); const pb = global.TarinaiPhysicsBodySystem;
const b = runtime?.endpointWorld?.(it.linkB, this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), this);
d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d; d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d;
if (d < 18 && d < bestD) { best = it; bestD = d; } if (d < 18 && d < bestD) { best = it; bestD = d; }
continue; continue;
@ -79,8 +80,9 @@
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (it.type === "rope" || it.type === "rod") { if (it.type === "rope" || it.type === "rod") {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this); const pb = global.TarinaiPhysicsBodySystem;
const b = runtime?.endpointWorld?.(it.linkB, this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), this);
d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d; d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? d;
if (d <= radius + 8) candidates.push(it); if (d <= radius + 8) candidates.push(it);
continue; continue;
@ -137,7 +139,7 @@
const hit = Math.max(18, (t.radius || 18) * 1.12); const hit = Math.max(18, (t.radius || 18) * 1.12);
const d = distXY(x, y, t.x, t.y); const d = distXY(x, y, t.x, t.y);
if (d <= hit && d < bestD) { if (d <= hit && d < bestD) {
best = { kind: "tarinai", id: t.id, type: t.type || "tarinai", label: t.name || "たりない", x: t.x, y: t.y, localX: 0, localY: 0, center: true }; best = { kind: "tarinai", id: t.id || "", liveToken: t.liveToken ?? null, _ref: t, type: t.type || "tarinai", label: t.name || "たりない", x: t.x, y: t.y, localX: 0, localY: 0, center: true };
bestD = d; bestD = d;
} }
} }
@ -152,8 +154,9 @@
let d = distXY(x, y, it.x, it.y); let d = distXY(x, y, it.x, it.y);
if (isConstraint) { if (isConstraint) {
const runtime = global.TarinaiLinkRuntime; const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, this); const pb = global.TarinaiPhysicsBodySystem;
const b = runtime?.endpointWorld?.(it.linkB, this); const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), this);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), this);
if (!a || !b) continue; if (!a || !b) continue;
d = runtime?.pointSegmentDistance?.(x, y, a.x, a.y, b.x, b.y) ?? d; d = runtime?.pointSegmentDistance?.(x, y, a.x, a.y, b.x, b.y) ?? d;
if (d > 20 || d >= bestD) continue; if (d > 20 || d >= bestD) continue;
@ -208,14 +211,17 @@
const constraintType = this.tool === "rod" ? "rod" : "rope"; const constraintType = this.tool === "rod" ? "rod" : "rope";
const item = new Item(constraintType, (p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5); const item = new Item(constraintType, (p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5);
item.world = this; item.world = this;
item.linkA = { ...first, x: p1.x, y: p1.y }; const pb = global.TarinaiPhysicsBodySystem;
item.linkB = { ...endpoint, x: p2.x, y: p2.y }; pb?.ensureConstraint?.(item, this, { syncFromLegacy: false });
item.linkLength = Math.max(24, distXY(p1.x, p1.y, p2.x, p2.y)); pb?.setEndpoint?.(item, 0, { ...first, x: p1.x, y: p1.y });
item.linkMidX = (p1.x + p2.x) * 0.5; pb?.setEndpoint?.(item, 1, { ...endpoint, x: p2.x, y: p2.y });
item.linkMidY = (p1.y + p2.y) * 0.5; const linkLen = Math.max(24, distXY(p1.x, p1.y, p2.x, p2.y));
item.linkMidVX = 0; pb?.setLinkScalar?.(item, "len", linkLen);
item.linkMidVY = 0; pb?.setLinkScalar?.(item, "midX", (p1.x + p2.x) * 0.5);
item.r = Math.max(18, item.linkLength * 0.5); pb?.setLinkScalar?.(item, "midY", (p1.y + p2.y) * 0.5);
pb?.setLinkScalar?.(item, "midVx", 0);
pb?.setLinkScalar?.(item, "midVy", 0);
item.r = Math.max(18, linkLen * 0.5);
item.amount = 999; item.amount = 999;
const placed = this.addItem?.(item, constraintType); const placed = this.addItem?.(item, constraintType);
this.pendingLinkEndpoint = null; this.pendingLinkEndpoint = null;

View file

@ -42,6 +42,8 @@
markSpatialDirty(reason = "manual") { markSpatialDirty(reason = "manual") {
this.spatialDirty = true; this.spatialDirty = true;
this.spatialDirtyReason = reason; this.spatialDirtyReason = reason;
if (!this.spatialDirtyReasonCountsThisFrame) this.spatialDirtyReasonCountsThisFrame = {};
this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] = (this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1; this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1; this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
this.lastSpatialDirtyReason = reason; this.lastSpatialDirtyReason = reason;
@ -50,7 +52,7 @@
this.spatialTarinaiDirty = true; this.spatialTarinaiDirty = true;
} else if (/ants?-moved|post-ant|ant/i.test(r) && !/ant_nest|ant-nest/i.test(r)) { } else if (/ants?-moved|post-ant|ant/i.test(r) && !/ant_nest|ant-nest/i.test(r)) {
this.spatialAntDirty = true; this.spatialAntDirty = true;
} else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi/i.test(r)) { } else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi|mechanical|rotator|reciprocator|poison-block|constraint|rod-/i.test(r)) {
this.spatialDynamicItemsDirty = true; this.spatialDynamicItemsDirty = true;
} else { } else {
this.spatialStaticItemsDirty = true; this.spatialStaticItemsDirty = true;
@ -61,27 +63,52 @@
}, },
markTerrainDirty(reason = "manual") { markTerrainDirty(reason = "manual") {
const reasonKey = String(reason || "manual");
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
this.terrainDirtyStatsThisFrame.raw += 1;
this.terrainDirtyStatsThisFrame.global += 1;
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
if (this.terrainDirtyGlobal && this.terrainDirtyReason === reasonKey) {
this.terrainDirtyStatsThisFrame.coalesced += 1;
this.terrainDirty = true;
this.terrainLastDirtyAt = this.time || 0;
return;
}
this.terrainDirty = true; this.terrainDirty = true;
this.terrainDirtyGlobal = true; this.terrainDirtyGlobal = true;
this.terrainDirtyReason = reason; this.terrainDirtyReason = reasonKey;
this.terrainVersion = (this.terrainVersion || 0) + 1; this.terrainVersion = (this.terrainVersion || 0) + 1;
this.terrainLastDirtyAt = this.time || 0; this.terrainLastDirtyAt = this.time || 0;
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion, global: true }); this.emit("terrain:dirty", { reason: reasonKey, terrainVersion: this.terrainVersion, global: true });
}, },
markTerrainDirtyAt(x, y, radius = 48, reason = "manual") { markTerrainDirtyAt(x, y, radius = 48, reason = "manual") {
const reasonKey = String(reason || "manual");
const cx = Math.floor((Number(x) || 0) / 256); const cx = Math.floor((Number(x) || 0) / 256);
const cy = Math.floor((Number(y) || 0) / 256); const cy = Math.floor((Number(y) || 0) / 256);
const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256)); const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256));
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
this.terrainDirtyStatsThisFrame.raw += 1;
this.terrainDirtyStatsThisFrame.chunk += 1;
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set(); if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set();
let added = 0;
for (let yy = cy - cr; yy <= cy + cr; yy++) { for (let yy = cy - cr; yy <= cy + cr; yy++) {
for (let xx = cx - cr; xx <= cx + cr; xx++) this.terrainDirtyChunks.add(`${xx}:${yy}`); for (let xx = cx - cr; xx <= cx + cr; xx++) {
const key = `${xx}:${yy}`;
if (!this.terrainDirtyChunks.has(key)) added += 1;
this.terrainDirtyChunks.add(key);
}
} }
this.terrainDirty = true; this.terrainDirty = true;
this.terrainDirtyReason = reason; this.terrainDirtyReason = reasonKey;
this.terrainVersion = (this.terrainVersion || 0) + 1;
this.terrainLastDirtyAt = this.time || 0; this.terrainLastDirtyAt = this.time || 0;
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion, global: false, x, y, radius }); if (added <= 0) {
this.terrainDirtyStatsThisFrame.coalesced += 1;
return true;
}
this.terrainVersion = (this.terrainVersion || 0) + 1;
this.emit("terrain:dirty", { reason: reasonKey, terrainVersion: this.terrainVersion, global: false, x, y, radius });
return true; return true;
}, },

View file

@ -194,6 +194,26 @@ def check_legacy_behavior_refs() -> None:
def check_legacy_physics_props_removed() -> None:
terms = [
"rotatorThickness", "rotatorSegments", "rotatorPowered", "rotatorSpeed", "rotatorAngularVelocity",
"poisonCollisionEnabled", "poisonDamage", "poisonMass", "poisonInertia",
"reciprocatorPowered", "reciprocatorSpeed", "reciprocatorTravel", "reciprocatorPhase",
"reciprocatorDirection", "reciprocatorAxisAngle", "reciprocatorVelocity", "reciprocatorAnchorX", "reciprocatorAnchorY",
"linkA", "linkB", "linkLength", "linkMidX", "linkMidY", "linkMidVX", "linkMidVY",
"ropeParticles", "_physicsAwakeUntil", "_linkAwakeUntil",
]
offenders = []
for path in (ROOT / "js").glob("*.js"):
text = read_text(path)
for term in terms:
if term in text:
offenders.append(f"{path.relative_to(ROOT).as_posix()}:{term}")
if offenders:
fail("legacy physics property names still present: " + ", ".join(offenders[:20]))
ok("legacy physics property names removed")
def check_basic_action_spec_runtime() -> None: def check_basic_action_spec_runtime() -> None:
source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"]) source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"])
required = { required = {
@ -1232,6 +1252,7 @@ def main() -> None:
check_processing_split() check_processing_split()
command_dispatcher_smoke() command_dispatcher_smoke()
check_legacy_behavior_refs() check_legacy_behavior_refs()
check_legacy_physics_props_removed()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,11 +1,11 @@
"use strict"; "use strict";
const APP_VERSION = "39.00.00"; const APP_VERSION = "39.15.18";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`; const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
const coreScriptNames = [ const coreScriptNames = [
"version", "event_bus", "registry_base", "disease_registry", "sound_pack", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "collision_footprint_system", "mechanical_system", "constraint_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_runtime", "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_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "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", "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", "world_update_phases", "system_order", "simulation", "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", "patch_helpers", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "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", "item_registry", "data", "ground_types", "input_mode_manager", "math", "physics_helpers", "collision_footprint_system", "mechanical_system", "constraint_system", "physics_world_system", "assets", "audio", "perf_profiler", "render", "sim_core", "tarinai_seed_factory", "structures", "items", "item_type_initializers", "item_lifecycle_support", "item_lifecycle_runtime", "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_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_forced_action_planner_system", "tarinai_action_planner_system", "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", "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", "world_update_phases", "system_order", "simulation", "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", "patch_helpers", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "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 = [ const CORE_ASSETS = [
"./", "./",