This commit is contained in:
33333-33333 2026-07-03 00:45:22 +09:00
commit ee22e1a929
49 changed files with 1380 additions and 338 deletions

View file

@ -1,3 +1,10 @@
tarinai_water_balloon_fan_ui_v1
- Fan drawing now shows the actual forward wind cone/range.
- Mobile rotator/reciprocator editor dialogs are scrollable; reciprocator canvas shares editor sizing.
- Balloon tool is replaced by water balloon visuals/text.
- Water balloons splash water when popped; low-openness tarinai panic and flee with behavior text: 水風船が割れておどろいている。
- Version: 39.15.116
tarinai_king_fix_v10
- Fire tool button hidden; flames spawn only from flame firecracker.
- Burning tarinai and ants draw lightweight flame-shaped overlays.

View file

@ -170,3 +170,23 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju
- The app supports manual save and restore through browser storage.
- Reset opens field selection and starts a new field state.
- Service worker caching allows the static app shell to remain available after load, depending on browser support.
## 39.15.116 土管外観・内部温度
- 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。
- 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。
- 土管ツールチップの内部気温表示にも、土管内補正後の温度を反映。
## 39.15.115 水風船の飛散強化・扇風機首振り設定
- 水風船破裂時に水滴が放射状に飛び、短時間移動してから水たまり状に落ち着く。
- 配置済み扇風機をクリックすると首振り設定UIを開ける。個別に首振りON/OFF、向き、振り幅、速度を設定可能。
- 首振り中の扇風機は風の範囲表示と実際の風判定が現在角度に追従する。
## 39.15.115 土管
- 生活カテゴリに「土管」を追加。丸い口が見える配置アイテムとして表示。
- たりないが巣箱のように土管の中へ入り、内部の個体は巣箱系ツールチップで確認可能。
- 土管から出る時は、複数ある場合は別のランダムな土管から出る。
- 土管内でも眠れるが、寝心地と睡眠優先順位は干草寝床の一段下。

View file

@ -1,5 +1,5 @@
{
"version": "39.15.110",
"version": "39.15.116",
"css": [
"css/base.css",
"css/layout.css",

View file

@ -450,6 +450,10 @@ h1 {
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96));
border: 1px solid rgba(94, 70, 42, 0.20);
box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72);
max-height: calc(100dvh - 36px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.signboard-editor-titlebar {
display: flex;
@ -595,6 +599,10 @@ h1 {
background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.97));
border: 1px solid rgba(94, 70, 42, 0.20);
box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72);
max-height: calc(100dvh - 36px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.rotator-editor-titlebar {
display: flex;
@ -660,7 +668,8 @@ h1 {
border-color: rgba(112, 80, 170, 0.38);
font-weight: 700;
}
#rotatorCanvas {
#rotatorCanvas,
#reciprocatorCanvas {
width: 100%;
height: min(58vh, 360px);
display: block;
@ -682,7 +691,44 @@ h1 {
margin-top: 12px;
}
@media (max-width: 560px) {
.rotator-editor-panel { padding: 10px; }
.rotator-editor {
place-items: start center;
padding: 8px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.rotator-editor-panel {
width: calc(100vw - 16px);
max-height: calc(100dvh - 16px);
padding: 10px;
}
.rotator-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
#rotatorCanvas { height: 320px; }
.rotator-editor-tools { max-height: 22vh; overflow-y: auto; padding-right: 2px; }
#rotatorCanvas, #reciprocatorCanvas { height: min(44vh, 270px); }
}
.fan-editor-panel { width: min(430px, calc(100vw - 28px)); }
.fan-editor-help {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin: 0 2px 10px;
}
.fan-editor-controls label {
grid-template-columns: 56px 1fr 72px auto;
}
.fan-editor-controls .fan-editor-check {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.58);
border: 1px solid rgba(84,68,48,0.11);
}
.fan-editor-controls input[type="checkbox"] { accent-color: #70aee8; }
@media (max-width: 560px) {
.fan-editor-controls label { grid-template-columns: 1fr; align-items: stretch; }
.fan-editor-panel { width: calc(100vw - 16px); }
}

View file

@ -7,11 +7,11 @@
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=16.07.17" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=16.07.17" />
<link rel="shortcut icon" href="favicon.ico?v=16.07.17" />
<link rel="stylesheet" href="css/base.css?v=39.15.110" />
<link rel="stylesheet" href="css/layout.css?v=39.15.110" />
<link rel="stylesheet" href="css/panel.css?v=39.15.110" />
<link rel="stylesheet" href="css/components.css?v=39.15.110" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.110" />
<link rel="stylesheet" href="css/base.css?v=39.15.116" />
<link rel="stylesheet" href="css/layout.css?v=39.15.116" />
<link rel="stylesheet" href="css/panel.css?v=39.15.116" />
<link rel="stylesheet" href="css/components.css?v=39.15.116" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.116" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -257,164 +257,164 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.110" defer></script>
<script src="js/event_bus.js?v=39.15.110" defer></script>
<script src="js/registry_base.js?v=39.15.110" defer></script>
<script src="js/disease_registry.js?v=39.15.110" defer></script>
<script src="js/sound_pack.js?v=39.15.110" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.110" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.110" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.110" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.110" defer></script>
<script src="js/item_food_definitions.js?v=39.15.110" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.110" defer></script>
<script src="js/item_registry.js?v=39.15.110" defer></script>
<script src="js/data.js?v=39.15.110" defer></script>
<script src="js/ground_types.js?v=39.15.110" defer></script>
<script src="js/input_mode_manager.js?v=39.15.110" defer></script>
<script src="js/math.js?v=39.15.110" defer></script>
<script src="js/geometry_helpers.js?v=39.15.110" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.110" defer></script>
<script src="js/physics_helpers.js?v=39.15.110" defer></script>
<script src="js/placement_preview_system.js?v=39.15.110" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.110" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.110" defer></script>
<script src="js/mechanical_system.js?v=39.15.110" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.110" defer></script>
<script src="js/constraint_system.js?v=39.15.110" defer></script>
<script src="js/physics_world_system.js?v=39.15.110" defer></script>
<script src="js/physics_projection_system.js?v=39.15.110" defer></script>
<script src="js/assets.js?v=39.15.110" defer></script>
<script src="js/audio.js?v=39.15.110" defer></script>
<script src="js/perf_profiler.js?v=39.15.110" defer></script>
<script src="js/render.js?v=39.15.110" defer></script>
<script src="js/sim_core.js?v=39.15.110" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.110" defer></script>
<script src="js/structures.js?v=39.15.110" defer></script>
<script src="js/items.js?v=39.15.110" defer></script>
<script src="js/item_type_initializers.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.110" defer></script>
<script src="js/item_update_policy.js?v=39.15.110" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.110" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.110" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.110" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.110" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.110" defer></script>
<script src="js/item_runtime.js?v=39.15.110" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.110" defer></script>
<script src="js/item_render_helpers.js?v=39.15.110" defer></script>
<script src="js/item_render_runtime.js?v=39.15.110" defer></script>
<script src="js/burn_motion_util.js?v=39.15.110" defer></script>
<script src="js/ants.js?v=39.15.110" defer></script>
<script src="js/health.js?v=39.15.110" defer></script>
<script src="js/tarinai.js?v=39.15.110" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.110" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.110" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.110" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.110" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.110" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.110" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.110" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.110" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.110" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.110" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.110" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.110" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.110" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.110" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.110" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.110" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.110" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.110" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.110" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.110" defer></script>
<script src="js/tarinai_render.js?v=39.15.110" defer></script>
<script src="js/world.js?v=39.15.110" defer></script>
<script src="js/world_view.js?v=39.15.110" defer></script>
<script src="js/family_graph.js?v=39.15.110" defer></script>
<script src="js/world_reset_presets.js?v=39.15.110" defer></script>
<script src="js/world_family_social.js?v=39.15.110" defer></script>
<script src="js/impact_core_system.js?v=39.15.110" defer></script>
<script src="js/impact_response_system.js?v=39.15.110" defer></script>
<script src="js/collision_response_system.js?v=39.15.110" defer></script>
<script src="js/world_combat_effects.js?v=39.15.110" defer></script>
<script src="js/world_environment.js?v=39.15.110" defer></script>
<script src="js/world_temperature_system.js?v=39.15.110" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.110" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.110" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.110" defer></script>
<script src="js/world_ants_system.js?v=39.15.110" defer></script>
<script src="js/weather_system.js?v=39.15.110" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.110" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.110" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.110" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.110" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.110" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.110" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.110" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.110" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.110" defer></script>
<script src="js/simulation_systems.js?v=39.15.110" defer></script>
<script src="js/system_order.js?v=39.15.110" defer></script>
<script src="js/simulation.js?v=39.15.110" defer></script>
<script src="js/colony_situation_system.js?v=39.15.110" defer></script>
<script src="js/world_update.js?v=39.15.110" defer></script>
<script src="js/world_tool_actions.js?v=39.15.110" defer></script>
<script src="js/world_placement_log.js?v=39.15.110" defer></script>
<script src="js/world_event_effects.js?v=39.15.110" defer></script>
<script src="js/command_dispatcher.js?v=39.15.110" defer></script>
<script src="js/text_catalog.js?v=39.15.110" defer></script>
<script src="js/ui.js?v=39.15.110" defer></script>
<script src="js/ui_log.js?v=39.15.110" defer></script>
<script src="js/ui_helpers.js?v=39.15.110" defer></script>
<script src="js/ui_selected.js?v=39.15.110" defer></script>
<script src="js/save_schema.js?v=39.15.110" defer></script>
<script src="js/snapshot_system.js?v=39.15.110" defer></script>
<script src="js/restore_coordinator.js?v=39.15.110" defer></script>
<script src="js/history_system.js?v=39.15.110" defer></script>
<script src="js/save_codec.js?v=39.15.110" defer></script>
<script src="js/save_storage.js?v=39.15.110" defer></script>
<script src="js/save_system.js?v=39.15.110" defer></script>
<script src="js/ui_tooltips.js?v=39.15.110" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.110" defer></script>
<script src="js/ui_ground.js?v=39.15.110" defer></script>
<script src="js/ui_tools.js?v=39.15.110" defer></script>
<script src="js/ui_input_shared.js?v=39.15.110" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.110" defer></script>
<script src="js/ui_input_touch.js?v=39.15.110" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.110" defer></script>
<script src="js/ui_bind.js?v=39.15.110" defer></script>
<script src="js/ui_charts.js?v=39.15.110" defer></script>
<script src="js/ui_family_data.js?v=39.15.110" defer></script>
<script src="js/ui_family_async.js?v=39.15.110" defer></script>
<script src="js/ui_family_layout.js?v=39.15.110" defer></script>
<script src="js/ui_family_paths.js?v=39.15.110" defer></script>
<script src="js/ui_family_render.js?v=39.15.110" defer></script>
<script src="js/main.js?v=39.15.110" defer></script>
<script src="js/debug_tools.js?v=39.15.110" defer></script>
<script src="js/version.js?v=39.15.116" defer></script>
<script src="js/event_bus.js?v=39.15.116" defer></script>
<script src="js/registry_base.js?v=39.15.116" defer></script>
<script src="js/disease_registry.js?v=39.15.116" defer></script>
<script src="js/sound_pack.js?v=39.15.116" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.116" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.116" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.116" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.116" defer></script>
<script src="js/item_food_definitions.js?v=39.15.116" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.116" defer></script>
<script src="js/item_registry.js?v=39.15.116" defer></script>
<script src="js/data.js?v=39.15.116" defer></script>
<script src="js/ground_types.js?v=39.15.116" defer></script>
<script src="js/input_mode_manager.js?v=39.15.116" defer></script>
<script src="js/math.js?v=39.15.116" defer></script>
<script src="js/geometry_helpers.js?v=39.15.116" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.116" defer></script>
<script src="js/physics_helpers.js?v=39.15.116" defer></script>
<script src="js/placement_preview_system.js?v=39.15.116" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.116" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_system.js?v=39.15.116" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.116" defer></script>
<script src="js/constraint_system.js?v=39.15.116" defer></script>
<script src="js/physics_world_system.js?v=39.15.116" defer></script>
<script src="js/physics_projection_system.js?v=39.15.116" defer></script>
<script src="js/assets.js?v=39.15.116" defer></script>
<script src="js/audio.js?v=39.15.116" defer></script>
<script src="js/perf_profiler.js?v=39.15.116" defer></script>
<script src="js/render.js?v=39.15.116" defer></script>
<script src="js/sim_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.116" defer></script>
<script src="js/structures.js?v=39.15.116" defer></script>
<script src="js/items.js?v=39.15.116" defer></script>
<script src="js/item_type_initializers.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.116" defer></script>
<script src="js/item_update_policy.js?v=39.15.116" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.116" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.116" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.116" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.116" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.116" defer></script>
<script src="js/item_runtime.js?v=39.15.116" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.116" defer></script>
<script src="js/item_render_helpers.js?v=39.15.116" defer></script>
<script src="js/item_render_runtime.js?v=39.15.116" defer></script>
<script src="js/burn_motion_util.js?v=39.15.116" defer></script>
<script src="js/ants.js?v=39.15.116" defer></script>
<script src="js/health.js?v=39.15.116" defer></script>
<script src="js/tarinai.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.116" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.116" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.116" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.116" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.116" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.116" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.116" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.116" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.116" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.116" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.116" defer></script>
<script src="js/tarinai_render.js?v=39.15.116" defer></script>
<script src="js/world.js?v=39.15.116" defer></script>
<script src="js/world_view.js?v=39.15.116" defer></script>
<script src="js/family_graph.js?v=39.15.116" defer></script>
<script src="js/world_reset_presets.js?v=39.15.116" defer></script>
<script src="js/world_family_social.js?v=39.15.116" defer></script>
<script src="js/impact_core_system.js?v=39.15.116" defer></script>
<script src="js/impact_response_system.js?v=39.15.116" defer></script>
<script src="js/collision_response_system.js?v=39.15.116" defer></script>
<script src="js/world_combat_effects.js?v=39.15.116" defer></script>
<script src="js/world_environment.js?v=39.15.116" defer></script>
<script src="js/world_temperature_system.js?v=39.15.116" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.116" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.116" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.116" defer></script>
<script src="js/world_ants_system.js?v=39.15.116" defer></script>
<script src="js/weather_system.js?v=39.15.116" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.116" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.116" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.116" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.116" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.116" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.116" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.116" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.116" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.116" defer></script>
<script src="js/simulation_systems.js?v=39.15.116" defer></script>
<script src="js/system_order.js?v=39.15.116" defer></script>
<script src="js/simulation.js?v=39.15.116" defer></script>
<script src="js/colony_situation_system.js?v=39.15.116" defer></script>
<script src="js/world_update.js?v=39.15.116" defer></script>
<script src="js/world_tool_actions.js?v=39.15.116" defer></script>
<script src="js/world_placement_log.js?v=39.15.116" defer></script>
<script src="js/world_event_effects.js?v=39.15.116" defer></script>
<script src="js/command_dispatcher.js?v=39.15.116" defer></script>
<script src="js/text_catalog.js?v=39.15.116" defer></script>
<script src="js/ui.js?v=39.15.116" defer></script>
<script src="js/ui_log.js?v=39.15.116" defer></script>
<script src="js/ui_helpers.js?v=39.15.116" defer></script>
<script src="js/ui_selected.js?v=39.15.116" defer></script>
<script src="js/save_schema.js?v=39.15.116" defer></script>
<script src="js/snapshot_system.js?v=39.15.116" defer></script>
<script src="js/restore_coordinator.js?v=39.15.116" defer></script>
<script src="js/history_system.js?v=39.15.116" defer></script>
<script src="js/save_codec.js?v=39.15.116" defer></script>
<script src="js/save_storage.js?v=39.15.116" defer></script>
<script src="js/save_system.js?v=39.15.116" defer></script>
<script src="js/ui_tooltips.js?v=39.15.116" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.116" defer></script>
<script src="js/ui_ground.js?v=39.15.116" defer></script>
<script src="js/ui_tools.js?v=39.15.116" defer></script>
<script src="js/ui_input_shared.js?v=39.15.116" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.116" defer></script>
<script src="js/ui_input_touch.js?v=39.15.116" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.116" defer></script>
<script src="js/ui_bind.js?v=39.15.116" defer></script>
<script src="js/ui_charts.js?v=39.15.116" defer></script>
<script src="js/ui_family_data.js?v=39.15.116" defer></script>
<script src="js/ui_family_async.js?v=39.15.116" defer></script>
<script src="js/ui_family_layout.js?v=39.15.116" defer></script>
<script src="js/ui_family_paths.js?v=39.15.116" defer></script>
<script src="js/ui_family_render.js?v=39.15.116" defer></script>
<script src="js/main.js?v=39.15.116" defer></script>
<script src="js/debug_tools.js?v=39.15.116" defer></script>
</body>
</html>

View file

@ -307,7 +307,7 @@ const audio = {
if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 });
if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 });
if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 });
if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
if (type === "ant_nest" || type === "nest_box" || type === "pipe") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 });
return this.play("sfx_place", { category: "ops", minGap: 0.08 });
},
delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); },

View file

@ -85,7 +85,7 @@
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
} else if (!rects.length) {
const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12);
const defaultMul = item.type === "ball" ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
const defaultMul = (item.type === "ball" || item.type === "balloon") ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
const mul = num(opts.radiusMultiplier, defaultMul);
const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul);
hit = bestDistance <= r + padding;
@ -95,7 +95,7 @@
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box"]);
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || item.type === "rope" || item.type === "rod" || isFenceItemType(item.type));
if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item);

View file

@ -6,22 +6,35 @@
const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed;
const itemHasAttachedLink = ImpactCore.itemHasAttachedLink;
function isBallLikeType(type = "") { return type === "ball" || type === "balloon"; }
function ballLikeCount(worldRef) { return (worldRef?.itemCounts?.ball || 0) + (worldRef?.itemCounts?.balloon || 0); }
function ballLikeItems(worldRef) {
if (!worldRef) return [];
if (worldRef.itemsOfType) {
const balls = worldRef.itemsOfType("ball") || [];
const balloons = worldRef.itemsOfType("balloon") || [];
return balls.concat(balloons);
}
return (worldRef.items || []).filter(it => it && !it.dead && isBallLikeType(it.type));
}
function ballLikeLabel(item) { return item?.type === "balloon" ? "水風船" : "ボール"; }
function resolveBallBallCollisions(worldRef, dt) {
if (!worldRef) return undefined;
if ((worldRef.itemCounts?.ball || 0) < 2) return;
if (ballLikeCount(worldRef) < 2) return;
const now = worldRef.time || 0;
if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map();
const balls = worldRef.itemsOfType?.("ball") || (worldRef.items || []).filter(it => it && !it.dead && it.type === "ball");
const balls = ballLikeItems(worldRef);
for (const a of balls) {
if (!a || a.dead || a.type !== "ball") continue;
if (!a || a.dead || !isBallLikeType(a.type)) continue;
const ar = a.r || 18;
const avx = a.vx || 0;
const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy);
const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52;
for (const b of worldRef.nearbyItems(a.x, a.y, range) || []) {
if (!b || b === a || b.dead || b.type !== "ball") continue;
if (!b || b === a || b.dead || !isBallLikeType(b.type)) continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (now - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue;
const br = b.r || 18;
@ -75,7 +88,7 @@
const relNormal = rvx * nx + rvy * ny;
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
worldRef.ballCollisionMemo.set(key, now);
const restitution = 0.86;
const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86;
const impulse = -(1 + restitution) * relNormal / 2;
if (Number.isFinite(impulse)) {
a.vx = (a.vx || 0) - impulse * nx;
@ -102,9 +115,9 @@
function resolveBallInteractions(worldRef, dt) {
if (!worldRef) return undefined;
if (!worldRef.itemCounts?.ball) return;
for (const ball of (worldRef.itemsOfType ? worldRef.itemsOfType("ball") : worldRef.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
if (ballLikeCount(worldRef) < 1) return;
for (const ball of ballLikeItems(worldRef)) {
if (!ball || ball.dead || !isBallLikeType(ball.type)) continue;
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84;
const range = (ball.r || 18) + 72 + sweep;
@ -127,7 +140,7 @@
if (d > hitDistance) continue;
const now = worldRef.time || 0;
const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
if (repeat && ballSpeed < 300) continue;
if (repeat && ballSpeed < (ball.type === "balloon" ? 180 : 300)) continue;
let nx = (hitX - t.x) / d;
let ny = (hitY - t.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
@ -140,7 +153,7 @@
}
}
const transferredPins = globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 0;
const transferredPins = ball.type === "ball" ? (globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 0) : 0;
if (transferredPins > 0) {
ball.vx = (ball.vx || 0) * 0.72 - nx * Math.min(78, Math.max(18, ballSpeed * 0.06));
ball.vy = (ball.vy || 0) * 0.72 - ny * Math.min(78, Math.max(18, ballSpeed * 0.06));
@ -185,7 +198,9 @@
continue;
}
const isDangerous = ballSpeed >= 285;
const isBalloon = ball.type === "balloon";
const objectLabel = ballLikeLabel(ball);
const isDangerous = !isBalloon && ballSpeed >= 285;
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 });
@ -223,9 +238,10 @@
const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball;
const playful = t.shouldApplyPersonalityBehavior?.("openness", 1);
const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260);
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52;
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52;
const impulseBase = isBalloon ? 34 : 48;
const impulse = clamp(impulseBase + relativeSpeed * (isBalloon ? 0.48 : 0.72) + (playIntent ? (isBalloon ? 34 : 52) : 0) + (playful ? (isBalloon ? 18 : 24) : 0), impulseBase, isDangerous ? 360 : (isBalloon ? 165 : 260));
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon ? 0.32 : 0.52);
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon ? 0.32 : 0.52);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38);
ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
@ -235,7 +251,7 @@
t.vy -= ny * Math.min(22, impulse * 0.10);
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100));
const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
const sensitiveBump = !isBalloon && t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 });
if (playful && playIntent) {
t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
@ -246,8 +262,8 @@
t.nextPlayBubbleAt = now + deterministicRange(worldRef, "ball-play-bubble-delay", 2.0, 3.8, ball, t);
worldRef.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)");
}
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] });
worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" }));
if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}は${objectLabel}を追いかけて弾いた。`, "observe", { participants: [t] });
worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: isBalloon ? "rgba(248,122,166,0.42)" : "rgba(170,210,95,0.50)" }));
}
}

View file

@ -323,6 +323,7 @@
if (!obj || obj.dead) return Infinity;
if (obj._heldByPlayer || obj.playerHeld) return Infinity;
if (obj.radius && obj.name) return 1.0;
if (obj.type === "balloon") return 0.22;
if (obj.type === "ball") return 0.7;
if (obj.type === "zunchi") return 0.55;
if (obj.type === "rope" || obj.type === "rod") return 1.6;
@ -332,7 +333,7 @@
if (obj.type === "poison_block") return Math.max(0.35, ps(obj, "mass", 0.45));
return 8.0;
}
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5;
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box" || obj.type === "pipe")) return obj.type.includes("fence") ? Infinity : 5.5;
return 2.2;
}

View file

@ -50,14 +50,19 @@
hit += 1;
}
let ballHit = 0;
if (isGenkotsu && worldRef.itemCounts?.ball) {
if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) {
const ballRange = radius + Math.max(72, (it.r || 88) * 0.78);
const balls = worldRef.nearbyItems?.(it.x, it.y, ballRange + 42, true) || worldRef.items || [];
for (const ball of balls) {
if (!ball || ball.dead || ball.type !== "ball") continue;
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y));
const p = clamp(1 - d / (ballRange + (ball.r || 18)), 0, 1);
if (p <= 0) continue;
if (ball.type === "balloon") {
globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, worldRef, it);
ballHit += 1;
continue;
}
let nx = (ball.x - it.x) / d;
let ny = (ball.y - it.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {

View file

@ -1,10 +1,20 @@
"use strict";
// Layer: entity-runtime/items/dynamic-system
// Owns ball item motion and obstacle bounce behavior. Item.prototype ball
// methods delegate to this system.
// Owns ball-like item motion, balloon drift/pop behavior, and fan wind.
// Item.prototype ball/balloon/fan methods delegate to this system.
(function (global) {
function isBallLike(item) {
return !!item && !item.dead && (item.type === "ball" || item.type === "balloon");
}
function update(item, dt, worldRef) {
if (!isBallLike(item)) return false;
if (item.type === "balloon") return updateBalloon(item, dt, worldRef);
return updateBall(item, dt, worldRef);
}
function updateBall(item, dt, worldRef) {
if (!item || item.dead || item.type !== "ball") return false;
item.prevX = item.x;
item.prevY = item.y;
@ -32,14 +42,58 @@
return true;
}
function updateBalloon(item, dt, worldRef) {
if (!item || item.dead || item.type !== "balloon") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
item.prevX = item.x;
item.prevY = item.y;
// A balloon should never be completely inert: it slowly wobbles, then fans
// and collisions can turn that small drift into visible motion.
const phase = (worldRef?.time || 0) * 1.35 + (item.seed || 0) * 0.017 + (item.balloonWobble || 0);
item.vx = (item.vx || 0) + Math.cos(phase * 0.83) * 6.5 * frameDt + Math.sin(phase * 0.31) * 2.4 * frameDt;
item.vy = (item.vy || 0) + Math.sin(phase * 0.71) * 5.4 * frameDt;
const maxSpeed = 560;
const speedBefore = Math.hypot(item.vx || 0, item.vy || 0);
if (speedBefore > maxSpeed) {
const s = maxSpeed / speedBefore;
item.vx *= s;
item.vy *= s;
}
const moving = Math.hypot(item.vx || 0, item.vy || 0);
if (moving > 0.04) {
item.x += (item.vx || 0) * dt;
item.y += (item.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; }
resolveObstacleCollisions(item, dt, worldRef);
item.x = clamp(item.x, p, worldRef.w - p);
item.y = clamp(item.y, p, worldRef.h - p);
const airFriction = Math.pow(0.47, frameDt);
item.vx *= airFriction;
item.vy *= airFriction;
}
checkBalloonHazards(item, worldRef);
item.spin = (item.spin || 0) + (item.spinVelocity || 0) * frameDt + (item.vx || 0) * frameDt / Math.max(12, item.r || 20) * 0.42;
item.spinVelocity *= Math.pow(0.58, frameDt);
if (Math.hypot(item.vx || 0, item.vy || 0) < 0.08) { item.vx = 0; item.vy = 0; }
return true;
}
function resolveObstacleCollisions(item, dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(item.vx || 0, item.vy || 0);
const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || [];
const seen = new Set(items);
// \u5927\u304d\u306a\u6a5f\u69cb\u306f\u4e2d\u5fc3\u304c\u8fd1\u508d\u30bb\u30eb\u5916\u3067\u3082\u5f53\u305f\u308b\u53ef\u80fd\u6027\u304c\u3042\u308b\u3002
// \u4e2d\u5fc3\u3067\u306f\u306a\u304f\u5916\u63a5\u534a\u5f84\u3067\u8ffd\u52a0\u8d70\u67fb\u3057\u3066\u3001\u9577\u3044\u68d2\u30fb\u5f80\u5fa9\u5e45\u306b\u3082\u885d\u7a81\u3055\u305b\u308b\u3002
// 大きな機構は中心が近傍セル外でも当たる可能性がある。
// 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。
if (Array.isArray(worldRef.items)) {
for (const it of worldRef.items) {
if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem.isMechanicalType(it.type) || seen.has(it)) continue;
@ -55,7 +109,14 @@
if (it.type === "bed") {
applyHayDrag(item, it, dt, worldRef);
} else if (it.type === "stone") {
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef);
resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), item.type === "balloon" ? 0.48 : 0.82, worldRef);
} else if (it.type === "fan" && item.type === "balloon") {
const wind = fanWindAt(it, item.x, item.y);
if (wind) {
const a = itemAngleFor(it);
item.vx += Math.cos(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
item.vy += Math.sin(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016);
}
}
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
if (!rects.length) continue;
@ -70,7 +131,7 @@
const dy = item.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
const slow = Math.pow(item.type === "balloon" ? 0.28 : 0.16, Math.max(0.016, dt || 0.016));
item.vx *= slow;
item.vy *= slow;
item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
@ -119,12 +180,14 @@
item.vx = (item.vx || 0) - (1 + restitution) * toward * nx;
item.vy = (item.vy || 0) - (1 + restitution) * toward * ny;
} else {
item.vx = (item.vx || 0) + nx * 24;
item.vy = (item.vy || 0) + ny * 24;
const push = item.type === "balloon" ? 12 : 24;
item.vx = (item.vx || 0) + nx * push;
item.vy = (item.vy || 0) + ny * push;
}
item.vx *= 0.96;
item.vy *= 0.96;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
const damp = item.type === "balloon" ? 0.84 : 0.96;
item.vx *= damp;
item.vy *= damp;
item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (item.type === "balloon" ? 2.2 : 5.5), -38, 38);
emitBounce(item, worldRef, obstacle);
}
@ -133,13 +196,13 @@
const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect;
if (typeof bounceCircleOffRect !== "function") return;
const hitRadius = (item.r || 18) + 2;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78;
const velocityDamp = rect.bounce ? 1.0 : 0.94;
const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.54 : 0.78);
const velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94);
const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0);
const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, {
minBounceSpeed: rect.bounce ? minBounceSpeed : 0,
fallbackImpulse: 18,
spinKick: 6.2,
fallbackImpulse: item.type === "balloon" ? 10 : 18,
spinKick: item.type === "balloon" ? 2.4 : 6.2,
spinLimit: 38,
});
if (!bounced) return;
@ -152,12 +215,343 @@
const now = worldRef?.time || 0;
if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return;
item.lastObstacleBounceAt = now;
const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)");
const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"));
worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color }));
}
function normalizeAngleLocal(angle = 0, fallback = 0) {
if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback);
const twoPi = Math.PI * 2;
const n = Number(angle);
const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0);
return ((base % twoPi) + twoPi) % twoPi;
}
function updateFanSwing(item, dt) {
if (!item || item.dead || item.type !== "fan") return false;
if (!item.fanSwingOn) {
item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : itemAngleFor(item);
return false;
}
const center = normalizeAngleLocal(item.fanSwingCenter, itemAngleFor(item));
const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180);
const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180);
item.fanSwingCenter = center;
item.fanSwingRange = range;
item.fanSwingSpeed = speed;
item.fanSwingPhase = Number(item.fanSwingPhase || 0) + speed * Math.max(0.016, Number(dt || 0.016) || 0.016);
item.angle = normalizeAngleLocal(center + Math.sin(item.fanSwingPhase || 0) * range, center);
return true;
}
function fanWindAt(fan, x, y, opts = {}) {
if (!fan || fan.dead || fan.type !== "fan") return null;
const range = Math.max(80, Number(opts.range || 300) || 300);
const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62);
const dx = x - fan.x;
const dy = y - fan.y;
const angle = itemAngleFor(fan);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const along = dx * ax + dy * ay;
if (along < 8 || along > range) return null;
const lateral = Math.abs(-dx * ay + dy * ax);
const halfWidth = 26 + along * Math.tan(spread);
if (lateral > halfWidth) return null;
const distFalloff = clamp(1 - along / range, 0, 1);
const edgeFalloff = clamp(1 - lateral / Math.max(1, halfWidth), 0, 1);
const falloff = Math.pow(distFalloff, 0.64) * Math.pow(edgeFalloff, 0.34);
if (falloff <= 0.02) return null;
return { angle, along, lateral, halfWidth, range, falloff };
}
function updateFan(item, dt, worldRef) {
if (!item || item.dead || item.type !== "fan") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const now = worldRef?.time || 0;
item.prevX = item.x;
item.prevY = item.y;
item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt;
item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt;
updateFanSwing(item, frameDt);
const angle = itemAngleFor(item);
const ax = Math.cos(angle);
const ay = Math.sin(angle);
const range = 320;
const searchRadius = range + 40;
const nearbyItems = worldRef?.nearbyItems?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
for (const target of nearbyItems) {
if (!target || target.dead || target === item) continue;
const wind = fanWindAt(item, target.x, target.y, { range });
if (!wind) continue;
applyFanToItem(item, target, wind, frameDt, worldRef);
}
const nearbyTarinai = worldRef?.nearbyTarinai?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || [];
for (const t of nearbyTarinai) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const wind = fanWindAt(item, t.x, t.y, { range });
if (!wind) continue;
applyFanToTarinai(item, t, wind, frameDt, worldRef);
}
if ((item.lastFanEffectAt || -999) + 0.18 < now) {
item.lastFanEffectAt = now;
const phase = Math.sin(item.fanPulse || 0);
const px = item.x + ax * (42 + 24 * phase);
const py = item.y + ay * (42 + 24 * phase);
worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" }));
}
return true;
}
function applyFanToItem(fan, target, wind, dt, worldRef) {
const type = target.type || "";
let massFactor = 0;
if (type === "balloon") massFactor = 1.85;
else if (type === "ball") massFactor = 0.72;
else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90;
else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42;
else if (type === "zunchi") massFactor = 0.30;
else if (type === "fire") massFactor = 0.18;
else return;
const force = 236 * wind.falloff * massFactor;
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
target.prevX = Number.isFinite(target.prevX) ? target.prevX : target.x;
target.prevY = Number.isFinite(target.prevY) ? target.prevY : target.y;
target.vx = clamp((target.vx || 0) + ax * force * dt, -820, 820);
target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820);
if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12);
else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38);
if (type === "fire") target.amount = Math.max(0, (target.amount ?? 1) - wind.falloff * dt * 1.0);
worldRef?.markSpatialDirty?.("fan-wind-item");
}
function applyFanToTarinai(fan, t, wind, dt, worldRef) {
const ax = Math.cos(wind.angle);
const ay = Math.sin(wind.angle);
const radius = t.radius || 18;
const speed = 30 * wind.falloff;
t.vx = clamp((t.vx || 0) + ax * speed * dt, -360, 360);
t.vy = clamp((t.vy || 0) + ay * speed * dt, -360, 360);
t.fanBreezeTimer = Math.max(t.fanBreezeTimer || 0, 0.35);
if (wind.falloff > 0.58 && (worldRef?.time || 0) - (t.lastFanSurpriseAt || -999) > 2.2) {
t.lastFanSurpriseAt = worldRef?.time || 0;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12);
if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 });
worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" }));
}
}
function checkBalloonHazards(balloon, worldRef) {
if (!balloon || balloon.dead || balloon.type !== "balloon" || !worldRef?.nearbyItems) return;
const now = worldRef.time || 0;
if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return;
const radius = Math.max(26, (balloon.r || 20) + 22);
for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) {
if (!it || it.dead || it === balloon) continue;
if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker") {
const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + (it.type === "fire" ? 16 : 4);
if (hit) {
popBalloon(balloon, worldRef, it);
return;
}
}
}
}
const WATER_BALLOON_SURPRISE_TEXT = "水風船が割れておどろいている。";
function tarinaiOpennessValue(t) {
const v = (typeof global.effectivePersonalityValue === "function")
? global.effectivePersonalityValue(t, "openness")
: (t?.currentPersonality?.openness ?? t?.birthPersonality?.openness ?? t?.personality?.openness ?? 0);
return Number(v) || 0;
}
function shouldPanicFromWaterBalloon(t, strength, worldRef, balloon) {
const openness = tarinaiOpennessValue(t);
if (openness >= -0.08) return false;
if (openness <= -0.48) return true;
const chance = clamp(0.32 + Math.max(0, -openness) * 0.70 + Math.max(0, strength) * 0.26, 0.34, 0.92);
return deterministicChance(worldRef || t?.world || null, "water-balloon-low-openness-panic", chance, t, balloon, Math.round(strength * 1000));
}
function setWaterBalloonSurpriseBehavior(t, target, panic = false) {
if (!t || t.dead) return false;
t.lastPanicDetail = WATER_BALLOON_SURPRISE_TEXT;
t.thought = WATER_BALLOON_SURPRISE_TEXT;
if (typeof global.setBehavior === "function") {
global.setBehavior(t, {
actionId: panic ? "panic_escape" : "water_balloon_surprise",
need: "safety",
label: WATER_BALLOON_SURPRISE_TEXT,
text: WATER_BALLOON_SURPRISE_TEXT,
reason: WATER_BALLOON_SURPRISE_TEXT,
target: target || null,
phase: "perform",
source: "behavior",
tiedNeeds: ["safety"],
lockSeconds: panic ? 2.6 : 1.1,
minDuration: panic ? 1.2 : 0.55,
});
return true;
}
if (typeof global.setBehaviorText === "function") {
global.setBehaviorText(t, { need: "safety", actionId: panic ? "panic_escape" : "water_balloon_surprise", actionLabel: WATER_BALLOON_SURPRISE_TEXT, reasonText: WATER_BALLOON_SURPRISE_TEXT, target: target || null, phase: "perform", source: "behavior" });
return true;
}
return false;
}
function spawnWaterFromBalloon(balloon, worldRef) {
if (!balloon || !worldRef) return 0;
const baseR = Math.max(14, Number(balloon.r || 20) || 20);
const count = Math.max(7, Math.min(13, Math.round(baseR * 0.42)));
let spawned = 0;
for (let i = 0; i < count; i++) {
if (typeof global.Item !== "function") break;
const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i);
const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.10, baseR * 0.58, balloon, i);
const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 150, 420, balloon, i);
const drop = new global.Item("water", balloon.x + Math.cos(a) * d, balloon.y + Math.sin(a) * d);
drop.r = deterministicRange(worldRef || balloon, "water-balloon-drop-radius", 5.5, 10.5, balloon, i);
drop.amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 10, 28, balloon, i);
drop.prevX = drop.x;
drop.prevY = drop.y;
drop.vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.18;
drop.vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.18;
drop.splashFlightTimer = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.30, 0.62, balloon, i);
drop.splashSourceId = balloon.id || "";
if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1;
else { worldRef.items?.push?.(drop); spawned += 1; }
if (i < 8) {
worldRef.effects?.push(new Effect("fight", drop.x, drop.y, {
size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i),
life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i),
color: "rgba(96,196,244,0.66)",
vx: drop.vx * 0.42,
vy: drop.vy * 0.42,
}));
}
}
if (spawned) {
worldRef.markItemBucketsDirty?.("water-balloon-splash");
worldRef.markSpatialDirty?.("water-balloon-splash");
worldRef.markTerrainDirtyAt?.(balloon.x, balloon.y, Math.max(70, baseR * 5.2), "water-balloon-splash");
}
return spawned;
}
function updateWaterDropMotion(item, dt, worldRef) {
if (!item || item.dead || item.type !== "water") return false;
const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016);
const speed = Math.hypot(item.vx || 0, item.vy || 0);
if (speed <= 0.05 && !(item.splashFlightTimer > 0)) return false;
item.prevX = Number.isFinite(Number(item.x)) ? item.x : 0;
item.prevY = Number.isFinite(Number(item.y)) ? item.y : 0;
item.x += (Number(item.vx || 0) || 0) * frameDt;
item.y += (Number(item.vy || 0) || 0) * frameDt;
const pad = Math.max(28, CONFIG.worldPadding || 30);
const r = Math.max(5, Number(item.r || 8) || 8);
if (worldRef) {
if (item.x < pad) { item.x = pad; item.vx = Math.abs(item.vx || 0) * 0.30; }
if (item.x > worldRef.w - pad) { item.x = worldRef.w - pad; item.vx = -Math.abs(item.vx || 0) * 0.30; }
if (item.y < pad) { item.y = pad; item.vy = Math.abs(item.vy || 0) * 0.30; }
if (item.y > worldRef.h - pad) { item.y = worldRef.h - pad; item.vy = -Math.abs(item.vy || 0) * 0.30; }
}
item.splashFlightTimer = Math.max(0, Number(item.splashFlightTimer || 0) - frameDt);
const damp = item.splashFlightTimer > 0 ? Math.pow(0.78, frameDt * 60) : Math.pow(0.58, frameDt * 60);
item.vx *= damp;
item.vy *= damp;
if (Math.hypot(item.vx || 0, item.vy || 0) < 8 && item.splashFlightTimer <= 0) {
item.vx = 0;
item.vy = 0;
}
if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) {
item.lastSplashTrailAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 }));
}
worldRef?.markSpatialDirty?.("water-drop-motion");
worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function applyWaterBalloonSurprise(balloon, worldRef) {
for (const t of worldRef?.nearbyTarinai?.(balloon.x, balloon.y, 165) || []) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const d = Math.max(1, distXY(balloon.x, balloon.y, t.x, t.y));
const strength = clamp(1 - d / 165, 0, 1);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34 + strength * 0.44);
if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "水風船が割れて驚いた");
if (typeof t.spawnBubble === "function" && strength > 0.35) t.spawnBubble("!", { life: 0.72 });
const awayX = (t.x - balloon.x) / d;
const awayY = (t.y - balloon.y) / d;
t.vx = clamp((t.vx || 0) + awayX * 68 * strength, -360, 360);
t.vy = clamp((t.vy || 0) + awayY * 68 * strength, -360, 360);
const panic = shouldPanicFromWaterBalloon(t, strength, worldRef, balloon);
if (panic) {
const threat = { x: balloon.x, y: balloon.y, dead: true, type: "water_balloon_pop" };
const destination = t.panicDestination?.(threat, true) || { x: t.x + awayX * 190, y: t.y + awayY * 190, dead: false, panic: true };
t.enterPanic?.({
threat,
destination,
target: destination,
reason: WATER_BALLOON_SURPRISE_TEXT,
cause: "water_balloon_pop",
fearTimer: 1.35 + strength * 1.30,
stress: 14 + strength * 24,
surpriseTimer: 0.70 + strength * 0.45,
wake: true,
forceDestination: true,
bubble: "!!",
bubbleCooldown: 0.7,
}) || t.setActionState?.("panic", { target: destination, reason: WATER_BALLOON_SURPRISE_TEXT, wake: true, sleeping: false });
t.fearTimer = Math.max(t.fearTimer || 0, 1.85 + strength * 1.40);
t.panicStartedAt = worldRef?.time || t.panicStartedAt || 0;
t.panicHardStopAt = Math.max(t.panicHardStopAt || 0, (worldRef?.time || 0) + 5.5);
t.lastPanicCause = "water_balloon_pop";
setWaterBalloonSurpriseBehavior(t, destination, true);
} else {
setWaterBalloonSurpriseBehavior(t, null, false);
}
}
}
function popBalloon(balloon, worldRef, cause = null) {
if (!balloon || balloon.dead) return;
const now = worldRef?.time || 0;
balloon.balloonPoppedAt = now;
// Item.dead is a getter based on amount, so mark the water balloon gone
// through amount/hp instead of assigning to .dead directly.
balloon.amount = 0;
balloon.hp = 0;
worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" }));
for (let i = 0; i < 9; i++) {
const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i);
const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i);
worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s }));
}
spawnWaterFromBalloon(balloon, worldRef);
const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" : (cause?.type === "genkotsu" ? "げんこつ" : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "何か"));
if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) {
worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] });
}
applyWaterBalloonSurprise(balloon, worldRef);
worldRef?.markSpatialDirty?.("water-balloon-pop");
}
global.TarinaiItemDynamicBallSystem = Object.freeze({
update,
updateFan,
updateWaterDropMotion,
fanWindAt,
resolveObstacleCollisions,
popBalloon,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -20,7 +20,12 @@
case "rod":
return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false;
case "ball":
case "balloon":
return global.TarinaiItemDynamicBallSystem.update(item, dt, worldRef) !== false;
case "fan":
return global.TarinaiItemDynamicBallSystem.updateFan(item, dt, worldRef) !== false;
case "water":
return global.TarinaiItemDynamicBallSystem.updateWaterDropMotion(item, dt, worldRef) !== false;
case "duplicator":
return global.TarinaiItemDynamicDuplicatorSystem.update(item, dt, worldRef) !== false;
case "zunchi":

View file

@ -279,6 +279,86 @@ draw(ctx, t, lighting = null) {
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "pipe") {
const r = this.r || 36;
ctx.save();
const body = styleNight ? "#506d5f" : (styleWarm ? "#7f9a72" : "#6f8d75");
const side = styleNight ? "#3e574d" : (styleWarm ? "#637f5d" : "#587260");
const lip = styleNight ? "#779083" : (styleWarm ? "#adc49b" : "#94b199");
const lipDark = styleNight ? "#2f4640" : "#486858";
const inner = styleNight ? "#0d1514" : "#152420";
const outline = styleNight ? "rgba(9,15,15,0.86)" : "rgba(30,50,42,0.78)";
const topY = -r * 0.42;
const bottomY = r * 0.54;
const topRx = r * 1.04;
const topRy = r * 0.43;
const bottomRx = r * 0.94;
const bottomRy = r * 0.34;
// Shadow under the visible front edge.
ctx.fillStyle = "rgba(18,24,22,0.20)";
ctx.beginPath();
ctx.ellipse(0, r * 0.73, r * 1.12, r * 0.30, 0, 0, Math.PI * 2);
ctx.fill();
// Cylindrical side wall: the lower edge is visible because the pipe is viewed from above-front.
ctx.fillStyle = side;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.2, r * 0.07);
ctx.beginPath();
ctx.moveTo(-topRx, topY);
ctx.bezierCurveTo(-topRx * 0.98, topY + r * 0.30, -bottomRx * 1.02, bottomY - r * 0.16, -bottomRx, bottomY);
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, Math.PI, 0, true);
ctx.bezierCurveTo(topRx * 1.02, bottomY - r * 0.16, topRx * 0.98, topY + r * 0.30, topRx, topY);
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI, true);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Front lower rim shade.
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, 0, Math.PI);
ctx.lineTo(bottomRx * 0.88, bottomY + r * 0.08);
ctx.ellipse(0, bottomY + r * 0.08, bottomRx * 0.88, bottomRy * 0.58, 0, 0, Math.PI, true);
ctx.closePath();
ctx.fill();
// Top lip and visible round opening.
ctx.fillStyle = lip;
ctx.strokeStyle = outline;
ctx.lineWidth = Math.max(2.6, r * 0.08);
ctx.beginPath();
ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = inner;
ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.88)" : "rgba(14,26,23,0.90)";
ctx.lineWidth = Math.max(2.0, r * 0.065);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.64, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = lipDark;
ctx.lineWidth = Math.max(1.4, r * 0.045);
ctx.beginPath();
ctx.ellipse(0, topY + r * 0.02, r * 0.78, r * 0.32, 0, Math.PI * 0.04, Math.PI * 0.96);
ctx.stroke();
// Upper-left highlight for the rim and side.
ctx.globalAlpha = 0.34;
ctx.fillStyle = "rgba(255,255,255,0.86)";
ctx.beginPath();
ctx.ellipse(-r * 0.42, topY - r * 0.17, r * 0.24, r * 0.055, -0.10, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.22;
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.lineWidth = Math.max(1.1, r * 0.026);
ctx.beginPath();
ctx.moveTo(-r * 0.72, -r * 0.10);
ctx.bezierCurveTo(-r * 0.66, r * 0.18, -r * 0.62, r * 0.32, -r * 0.48, r * 0.50);
ctx.stroke();
ctx.restore();
} else if (this.type === "robot_cleaner") {
ctx.save();
const flash = clamp(Number(this.robotCleanerCleanFlash || 0) || 0, 0, 1);
@ -605,6 +685,182 @@ draw(ctx, t, lighting = null) {
ctx.fill();
}
ctx.restore();
} else if (this.type === "balloon") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
const r = this.r || 20;
const wobble = Math.sin(t * 3.0 + (this.seed || 0) * 0.037 + (this.balloonWobble || 0)) * 0.10 + (this.spin || 0) * 0.05;
ctx.save();
ctx.rotate(wobble);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(68,34,45,0.16)";
ctx.shadowBlur = 2 + moving * 4;
ctx.shadowOffsetY = 2;
}
const grad = ctx.createRadialGradient(-r * 0.34, -r * 0.54, r * 0.16, 0, -r * 0.18, r * 1.18);
grad.addColorStop(0, "rgba(232,250,255,0.98)");
grad.addColorStop(0.44, "rgba(96,196,238,0.94)");
grad.addColorStop(1, "rgba(37,126,198,0.90)");
ctx.fillStyle = grad;
ctx.strokeStyle = "rgba(34,82,125,0.70)";
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.ellipse(0, -r * 0.18, r * 0.82, r * 1.06, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.fillStyle = "rgba(255,255,255,0.50)";
ctx.beginPath();
ctx.ellipse(-r * 0.28, -r * 0.62, r * 0.18, r * 0.28, -0.42, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(35,106,168,0.88)";
ctx.beginPath();
ctx.moveTo(-r * 0.18, r * 0.78);
ctx.lineTo(r * 0.18, r * 0.78);
ctx.lineTo(0, r * 1.12);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(61,113,150,0.58)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(0, r * 1.06);
ctx.bezierCurveTo(-r * 0.18, r * 1.34, r * 0.20, r * 1.52, -r * 0.04, r * 1.76);
ctx.stroke();
if (moving > 0.42) {
ctx.globalAlpha *= 0.10 + moving * 0.12;
ctx.strokeStyle = "rgba(231,249,255,0.90)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(-r * 0.10, -r * 0.18, r * 0.92, -1.92, -0.95);
ctx.stroke();
}
ctx.restore();
} else if (this.type === "fan") {
const r = this.r || 26;
const angle = itemAngleFor(this);
const spin = (this.fanSpin || 0) + t * 10.5;
ctx.save();
ctx.rotate(angle);
const windRange = 320;
const windStart = r * 1.02;
const windSpread = 0.62;
const windHalfStart = 26 + windStart * Math.tan(windSpread);
const windHalfEnd = 26 + windRange * Math.tan(windSpread);
const fanAlpha = ctx.globalAlpha;
ctx.globalAlpha = fanAlpha * 0.16;
ctx.fillStyle = "rgba(132,190,220,0.34)";
ctx.strokeStyle = "rgba(80,155,205,0.46)";
ctx.lineWidth = 1.4;
ctx.setLineDash([10, 8]);
ctx.beginPath();
ctx.moveTo(windStart, -windHalfStart);
ctx.lineTo(windRange, -windHalfEnd);
ctx.quadraticCurveTo(windRange + r * 0.36, 0, windRange, windHalfEnd);
ctx.lineTo(windStart, windHalfStart);
ctx.quadraticCurveTo(r * 1.28, 0, windStart, -windHalfStart);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.setLineDash([]);
// Wind direction indicator: centerline + arrowheads. The cone shows the
// affected range; the arrows make the actual airflow direction explicit.
ctx.globalAlpha = fanAlpha * 0.52;
ctx.strokeStyle = "rgba(46,132,200,0.70)";
ctx.fillStyle = "rgba(46,132,200,0.62)";
ctx.lineWidth = 2.1;
ctx.setLineDash([16, 10]);
ctx.beginPath();
ctx.moveTo(windStart + r * 0.55, 0);
ctx.lineTo(windRange - r * 0.50, 0);
ctx.stroke();
ctx.setLineDash([]);
for (let i = 0; i < 3; i++) {
const x = windStart + r * 2.0 + i * ((windRange - windStart - r * 3.2) / 2);
const wobbleY = Math.sin(t * 2.4 + i * 1.7) * r * 0.035;
const arrow = Math.max(9, r * 0.42);
ctx.beginPath();
ctx.moveTo(x + arrow * 0.55, wobbleY);
ctx.lineTo(x - arrow * 0.55, wobbleY - arrow * 0.48);
ctx.lineTo(x - arrow * 0.30, wobbleY);
ctx.lineTo(x - arrow * 0.55, wobbleY + arrow * 0.48);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = fanAlpha * 0.34;
ctx.strokeStyle = "rgba(132,190,220,0.42)";
ctx.lineWidth = 2.0;
for (let i = -2; i <= 2; i++) {
const y = i * r * 0.28 + Math.sin(t * 3 + i) * r * 0.08;
ctx.beginPath();
ctx.moveTo(r * 0.95, y);
ctx.bezierCurveTo(r * 1.55, y - r * 0.22, r * 2.30, y + r * 0.20, r * 3.05, y - r * 0.08);
ctx.stroke();
}
ctx.globalAlpha = fanAlpha;
ctx.restore();
ctx.save();
ctx.rotate(angle);
if (itemShadowsEnabled()) {
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(52,40,26,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
}
ctx.strokeStyle = "rgba(75,92,105,0.88)";
ctx.fillStyle = "rgba(221,231,235,0.96)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, r * 0.82, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.strokeStyle = "rgba(96,117,130,0.42)";
ctx.lineWidth = 1.0;
for (let i = 0; i < 8; i++) {
const a = i * Math.PI / 4;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * r * 0.76, Math.sin(a) * r * 0.76);
ctx.stroke();
}
ctx.save();
ctx.rotate(spin);
ctx.fillStyle = "rgba(126,170,196,0.88)";
ctx.strokeStyle = "rgba(62,82,96,0.44)";
ctx.lineWidth = 1.1;
for (let i = 0; i < 3; i++) {
ctx.rotate(Math.PI * 2 / 3);
ctx.beginPath();
ctx.ellipse(r * 0.30, 0, r * 0.38, r * 0.13, 0.10, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
ctx.restore();
ctx.fillStyle = "rgba(65,78,86,0.94)";
ctx.beginPath();
ctx.arc(0, 0, r * 0.16, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(62,82,96,0.74)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-r * 0.22, r * 0.84);
ctx.lineTo(-r * 0.22, r * 1.35);
ctx.stroke();
ctx.fillStyle = "rgba(91,104,112,0.88)";
ctx.beginPath();
ctx.ellipse(-r * 0.22, r * 1.42, r * 0.62, r * 0.16, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(65,78,86,0.74)";
ctx.stroke();
ctx.fillStyle = "rgba(92,116,132,0.96)";
ctx.beginPath();
ctx.moveTo(r * 0.82, 0);
ctx.lineTo(r * 1.08, -r * 0.14);
ctx.lineTo(r * 1.08, r * 0.14);
ctx.closePath();
ctx.fill();
ctx.restore();
} else if (this.type === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);

View file

@ -40,10 +40,13 @@ const TOOL_DEFINITIONS = Object.freeze({
genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" },
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u4e2d\u306b\u5165\u3063\u3066\u7720\u308c\u308b\u4e38\u3044\u571f\u7ba1\u3002\u51fa\u308b\u6642\u306f\u30e9\u30f3\u30c0\u30e0\u306a\u571f\u7ba1\u304b\u3089\u51fa\u308b\u3002\u5bdd\u5fc3\u5730\u306f\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u6bb5\u4e0b\u3002" },
dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "水の入った風船。軽くて風に流される。画鋲・火・げんこつで割れ、水が飛び散って近くのたりないが驚く。" },
fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。水風船やボールを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
@ -107,6 +110,7 @@ function itemHasTrait(type = "", trait = "") {
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isNestContainerType(type = "") { const id = String(type || ""); return id === "nest_box" || id === "pipe"; }
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
@ -180,6 +184,7 @@ function toolTipsFromDefinitions() {
itemHasTrait,
isPinType,
isSleepFurnitureType,
isNestContainerType,
lodgedPinFor,
hasLodgedPinEffect,
pinBehaviorFor,

View file

@ -3,20 +3,20 @@
// Static item/tool metadata shared by tool definitions and UI helpers.
(function (global) {
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]),
hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box", "grass_bed"]),
draggable: new Set(["ball", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]),
sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]),
draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]),
});
const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"]));
const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"]));
const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]);
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES]));
const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"]));
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
@ -27,7 +27,7 @@
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "dry_ice", "stove", "ball", "signboard"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] },
{ id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] },
]);

View file

@ -7,7 +7,7 @@ function initializeItemTypeState(item, type, x, y) {
if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true;
if (type === "water" || type === "zunda_juice") item.roles.drink = true;
if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true;
if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true;
if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true;
if (["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true;
if (type === "grass") item.roles.grassMaterial = true;
if (type === "grass") {
@ -26,6 +26,10 @@ function initializeItemTypeState(item, type, x, y) {
item.comfort = rand(0.86, 1.14);
item.wear = 0;
}
if (type === "pipe") {
item.comfort = rand(0.66, 0.82);
item.wear = 0;
}
if (type === "signboard") {
item.text = "";
}
@ -51,18 +55,36 @@ function initializeItemTypeState(item, type, x, y) {
item.robotCleanerHighSpeed = false;
item.robotCleanerSpeed = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerMoveSpeed ?? 60;
}
if (type === "ball") {
item.vx = rand(-5, 5);
item.vy = rand(-5, 5);
if (type === "ball" || type === "balloon") {
const light = type === "balloon";
item.vx = rand(light ? -18 : -5, light ? 18 : 5);
item.vy = rand(light ? -14 : -5, light ? 14 : 5);
item.prevX = x;
item.prevY = y;
item.spin = rand(0, Math.PI * 2);
item.spinVelocity = rand(-1.6, 1.6);
item.spinVelocity = rand(light ? -0.8 : -1.6, light ? 0.8 : 1.6);
item.lastKickedAt = -999;
item.lastKickerId = "";
item.lastPokedAt = -999;
item.pokeCombo = 0;
item.lastPokeAngle = rand(0, Math.PI * 2);
if (light) {
item.balloonWobble = rand(0, Math.PI * 2);
item.balloonPoppedAt = -999;
}
}
if (type === "fan") {
item.amount = 999;
item.angle = defaultItemAngle(type);
item.fanSpin = rand(0, Math.PI * 2);
item.fanPulse = rand(0, Math.PI * 2);
item.lastFanEffectAt = -999;
item.lastFanLogAt = -999;
item.fanSwingOn = false;
item.fanSwingCenter = item.angle;
item.fanSwingRange = 35 * Math.PI / 180;
item.fanSwingSpeed = 48 * Math.PI / 180;
item.fanSwingPhase = 0;
}
if (type === "zunchi") {
item.amount = Math.max(Number(item.amount || 0) || 0, 240);

View file

@ -5,13 +5,13 @@
// helpers and the item scheduler both read this single policy instead of
// duplicating item-type timing rules.
(function (global) {
const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]);
const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]);
const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]);
const SCHEDULED_ITEM_TYPES = Object.freeze([
"ball", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo",
"ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo",
"plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food",
"sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
"stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
"stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod",
]);
function mobilePerfHint() {

View file

@ -111,6 +111,24 @@
"fieldScale": 1,
"accessibleShapeTraits": ["round", "soccer ball"],
"emojiFallback": "\u26bd"
},
"balloon": {
"renderer": "vector_balloon",
"shape": "round_water_balloon_with_string",
"palette": { "fill": "#58bde8", "stroke": "rgba(38,83,123,0.76)", "highlight": "rgba(255,255,255,0.72)" },
"scale": 1.05,
"uiPreviewScale": 1.06,
"fieldScale": 1.05,
"accessibleShapeTraits": ["round", "water balloon", "string", "blue"]
},
"fan": {
"renderer": "vector_fan",
"shape": "directional_fan",
"palette": { "fill": "#d7e2ea", "stroke": "rgba(58,74,84,0.74)", "accent": "rgba(92,142,174,0.88)" },
"scale": 1.08,
"uiPreviewScale": 1.08,
"fieldScale": 1.08,
"accessibleShapeTraits": ["fan", "directional", "wind"]
}
}`;

View file

@ -455,6 +455,7 @@ function previewItemRadiusFor(type = "") {
if (type === "rotator") return 25;
if (type === "reciprocator") return 26;
if (type === "nest_box") return 26;
if (type === "pipe") return 24;
if (type === "duplicator") return 24;
if (type === "signboard") return 22;
if (type === "bed") return 22;
@ -580,6 +581,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
genkotsu: 0.52,
bed: 0.80,
nest_box: 0.78,
pipe: 0.84,
signboard: 0.76,
duplicator: 0.82,
fence_v: 0.70,
@ -591,6 +593,8 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
reciprocator: 0.76,
ant_nest: 0.92,
ball: 0.92,
balloon: 0.86,
fan: 0.82,
pushpin: 0.92,
oshibyo: 0.62,
firecracker: 0.90,
@ -603,7 +607,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
if (isConsumableSpriteType(type)) scale *= 1.95;
else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85;
else if (type === "duplicator") scale *= 1.55;
else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08;
else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08;
else if (isParamEffectItemType(type)) scale *= 1.35;
if (watermark) scale *= 1.42;
ctx.scale(scale, scale);

View file

@ -128,7 +128,7 @@
if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence") && worldRef.fenceRect) {
return { rect: worldRef.fenceRect(item), rects: [] };
}
if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
if ((item.type === "nest_box" || item.type === "pipe") && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
const rects = rectsFor(worldRef, item);
return { rect: null, rects };
}

View file

@ -277,7 +277,7 @@ function drawToolTargetHoverHighlight(ctx, world) {
ctx.setLineDash([]);
drawLabel(cx, top, 14);
} else {
const r = Math.max(14, target.r || 16) * (target.type === "ball" ? 1.30 : 1.18);
const r = Math.max(14, target.r || 16) * ((target.type === "ball" || target.type === "balloon") ? 1.30 : 1.18);
strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76);
}
}
@ -532,7 +532,7 @@ function drawPlacementPreview(ctx, world) {
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
}
}
} else if (type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
} else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
ctx.beginPath();
ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
ctx.fill();
@ -887,6 +887,7 @@ function renderRadiusForEntity(entity) {
if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "glass_wall" || 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 + (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 === "pipe") return Math.max(78, (entity.r || 36) * 2.8);
if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0);
if (entity.kind === "queen") return 42;
if (entity.kind === "worker") return 26;

View file

@ -222,7 +222,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains
if (!removed) {
target.amount = 0;
target.hp = 0;
target.dead = true;
removed = true;
}
}

View file

@ -25,6 +25,7 @@
const FIRE_TYPE_ID = ITEM_TYPE_IDS.indexOf("fire");
const FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("firecracker");
const FLAME_FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("flame_firecracker");
const BALLOON_TYPE_ID = ITEM_TYPE_IDS.indexOf("balloon");
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
@ -627,7 +628,7 @@
writeBitFields(writer, [Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0)), extra[1] ? 1 : 0], [6, 1]);
} else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) {
writeSaveString(writer, JSON.stringify(extra || []));
} else if (typeId === 21) { // ball
} else if (typeId === 21 || typeId === BALLOON_TYPE_ID) { // ball/balloon
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
} else if (typeId === 23) { // ant_nest
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
@ -696,7 +697,7 @@
return [];
}
}
if (typeId === 21) return [reader.s(), reader.s()];
if (typeId === 21 || typeId === BALLOON_TYPE_ID) return [reader.s(), reader.s()];
if (typeId === 23) return [reader.s(), reader.s()];
if (typeId === 24) return [reader.s()];
if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()];

View file

@ -4,8 +4,8 @@
// Owns stable save identifiers shared by snapshot_system.js and save_codec.js.
// Current-format save identifiers only.
(function (global) {
const SNAPSHOT_VERSION = 18;
const BINARY_SCHEMA_VERSION = 23;
const SNAPSHOT_VERSION = 19;
const BINARY_SCHEMA_VERSION = 24;
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
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",
"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",
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker"
"splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe"
]);
function typeIds(names) {
@ -32,7 +32,7 @@
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 JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]);
const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "fan"]);
function enumIndex(list, value, fallback = 0) {
const i = list.indexOf(String(value ?? ""));

View file

@ -745,7 +745,7 @@ class SpatialGrid {
isDynamicItem(it) {
if (!it || it.dead) return false;
const type = it.type || "";
if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true;
if (type === "ball" || type === "balloon" || type === "fan" || 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;

View file

@ -291,12 +291,20 @@
}
if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) return { pf: 2, missing: true };
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" || type === "balloon") 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_corpse") return [q(item.decayTimer, 10)];
if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)];
if (type === "fire") return [q(item.flameLife, 10)];
if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (type === "fan") return [
q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)),
item.fanSwingOn ? 1 : 0,
q(item.fanSwingCenter ?? item.angle ?? 0, 1000),
q(item.fanSwingRange ?? (35 * Math.PI / 180), 1000),
q(item.fanSwingSpeed ?? (48 * Math.PI / 180), 1000),
q(item.fanSwingPhase || 0, 1000),
];
if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0];
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return [];
@ -352,7 +360,7 @@
return;
} else if (type === "duplicator") {
item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.toolLabel ? global.toolLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball") {
} else if (type === "ball" || type === "balloon") {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10);
} else if (type === "ant_nest") {
item.antCount = u(extra[0], 1); item.antWorkers = []; item.queenSpawnAt = u(extra[1], 10);
@ -364,6 +372,14 @@
item.flameLife = u(extra[0], 10, item.flameLife || 6);
} else if (typeof global.isPinType === "function" && global.isPinType(type)) {
item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10);
} else if (type === "fan") {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);
item.fanSwingOn = Boolean(extra[1]);
item.fanSwingCenter = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[2], 1000, item.angle), item.angle) : u(extra[2], 1000, item.angle);
item.fanSwingRange = Math.max(0, Math.min(150 * Math.PI / 180, u(extra[3], 1000, 35 * Math.PI / 180)));
item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * Math.PI / 180)));
item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0);
} else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) {
const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback);

View file

@ -112,7 +112,7 @@
if (!t || t.dead) continue;
const targetMatch = t.target === structure;
const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id;
const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id;
const nestMatch = (structure.type === "nest_box" || structure.type === "pipe") && t.insideNestBoxId === structure.id;
if (!targetMatch && !sleepTargetMatch && !nestMatch) continue;
if (nestMatch) {

View file

@ -71,7 +71,8 @@ function createSleepInBedActionSpec() {
if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label);
const isEasyBed = bed.type === "grass_bed";
bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined);
t.startSleeping?.(bed, bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b"));
const sleepText = bed.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b"));
t.startSleeping?.(bed, sleepText);
return true;
},
tick(t) {
@ -527,7 +528,7 @@ const TARINAI_ACTION_DEFINITIONS = [
label: "\u904a\u3093\u3067\u3044\u308b",
phrase: "\u904a\u3093\u3060",
weight: 24,
selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; },
selectTarget(t, world) { return world.nearest?.(t, ["ball", "balloon"], 520) || null; },
canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start: startMoveToTarget("play_ball", "\u904a\u3093\u3067\u3044\u308b"),
},

View file

@ -115,7 +115,8 @@
target.singleUsePending = true;
target.singleUseConsumedById = this.id || target.singleUseConsumedById || "";
}
if (target && target.type !== "nest_box") {
const targetIsNestContainer = Boolean(target && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
if (target && !targetIsNestContainer) {
this._sleepExternalMotionUntil = 0;
this._sleepExternalWakeSuppressUntil = Math.max(Number(this._sleepExternalWakeSuppressUntil || 0) || 0, now + 2.4);
this.vx = 0;
@ -123,7 +124,7 @@
this.impulseVx = 0;
this.impulseVy = 0;
}
const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b");
const text = reason || (target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"));
setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
return this.setActionState("sleep", { target, reason: text, sleeping: true });
},

View file

@ -60,7 +60,8 @@ const d = contact.distance;
const occ = tarinai.world.bedOccupancy(it);
const comfort = tarinai.world.bedComfort(it);
it.use?.(tarinai, tarinai.world, isEasyBed ? { purpose: "sleep" } : undefined);
tarinai.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
const sleepText = it.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
tarinai.startSleeping?.(it, sleepText);
if (tarinai.world.time > tarinai.nextSleepBubbleAt) {
tarinai.nextSleepBubbleAt = tarinai.world.time + 4.5;
tarinai.world.spawnBubble(tarinai.x, tarinai.y - tarinai.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
@ -71,7 +72,7 @@ const d = contact.distance;
}
}
if (it.type === "ball") {
if (it.type === "ball" || it.type === "balloon") {
const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1);
if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) {
tarinai.goodMode = "smile";

View file

@ -4,6 +4,13 @@
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_disease_nest.js");
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
function containerLabel(item) { return item?.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; }
function containerInsideSleepText(item, verb = "\u7720\u3063\u3066\u3044\u308b") { return `${containerLabel(item)}\u306e\u4e2d\u3067${verb}`; }
function containerFullText(item) { return `${containerLabel(item)}\u304c\u3044\u3063\u3071\u3044\u3067\u5165\u308c\u306a\u3044`; }
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
infectZunchiDisease(source = null, force = false) {
if (this.dead || this.zunchiDisease) return false;
@ -193,7 +200,7 @@
nestBoxById(id) {
if (!id) return null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null;
return this.world?.items?.find?.(it => it && !it.dead && it.id === id && isNestContainerItem(it)) || null;
},
nestBoxEntryActive() {
@ -203,7 +210,7 @@
return now < (this._nestBoxEntrySmoothUntil || -Infinity) || ((this.nestFade || 0) > 0.01 && (this.nestFade || 0) < 0.985);
},
abortNestBoxEntry(box = null, reason = "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") {
abortNestBoxEntry(box = null, reason = containerFullText(box)) {
const now = this.world?.time || 0;
if (this.insideNestBoxId && (!box || this.insideNestBoxId === box.id)) this.leaveNestBox?.(box);
if (!this.insideNestBoxId) this.nestFade = 0;
@ -233,14 +240,15 @@
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 0.7);
this._nestBoxSeekStartedAt = NaN;
this._nestBoxSeekTargetId = "";
this.goIdle?.(reason);
this.thought = reason;
const finalReason = reason || containerFullText(box);
this.goIdle?.(finalReason);
this.thought = finalReason;
this.pinchThoughtTimer = Math.max(this.pinchThoughtTimer || 0, 1.0);
return false;
},
wantsNestBox(box = null) {
if (!box || box.dead || box.type !== "nest_box") return false;
if (!isNestContainerItem(box)) return false;
const now = this.world?.time || 0;
if (this._nestBoxAvoidId === box.id && now < (this._nestBoxAvoidUntil || -Infinity)) return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;
@ -249,7 +257,7 @@
},
enterNestBox(box, dt = 0.016, options = {}) {
if (!box || box.dead || box.type !== "nest_box" || this.dead) return false;
if (!isNestContainerItem(box) || this.dead) return false;
const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const alreadyInside = this.insideNestBoxId === box.id;
@ -262,13 +270,13 @@
victim.leaveNestBox?.(box);
victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8);
victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22);
victim.thought = "\u5DE3\u7BB1\u304B\u3089\u62BC\u3057\u51FA\u3055\u308C\u305F";
victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`;
victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65);
victim.goIdle?.("\u5DE3\u7BB1\u304B\u3089\u62BC\u3057\u51FA\u3055\u308C\u305F");
victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`);
occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim);
}
}
if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") || false;
if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false;
}
if (!alreadyInside) {
this._nestBoxSeekStartedAt = NaN;
@ -286,7 +294,7 @@
}
this.insideNestBoxId = box.id;
this.target = box;
if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
if (this.state === "seek_bed") this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b"));
if (this.state === "sleep") this.sleeping = true;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y };
const smoothDt = clamp(Number(dt || 0.016) || 0.016, 0.008, 0.045);
@ -330,17 +338,18 @@
this._nestBoxExitDuration = 0;
return false;
}
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(b, this) : { x: b.x, y: b.y };
const door = this.world.nestBoxEntryPoint(b);
const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : door;
const exitContainer = b.type === "pipe" && this.world?.randomPipeExitFor ? (this.world.randomPipeExitFor(b, this) || b) : b;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(exitContainer, this) : { x: exitContainer.x, y: exitContainer.y };
const door = this.world.nestBoxEntryPoint(exitContainer);
const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(exitContainer, this) : door;
this._nestBoxExitStartX = Number.isFinite(this.x) ? this.x : inner.x;
this._nestBoxExitStartY = Number.isFinite(this.y) ? this.y : inner.y;
if ((this.nestFade || 0) > 0.82 || distXY(this._nestBoxExitStartX, this._nestBoxExitStartY, inner.x, inner.y) > (b.r || 42) * 0.7) {
if ((this.nestFade || 0) > 0.82 || distXY(this._nestBoxExitStartX, this._nestBoxExitStartY, inner.x, inner.y) > (exitContainer.r || b.r || 42) * 0.7) {
this._nestBoxExitStartX = inner.x;
this._nestBoxExitStartY = inner.y;
}
this._nestBoxExitDoorX = Number.isFinite(door.x) ? door.x : b.x;
this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : b.y;
this._nestBoxExitDoorX = Number.isFinite(door.x) ? door.x : exitContainer.x;
this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : exitContainer.y;
this._nestBoxExitEndX = Number.isFinite(exit.x) ? exit.x : this._nestBoxExitDoorX;
this._nestBoxExitEndY = Number.isFinite(exit.y) ? exit.y : this._nestBoxExitDoorY;
this._nestBoxExitDuration = 0.58;
@ -404,7 +413,7 @@
}
this.sleeping = false;
this.sleepSession = null;
if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.("\u5DE3\u7BB1\u304B\u3089\u51FA\u305F");
if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.(`${containerLabel(b)}\u304b\u3089\u51fa\u305f`);
this.nestBoxStayUntil = -Infinity;
this.nestBoxSlotIndex = -1;
this._nestBoxEntrySmoothUntil = -Infinity;
@ -421,7 +430,7 @@
updateNestBoxPresence(dt = 0) {
if (this.updateNestBoxExitAnimation?.(dt || 0.016)) return null;
const currentBox = this.nestBoxById(this.insideNestBoxId);
const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null;
const targetBox = isNestContainerItem(this.target) ? this.target : null;
const box = currentBox || targetBox;
if (!box) {
this.insideNestBoxId = null;
@ -436,7 +445,7 @@
if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) {
this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b"));
this.sleeping = true;
this.target = currentBox;
}
@ -462,7 +471,7 @@
this._nestBoxSeekTargetId = targetBox.id;
}
if (!this.insideNestBoxId && global.TarinaiNestSleepSystem.sleepPlaceAvailableFor && !global.TarinaiNestSleepSystem.sleepPlaceAvailableFor(this.world, this, targetBox)) {
return this.abortNestBoxEntry?.(targetBox, "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") || null;
return this.abortNestBoxEntry?.(targetBox, containerFullText(targetBox || box)) || null;
}
const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y };

View file

@ -382,7 +382,8 @@
},
targetIgnoresFence(target = this.target) {
return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
const targetIsContainer = Boolean(target && (typeof isNestContainerType === "function" ? isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe")));
return Boolean(target?.type === "sweet" || targetIsContainer || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
},
shouldAvoidTarget(target) {

View file

@ -35,7 +35,7 @@ function roleMatches(item, role) {
if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role);
if (item.roles?.[role]) return true;
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed";
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed";
if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(item.type);
if (role === "grassMaterial") return item.type === "grass";
return false;

View file

@ -1067,11 +1067,11 @@ function resolveNeedsCore(dt) {
},
isSleepFurniture(target) {
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))));
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box" || target.type === "pipe"))));
},
sleepSpotFor(bed) {
if (!bed) return { x: this.x, y: this.y };
if (bed.type === "nest_box") {
if (bed.type === "nest_box" || bed.type === "pipe") {
if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this);
if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed);
return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) };

View file

@ -1,27 +1,34 @@
"use strict";
// Layer: tarinai/nest-sleep
// Centralizes sleep furniture, nest occupancy/reservation, and sleep-target
// selection policy so nest-box entrance logic and bed targeting do not drift.
// Centralizes sleep furniture, nest/pipe occupancy/reservation, and sleep-target
// selection policy so entrance logic and bed targeting do not drift.
(function (global) {
const distFn = typeof dist === "function" ? dist : ((a, b) => Math.hypot((a?.x || 0) - (b?.x || 0), (a?.y || 0) - (b?.y || 0)));
const clampFn = typeof clamp === "function" ? clamp : ((v, a, b) => Math.max(a, Math.min(b, v)));
function isNestContainer(item) {
return Boolean(item && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
function isSleepFurniture(item) {
return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed"))));
return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed"))));
}
function sleepTargetBlocked(worldRef, tarinai, item) {
if (!worldRef || !tarinai || !item) return false;
// Pipe mouths are floor-level entry points. Treat them as reachable even if
// their circular sprite overlaps the tarinai body.
if (item.type === "pipe") return false;
return Boolean(worldRef.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65)));
}
function nestBoxCapacity(worldRef, box = null) {
return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : 5;
return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : (box?.type === "pipe" ? 3 : 5);
}
function nestBoxOccupants(worldRef, box, limit = 5) {
if (!worldRef || !box || box.dead || box.type !== "nest_box") return [];
if (!worldRef || !box || box.dead || !isNestContainer(box)) return [];
const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id);
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
}
@ -44,7 +51,7 @@
}
function nestBoxReservationCount(worldRef, box) {
if (!worldRef || !box || box.dead || box.type !== "nest_box") return 0;
if (!worldRef || !box || box.dead || !isNestContainer(box)) return 0;
const now = Number(worldRef.time || 0) || 0;
let n = 0;
for (const t of worldRef.tarinai || []) if (tarinaiIsReservingNest(t, box, now)) n += 1;
@ -53,7 +60,7 @@
function bedOccupancy(worldRef, bed) {
if (!worldRef || !isSleepFurniture(bed)) return 0;
if (bed.type === "nest_box") {
if (isNestContainer(bed)) {
return nestBoxOccupants(worldRef, bed, Infinity).length + nestBoxReservationCount(worldRef, bed);
}
let n = 0;
@ -69,7 +76,8 @@
function bedComfort(worldRef, bed) {
if (!isSleepFurniture(bed)) return 0.7;
const occ = bedOccupancy(worldRef, bed);
return clampFn((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
const base = bed.type === "pipe" ? Math.min(Number(bed.comfort ?? 0.74) || 0.74, 0.82) : (bed.comfort ?? 1);
return clampFn(base - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, bed.type === "pipe" ? 0.46 : 0.42, bed.type === "pipe" ? 0.88 : 1.18);
}
function sleepPlaceAvailableFor(worldRef, tarinai, item) {
@ -79,7 +87,7 @@
if (tarinai.shouldAvoidTarget?.(item)) return false;
if (worldRef?.sleepTargetTemperatureBlocked?.(tarinai, item)) return false;
if (sleepTargetBlocked(worldRef, tarinai, item)) return false;
const capacity = item.type === "nest_box" ? nestBoxCapacity(worldRef, item) : 1;
const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1;
const occupancy = bedOccupancy(worldRef, item);
return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id);
}
@ -135,6 +143,7 @@
return findOwnedStructure(worldRef, tarinai, "grass_bed", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "nest_box", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "bed", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "pipe", maxDist)
|| nearestSleepPlaceOfType(worldRef, tarinai, "grass_bed", maxDist)
|| null;
}
@ -145,6 +154,7 @@
}
global.TarinaiNestSleepSystem = Object.freeze({
isNestContainer,
isSleepFurniture,
isTarinaiHiddenInNestBox,
nestBoxOccupants,

View file

@ -22,6 +22,14 @@
const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002";
function isNestContainerItem(item) {
return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe")));
}
function sleepContainerText(item, verb = "\u4f11\u3093\u3067\u3044\u308b") {
return item?.type === "pipe" ? `\u571f\u7ba1\u306e\u4e2d\u3067${verb}` : `\u5de3\u7bb1\u306e\u4e2d\u3067${verb}`;
}
function tarinaiIsChampionBreedingPartner(champion, candidate) {
if (!champion || !candidate || champion === candidate) return false;
if (champion.birthPartnerId && champion.birthPartnerId === candidate.id) return true;
@ -316,7 +324,7 @@
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1);
this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115);
if (this.target && !this.target.dead && this.target.type === "nest_box") {
if (this.target && !this.target.dead && isNestContainerItem(this.target)) {
this.updateNestBoxPresence(dt);
this.vx = 0;
this.vy = 0;
@ -504,19 +512,20 @@
const bedDx = this.target.x - this.x;
const bedDy = this.target.y - this.y;
const bedDist = Math.hypot(bedDx, bedDy);
const reachedSleepFurniture = this.target.type === "nest_box"
const reachedSleepFurniture = isNestContainerItem(this.target)
? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62))
: (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20));
if (reachedSleepFurniture) {
const sleepTarget = this.target;
const isEasyBed = sleepTarget?.type === "grass_bed";
sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined);
this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"));
if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt);
const sleepText = isNestContainerItem(sleepTarget) ? sleepContainerText(sleepTarget, "\u4f11\u3093\u3067\u3044\u308b") : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b");
this.startSleeping?.(sleepTarget, sleepText);
if (isNestContainerItem(sleepTarget)) this.enterNestBox(sleepTarget, dt);
return;
}
}
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && this.target?.type === "nest_box") {
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) {
const nestDist = distXY(this.x, this.y, this.target.x, this.target.y);
if (nestDist <= Math.max(50, (this.radius || 20) + (this.target.r || 24) + 18)) {
this.enterNestBox?.(this.target, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON });
@ -524,7 +533,7 @@
}
}
let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1;
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && this.target?.type === "nest_box") sign = 1;
if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) sign = 1;
if (this.target?.detour) sign = 1;
let strength = 1;
if (this.state === "follow_parent") {
@ -685,7 +694,6 @@
if (this.world.recentDeathTimes.length > 24) this.world.recentDeathTimes = this.world.recentDeathTimes.slice(-24);
for (const item of this.world.items || []) {
if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue;
item.dead = true;
item.hp = 0;
item.amount = 0;
item.carriedById = "";

View file

@ -59,7 +59,7 @@
const lockedPassive = sleeping || state === "eat" || /eat|sunbath|birth_ritual|breed|repro/i.test(id) || /sunbath|birth_ritual|breed|repro/.test(state) || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.sunbathTimer || 0) > 0.04;
if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true;
if (lockedPassive && isVisible && activeMechanicalObstacleNear(tarinai, worldRef)) return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true;
if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(tarinai.target.type) : (tarinai.target.type === "nest_box" || tarinai.target.type === "pipe"))) return true;
return false;
}

View file

@ -120,7 +120,7 @@
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") {
if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(t.target.type) : (t.target.type === "nest_box" || t.target.type === "pipe"))) {
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;

View file

@ -153,7 +153,7 @@ TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (cleanedBehaviorLabel) return cleanedBehaviorLabel;
const target = TEXT_CATALOG.targetLabel(t.target);
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : (t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b");
if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b";
if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";

View file

@ -252,7 +252,7 @@
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
} else if ((target.type === "ball" || target.type === "balloon")) {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
@ -293,8 +293,8 @@
const frameDt = Math.max(1 / 90, Math.min(1 / 30, Number(world.dt || 1 / 60) || 1 / 60));
target.prevX = target.x - releaseVx * frameDt;
target.prevY = target.y - releaseVy * frameDt;
if (target.type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42);
} else if (target.type === "ball") {
if ((target.type === "ball" || target.type === "balloon")) target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42);
} else if ((target.type === "ball" || target.type === "balloon")) {
target.prevX = target.x;
target.prevY = target.y;
}

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "39.15.110";
const APP_BUILD = "tarinai-fps-cadence-v39-15-110";
const APP_VERSION = "39.15.116";
const APP_BUILD = "tarinai-pipe-v39-15-116";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -154,8 +154,8 @@
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
});
let blastedBalls = 0;
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy) || 1;
@ -243,8 +243,8 @@
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
});
for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) {
if (!ball || ball.dead || ball.type !== "ball") continue;
for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) {
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue;
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy) || 1;
@ -633,10 +633,10 @@
return global.TarinaiImpactResponseSystem.itemDropImpact(this, it);
},
limitBallChasers() {
if (!this.itemCounts?.ball) return;
if (!this.itemCounts?.ball && !this.itemCounts?.balloon) return;
const groups = new Map();
for (const t of this.tarinai || []) {
if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue;
if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || (t.target.type !== "ball" && t.target.type !== "balloon")) continue;
const arr = groups.get(t.target) || [];
arr.push(t);
groups.set(t.target, arr);
@ -649,14 +649,14 @@
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
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((ball.type === "balloon" ? "水風船" : "ボール") + "が混み合っているので眺めている");
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + deterministicRange(this, "ball-chaser-release-angle", -0.45, 0.45, t, ball);
}
}
},
pokeBall(ball, x, y) {
if (!ball || ball.dead || ball.type !== "ball") return false;
if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) return false;
const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0);
let dx = ball.x - x;
let dy = ball.y - y;
@ -674,14 +674,15 @@
ball.lastPokedAt = now;
ball.lastPokeAngle = Math.atan2(ny, nx);
const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24);
const isBalloon = ball.type === "balloon";
const impulse = (isBalloon ? 88 : 145) + ball.pokeCombo * (isBalloon ? 38 : 64) + Math.min(isBalloon ? 160 : 260, currentSpeed * (isBalloon ? 0.18 : 0.24));
ball.vx = (ball.vx || 0) + nx * impulse;
ball.vy = (ball.vy || 0) + ny * impulse;
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * ((isBalloon ? 2.4 : 5.6) + ball.pokeCombo * (isBalloon ? 0.55 : 1.15)), -38, 38);
ball.amount = Math.max(ball.amount || 999, 999);
audio.ballHit?.();
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" }));
this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" });
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" }));
this.log(isBalloon ? "水風船をつついて飛ばした。" : "ボールをつついて転がした。", "observe", { eventType: "ball_poke" });
return true;
},

View file

@ -18,7 +18,7 @@
const type = String(it.type || "");
let reach = Math.max(32, Number(it.r || it.radius || 32) || 32);
if (type === "fence" || type === "fence_v" || type === "glass_wall" || 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 if (type === "nest_box" || type === "pipe") reach = Math.max(92, reach * 1.9);
else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6);
return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach);
}
@ -232,13 +232,27 @@
},
nestBoxCapacity() {
return 5;
nestBoxCapacity(box = null) {
return box?.type === "pipe" ? 3 : 5;
},
isNestContainerItem(it) {
return Boolean(it && !it.dead && (typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(it.type) : (it.type === "nest_box" || it.type === "pipe")));
},
nestBoxBaseRect(it) {
if (!it || it.dead || it.type !== "nest_box") return null;
const r = it.r || 42;
if (!this.isNestContainerItem?.(it)) return null;
const r = it.r || (it.type === "pipe" ? 36 : 42);
if (it.type === "pipe") {
return {
left: it.x - r * 1.12,
right: it.x + r * 1.12,
top: it.y - r * 0.88,
bottom: it.y + r * 0.82,
type: "pipe",
item: it,
};
}
return {
left: it.x - r * 1.28,
right: it.x + r * 1.28,
@ -252,6 +266,7 @@
nestBoxSolidRects(it) {
const base = this.nestBoxBaseRect(it);
if (!base) return [];
if (it?.type === "pipe") return [];
const w = base.right - base.left;
const h = base.bottom - base.top;
const colW = w / 3;
@ -266,7 +281,13 @@
nestBoxEntryPoint(box) {
const base = this.nestBoxBaseRect(box);
if (!base) return { x: box?.x || 0, y: box?.y || 0 };
const r = box.r || 42;
const r = box.r || (box.type === "pipe" ? 36 : 42);
if (box.type === "pipe") {
return {
x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
y: clamp(box.y - r * 0.02, CONFIG.worldPadding, this.h - CONFIG.worldPadding),
};
}
// Move the approach point to the center of the open middle cell. The
// collision resolver also ignores this nest box for active sleepers near
// the door, so tarinai can cross the threshold instead of sliding off posts.
@ -279,7 +300,14 @@
nestBoxExitPoint(box, occupant = null) {
const base = this.nestBoxBaseRect(box);
if (!base) return this.nestBoxEntryPoint(box);
const r = box.r || 42;
const r = box.r || (box.type === "pipe" ? 36 : 42);
if (box.type === "pipe") {
const side = occupant ? (stableUnit(occupant.id || "pipe", `pipe-exit-${box.id || "pipe"}`) - 0.5) * r * 0.46 : 0;
return {
x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
y: clamp(box.y + r * 0.74, CONFIG.worldPadding, this.h - CONFIG.worldPadding),
};
}
const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0;
return {
x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding),
@ -294,17 +322,34 @@
let index = Number.isInteger(occupant?.nestBoxSlotIndex) && occupant.nestBoxSlotIndex >= 0 ? occupant.nestBoxSlotIndex : occupants.indexOf(occupant);
if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1);
index = clamp(index, 0, Math.max(0, this.nestBoxCapacity(box) - 1));
const r = box.r || 42;
const slots = [
[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16],
];
const r = box.r || (box.type === "pipe" ? 36 : 42);
const pipeSlots = [[0.00, -0.02], [-0.15, 0.06], [0.15, 0.06]];
const nestSlots = [[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16]];
const slots = box.type === "pipe" ? pipeSlots : nestSlots;
const slot = slots[index % slots.length];
if (box.type === "pipe") {
return {
x: clamp(box.x + slot[0] * r, base.left + r * 0.40, base.right - r * 0.40),
y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.28),
};
}
return {
x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46),
y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18),
};
},
randomPipeExitFor(sourcePipe = null, occupant = null) {
const allPipes = (this.items || []).filter(it => it && !it.dead && it.type === "pipe");
if (!allPipes.length) return sourcePipe;
const pipes = allPipes.length > 1 && sourcePipe ? allPipes.filter(it => it.id !== sourcePipe.id) : allPipes;
if (!pipes.length) return sourcePipe || allPipes[0] || null;
const now = Number(this.time || 0) || 0;
const salt = `${occupant?.id || "tarinai"}:${sourcePipe?.id || "pipe"}:${Math.floor(now * 1.7)}`;
const idx = Math.floor(stableUnit(salt, "random-pipe-exit") * pipes.length) % pipes.length;
return pipes[idx] || sourcePipe || null;
},
@ -323,12 +368,12 @@
return rect ? [{ ...rect, type: it.type, item: it }] : [];
}
if (global.TarinaiMechanicalShapeSystem.isMechanicalType(it.type)) return global.TarinaiMechanicalShapeSystem.obstacleRects(it) || [];
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
if (it.type === "nest_box" || it.type === "pipe") return this.nestBoxSolidRects(it);
return [];
},
shouldIgnoreNestBoxCollisionFor(t, box) {
if (!t || !box || box.dead || box.type !== "nest_box") return false;
if (!t || !box || box.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(box.type) : (box.type === "nest_box" || box.type === "pipe"))) return false;
if (t.insideNestBoxId) return true;
if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false;
const entry = this.nestBoxEntryPoint(box);

View file

@ -451,7 +451,7 @@
if (!p?.inside) return null;
let best = null, bestD = Infinity;
for (const it of this.nearbyItems(p.x, p.y, 140)) {
if (!it || it.dead || it.type !== "nest_box") continue;
if (!it || it.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(it.type) : (it.type === "nest_box" || it.type === "pipe"))) continue;
const d = distXY(p.x, p.y, it.x, it.y);
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
}
@ -462,21 +462,24 @@
},
nestBoxTooltipLines(box) {
if (!box || box.dead || box.type !== "nest_box") return [];
if (!box || box.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(box.type) : (box.type === "nest_box" || box.type === "pipe"))) return [];
const occupants = this.nestBoxOccupants(box, Infinity);
const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5);
const more = occupants.length > names.length ? `\u3001\u307b\u304b${occupants.length - names.length}\u5339` : "";
return ["\u5de3\u7bb1", names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "\u5185\u90E8\u6C17\u6E29")].filter(Boolean);
const label = box.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1";
return [label, names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "\u5185\u90E8\u6C17\u6E29")].filter(Boolean);
},
temperatureTooltipLineFor(target, label = "\u4F53\u611F\u6C17\u6E29") {
temperatureTooltipLineFor(target, label = "体感気温") {
if (!target || !this.temperatureAt) return "";
const temp = this.temperatureAt(target.x, target.y);
const temp = target.type === "pipe" && this.pipeInteriorTemperatureFor
? this.pipeInteriorTemperatureFor(target)
: this.temperatureAt(target.x, target.y);
if (!Number.isFinite(Number(temp))) return "";
const status = this.temperatureStatusFor?.(temp) || null;
const suffix = status?.label ? `\uFF08${status.label}\uFF09` : "";
return `${label}: ${Number(temp).toFixed(1)}\u2103${suffix}`;
const suffix = status?.label ? `(${status.label})` : "";
return `${label}: ${Number(temp).toFixed(1)}℃${suffix}`;
},
pointerTarinaiTooltipInfo() {
@ -521,7 +524,7 @@
const tarinaiInfo = this.pointerTarinaiTooltipInfo?.();
if (tarinaiInfo?.target) return tarinaiInfo;
const nest = this.pointerNestBoxInfo?.();
if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" };
if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: nest.box?.type === "pipe" ? "pipe" : "nest_box" };
const bedInfo = this.pointerOwnedBedInfo?.();
if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" };
return null;

View file

@ -218,6 +218,100 @@
return true;
}
function ensureFanEditor() {
return ensureEditorDialog("fanEditor", "rotator-editor fan-editor hidden", `
<div class="rotator-editor-panel fan-editor-panel" role="dialog" aria-modal="true" aria-labelledby="fanEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="fanEditorTitle">扇風機</h2>
<button id="fanEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div>
<div class="fan-editor-help">クリックした扇風機の首振りを設定します。保存すると、その扇風機だけに反映されます。</div>
<div class="rotator-editor-controls fan-editor-controls">
<label><span>向き</span><input id="fanCenterAngle" type="range" min="0" max="355" step="5"><input id="fanCenterAngleNum" type="number" min="0" max="355" step="5"><span>度</span></label>
<label><span>振り幅</span><input id="fanSwingRange" type="range" min="0" max="120" step="5"><input id="fanSwingRangeNum" type="number" min="0" max="150" step="5"><span>度</span></label>
<label><span>速度</span><input id="fanSwingSpeed" type="range" min="0" max="180" step="5"><input id="fanSwingSpeedNum" type="number" min="0" max="360" step="5"><span>度/秒</span></label>
<label class="fan-editor-check"><input id="fanSwingEnabled" type="checkbox"> 首振りON</label>
</div>
<p class="rotator-editor-hint">振り幅0度なら固定向きです。Q/Eで置く向きとは別に、配置済み扇風機をクリックして調整できます。</p>
<div class="rotator-editor-actions">
<button id="fanEditorStop" class="btn" type="button">固定にする</button>
<button id="fanEditorCancel" class="btn" type="button">キャンセル</button>
<button id="fanEditorApply" class="btn primary" type="button">保存</button>
</div>
</div>`);
}
function degForAngle(angle = 0) {
const rad = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angle, 0) : (((Number(angle) || 0) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
return Math.round((rad * 180 / Math.PI) % 360 + 360) % 360;
}
function syncEditorInputPair(rangeInput, numberInput, onChange = null) {
const sync = (source, target) => {
if (!source || !target) return;
target.value = source.value;
if (typeof onChange === "function") onChange();
};
if (rangeInput) rangeInput.oninput = () => sync(rangeInput, numberInput);
if (numberInput) numberInput.oninput = () => sync(numberInput, rangeInput);
}
function openFanEditor(item, worldRef) {
if (!item || item.type !== "fan") return false;
const dialog = ensureFanEditor();
const center = dialog.querySelector("#fanCenterAngle");
const centerNum = dialog.querySelector("#fanCenterAngleNum");
const swingRange = dialog.querySelector("#fanSwingRange");
const swingRangeNum = dialog.querySelector("#fanSwingRangeNum");
const swingSpeed = dialog.querySelector("#fanSwingSpeed");
const swingSpeedNum = dialog.querySelector("#fanSwingSpeedNum");
const enabled = dialog.querySelector("#fanSwingEnabled");
const stop = dialog.querySelector("#fanEditorStop");
const apply = dialog.querySelector("#fanEditorApply");
const cancel = dialog.querySelector("#fanEditorCancel");
const closeBtn = dialog.querySelector("#fanEditorClose");
const currentAngle = item.fanSwingOn ? (Number(item.fanSwingCenter) || itemAngleFor(item)) : itemAngleFor(item);
const centerDeg = degForAngle(currentAngle);
const rangeDeg = Math.round(Math.max(0, Math.min(150, Number(item.fanSwingRange ?? (35 * Math.PI / 180)) * 180 / Math.PI || 35)));
const speedDeg = Math.round(Math.max(0, Math.min(360, Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) * 180 / Math.PI || 48)));
if (center) center.value = String(Math.max(0, Math.min(355, centerDeg)));
if (centerNum) centerNum.value = String(centerDeg);
if (swingRange) swingRange.value = String(Math.max(0, Math.min(120, rangeDeg)));
if (swingRangeNum) swingRangeNum.value = String(rangeDeg);
if (swingSpeed) swingSpeed.value = String(Math.max(0, Math.min(180, speedDeg)));
if (swingSpeedNum) swingSpeedNum.value = String(speedDeg);
if (enabled) enabled.checked = Boolean(item.fanSwingOn);
syncEditorInputPair(center, centerNum);
syncEditorInputPair(swingRange, swingRangeNum);
syncEditorInputPair(swingSpeed, swingSpeedNum);
const close = () => dialog.classList.add("hidden");
const commit = (forceStop = false) => {
global.TarinaiHistory.capture(worldRef, "fan-swing-edit");
const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0));
const rDeg = Math.max(0, Math.min(150, Number(swingRangeNum?.value || swingRange?.value || 0) || 0));
const sDeg = Math.max(0, Math.min(360, Number(swingSpeedNum?.value || swingSpeed?.value || 0) || 0));
item.fanSwingCenter = (typeof normalizedItemAngle === "function" ? normalizedItemAngle(cDeg * Math.PI / 180, 0) : cDeg * Math.PI / 180);
item.fanSwingRange = rDeg * Math.PI / 180;
item.fanSwingSpeed = sDeg * Math.PI / 180;
item.fanSwingOn = forceStop ? false : Boolean(enabled?.checked && rDeg > 0 && sDeg > 0);
item.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
item.angle = item.fanSwingCenter;
worldRef?.markSpatialDirty?.("fan-swing-settings");
if (worldRef) worldRef.drawListDirty = true;
worldRef?.log?.(item.fanSwingOn ? "扇風機の首振り設定を変更した。" : "扇風機を固定向きにした。", "observe");
if (typeof showToast === "function") showToast(item.fanSwingOn ? "扇風機: 首振りON" : "扇風機: 固定向き");
global.render?.();
close();
};
if (stop) stop.onclick = () => commit(true);
if (apply) apply.onclick = () => commit(false);
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
function ensureRotatorEditor() {
const dialog = ensureEditorDialog("rotatorEditor", "rotator-editor hidden", `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
@ -527,7 +621,7 @@
const d = distXY(x, y, it.x, it.y);
let result = null;
if (collision?.hitTestItem) {
const isPrecise = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(type) || worldRef?.isFenceType?.(type) || type === "nest_box");
const isPrecise = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(type) || worldRef?.isFenceType?.(type) || type === "nest_box" || type === "pipe");
result = collision.hitTestItem(worldRef, it, x, y, {
padding: isPrecise ? 10 : 4,
radiusMultiplier: isPrecise ? 1.12 : 1.18,
@ -563,6 +657,13 @@
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 === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
if (item.type === "fan") {
data.fanSwingOn = Boolean(item.fanSwingOn);
data.fanSwingCenter = Number(item.fanSwingCenter ?? item.angle ?? 0) || 0;
data.fanSwingRange = Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
data.fanSwingSpeed = Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
data.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
}
if (item.type === "rotator") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
}
@ -600,6 +701,13 @@
item.fuseTimer = Math.max(0.1, Number(item.fuseMax || 5) || 5);
}
if (isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0;
if (item.type === "fan") {
item.fanSwingOn = Boolean(data.fanSwingOn);
item.fanSwingCenter = Number(data.fanSwingCenter ?? item.angle ?? 0) || 0;
item.fanSwingRange = Number(data.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
item.fanSwingSpeed = Number(data.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
item.fanSwingPhase = Number(data.fanSwingPhase || 0) || 0;
}
if (item.type === "signboard") item.text = String(data.text || "");
if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen);
if (item.type === "duplicator") {
@ -678,7 +786,7 @@
function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator");
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan");
}
function findEditableItemAt(worldRef, x, y) {
@ -705,6 +813,7 @@
if (item.type === "robot_cleaner") return openRobotCleanerEditor(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 === "fan") return openFanEditor(item, worldRef);
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
worldRef?.markSpatialDirty?.("gate-toggle");
@ -1021,7 +1130,7 @@
const original = this.placementProbeItem(item, start.x, start.y);
if (allowOriginal && !this.placementBlocked(original)) return start;
const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "rotator" || item.type === "reciprocator";
const solid = this.isFenceType(item.type) || (item.type === "nest_box" || item.type === "pipe") || item.type === "rotator" || item.type === "reciprocator";
const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2));
const count = attempts || (solid ? 72 : 36);
const golden = Math.PI * (3 - Math.sqrt(5));
@ -1061,7 +1170,7 @@
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
} else {
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: it.type === "ball" ? 4.0 : 2.2 }) || { hit: false, distance: d };
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: (it.type === "ball" || it.type === "balloon") ? 4.0 : 2.2 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}
@ -1093,7 +1202,7 @@
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
return null;
}
if (dropped && (item.type === "genkotsu" || item.type === "stone" || item.type === "ball" || isPinType(item.type))) {
if (dropped && (item.type === "genkotsu" || item.type === "stone" || item.type === "ball" || item.type === "balloon" || isPinType(item.type))) {
item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : 0.48);
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
@ -1211,7 +1320,7 @@
let ballTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "ball") continue;
if (!it || it.dead || (it.type !== "ball" && it.type !== "balloon")) continue;
const hit = Math.max(72, (it.r || 18) * 4.0);
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }

View file

@ -136,7 +136,7 @@ const RESET_PRESETS = Object.freeze({
function presetItemFootprintRadius(worldRef, item) {
const radius = globalThis.TarinaiCollisionFootprints?.itemFootprintRadius?.(worldRef, item);
if (Number.isFinite(Number(radius))) {
if (item?.type === "ball") return Math.max(34, Number(radius) * 1.35);
if (item?.type === "ball" || item?.type === "balloon") return Math.max(34, Number(radius) * 1.35);
return Math.max(14, Number(radius));
}
return Math.max(14, Number(item?.r || 16) || 16);
@ -177,7 +177,7 @@ const RESET_PRESETS = Object.freeze({
function findPresetItemSpot(worldRef, item, opts = {}) {
const allowOriginal = opts.allowOriginal !== false;
if (allowOriginal && !presetItemPlacementBlocked(worldRef, item, opts)) return { x: item.x, y: item.y };
const solid = Boolean(worldRef?.isFenceType?.(item.type) || item.type === "rotator" || item.type === "reciprocator" || item.type === "nest_box");
const solid = Boolean(worldRef?.isFenceType?.(item.type) || item.type === "rotator" || item.type === "reciprocator" || item.type === "nest_box" || item.type === "pipe");
const count = Math.max(12, Number(opts.attempts || 0) || (solid ? 96 : 58));
const maxRadius = Math.max(70, Number(opts.maxRadius || 0) || (solid ? 220 : 170));
const golden = Math.PI * (3 - Math.sqrt(5));

View file

@ -115,7 +115,21 @@
if ((this.groundType || "soil") === "ice") temp -= 5;
const range = Math.max(24, CONFIG.temperatureItemRange ?? 190);
for (const it of this.nearbyItems(x, y, range + 8)) {
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue;
if (it.type === "fan") {
const angle = itemAngleFor(it);
const dx = x - it.x;
const dy = y - it.y;
const along = dx * Math.cos(angle) + dy * Math.sin(angle);
const fanRange = range * 1.35;
if (along <= 8 || along > fanRange) continue;
const lateral = Math.abs(-dx * Math.sin(angle) + dy * Math.cos(angle));
const halfWidth = 28 + along * 0.72;
if (lateral > halfWidth) continue;
const edge = Math.pow(clamp(1 - along / fanRange, 0, 1), 0.62) * Math.pow(clamp(1 - lateral / Math.max(1, halfWidth), 0, 1), 0.30);
temp -= 4 * edge;
continue;
}
const d = distXY(x, y, it.x, it.y);
if (d > range) continue;
const edge = d <= range - 24 ? 1 : clamp((range - d) / 24, 0, 1);
@ -136,8 +150,22 @@
if (actor && (actor.state === "sunbath" || (actor.sunbathTimer || 0) > 0.04)) temp += 3;
return this.temperatureNumber(temp);
},
pipeInteriorTemperatureFor(pipe, actor = null) {
if (!pipe || pipe.dead || pipe.type !== "pipe") return null;
const base = this.temperatureAt?.(pipe.x, pipe.y, null);
const temp = this.temperatureNumber(base, this.currentTemperature ?? CONFIG.standardTemperature ?? 15);
const toward = 20 - temp;
return this.temperatureNumber(temp + clamp(toward, -7, 7));
},
feltTemperatureFor(actor, x = actor?.x, y = actor?.y) {
if (!actor) return this.temperatureAt(x, y);
if (actor.insideNestBoxId && this.nestBoxById) {
const box = this.nestBoxById(actor.insideNestBoxId);
if (box?.type === "pipe") {
const pipeTemp = this.pipeInteriorTemperatureFor?.(box, actor);
if (Number.isFinite(Number(pipeTemp))) return pipeTemp;
}
}
return this.temperatureAt(x, y, actor);
},
temperatureComfortOffsetFor(actor = null) {
@ -189,9 +217,16 @@
}
const itemRange = Math.max(24, CONFIG.temperatureItemRange ?? 190);
for (const it of this.items || []) {
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove")) continue;
if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue;
if (currentStatus.direction === "cold" && it.type !== "stove") continue;
if (currentStatus.direction === "hot" && it.type !== "dry_ice") continue;
if (currentStatus.direction === "hot" && it.type !== "dry_ice" && it.type !== "fan") continue;
if (it.type === "fan") {
const a = itemAngleFor(it);
pushCandidate(it.x + Math.cos(a) * itemRange * 0.52, it.y + Math.sin(a) * itemRange * 0.52, -50);
pushCandidate(it.x + Math.cos(a + 0.48) * itemRange * 0.72, it.y + Math.sin(a + 0.48) * itemRange * 0.72, -28);
pushCandidate(it.x + Math.cos(a - 0.48) * itemRange * 0.72, it.y + Math.sin(a - 0.48) * itemRange * 0.72, -28);
continue;
}
const toward = Math.atan2(actor.y - it.y, actor.x - it.x);
pushCandidate(it.x + Math.cos(toward) * itemRange * 0.45, it.y + Math.sin(toward) * itemRange * 0.45, -60);
pushCandidate(it.x + Math.cos(toward + 0.75) * itemRange * 0.62, it.y + Math.sin(toward + 0.75) * itemRange * 0.62, -32);
@ -225,12 +260,14 @@
},
sleepTargetTemperatureBlocked(actor, item) {
if (!actor || !item || item.dead) return false;
if (!(item.type === "grass_bed" || item.type === "bed" || item.type === "nest_box")) return false;
if (!(item.type === "grass_bed" || item.type === "bed" || item.type === "nest_box" || item.type === "pipe")) return false;
const current = this.feltTemperatureFor?.(actor) ?? this.temperatureAt?.(actor.x, actor.y, actor);
const currentStatus = this.temperatureStatusFor?.(current, actor);
if (!currentStatus || currentStatus.comfortable) return false;
if (!this.hasReachableComfortTemperatureSpot?.(actor)) return false;
const temp = this.temperatureAt?.(item.x, item.y, actor);
const temp = item.type === "pipe" && this.pipeInteriorTemperatureFor
? this.pipeInteriorTemperatureFor(item, actor)
: this.temperatureAt?.(item.x, item.y, actor);
const status = this.temperatureStatusFor?.(temp, actor);
return Boolean(status && !status.comfortable);
},

View file

@ -317,7 +317,7 @@
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
} else {
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: it.type === "ball" ? 3.2 : 1.65, padding: 10 }) || { hit: false, distance: d };
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: (it.type === "ball" || it.type === "balloon") ? 3.2 : 1.65, padding: 10 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}

View file

@ -1,6 +1,6 @@
"use strict";
const APP_VERSION = "39.15.110";
const APP_VERSION = "39.15.116";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.