This commit is contained in:
33333-33333 2026-07-09 16:08:11 +09:00
commit f1c3af2b89
49 changed files with 989 additions and 310 deletions

View file

@ -6,7 +6,7 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th
- Entrypoint: `index.html` defines the app shell, canvas, dialogs, right panels, and exact script order.
- Runtime style: plain browser JS, global IIFEs, exports on `window`, no bundler/module loader.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.164`.
- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.167`.
- Main loop: `js/main.js` initializes assets/audio/world/UI/save, then drives update/render.
- Performance/display settings: `js/perf_profiler.js` owns manual visual settings and profiler buckets; display tiers affect rendering/visuals, not simulation-quality branches.
- Update order: `js/system_order.js` calls phase facades from `js/simulation_systems.js`.

View file

@ -1,3 +1,23 @@
tarinai_physics_tuning_v167
- Replaced the 8-sided circle template with a 24-sided, 20px-grid-aligned contour.
- Raised bounce-fence restitution from 1.08 to 2.40 and increased minimum/capped launch speeds.
- Made rope endpoint and particle constraints inextensible while retaining slack/flexibility.
- Set sticky-bomb blastScale to 0.68, matching the small firecracker.
tarinai_mechanical_collision_grid_limit_audit_v1
- Restored shared SAT geometry exports so collision-enabled rotators, reciprocators, and poison blocks physically interact with one another.
- Mechanical shape editors now snap all geometry to a coarse 20px lattice.
- Removed central colony-limit bypass options and added direct-insertion, capacity, save-roundtrip, and over-limit regression checks.
- Version: 39.15.167
tarinai_champion_limits_mechanical_defaults_v1
- Tarinai champions actively prioritize other champions as breeding partners, with stronger mate drive and shorter champion-pair cooldowns.
- Reciprocators now default to 90 degrees.
- Poison blocks now use a smaller default footprint.
- Colony panel now supports optional tarinai-population and object-count limits; blank/0 means unlimited.
- Colony limits are saved in snapshot/binary data.
- Version: 39.15.165
tarinai_plushie_fling_speed_v1
- Reduced plushie poke-fling velocity to one third.
- Lowered plushie fling minimum-speed normalization to the same one-third scale.

View file

@ -1,6 +1,6 @@
{
"purpose": "Token-efficient machine-readable routing for tarinai_. Use scripts/ai_inventory.py for exact current file expansion.",
"version": "39.15.164",
"version": "39.15.167",
"entrypoints": {
"app": "index.html",
"runtime": "js/main.js",

View file

@ -2,6 +2,20 @@
This document describes the playable features of `tarinai_`: what the player sees, what tools do, and how tarinai behave. It is intentionally player/design-facing, not an implementation map.
## v39.15.167 mechanical interaction and limit audit
- 回転体・往復体・毒ブロックは、双方の「当たり判定ON」時にSAT矩形判定で相互作用する。当たり判定OFFの機構は機構同士の衝突対象から除外される。
- 3種類の輪郭編集は20px間隔のグリッド格子点へスナップする。直線、自由描画、消去位置、テンプレート、再編集時の既存端点に同じ規則を適用する。
- コロニー上限は中央生成APIで常時適用され、通常実行時に上限を迂回するオプションは持たない。既存要素やセーブ復元要素は削除せず、その後の新規生成のみを抑止する。
## v39.15.165 behavior and colony controls
- たりない王は、繁殖可能な別のたりない王を広い範囲から優先して探し、通常より強い繁殖欲求と短い再繁殖待機時間を持つ。
- 往復体の新規配置時の標準方向は90度。
- 毒ブロックの新規配置時の標準輪郭は小型半径44、輪郭104×40、太さ11
- コロニーパネルで、たりない個体数と生存オブジェクト総数の上限を設定できる。空欄または0は上限なし。既存要素は削除せず、新規追加だけを抑止する。
- 上限値はスナップショットとバイナリセーブに保存される。
## Game Overview
`tarinai_` is an observation sandbox about small creatures called tarinai. The player places food, furniture, hazards, machines, and environment tools, then watches how individuals live, move, eat, sleep, panic, fight, reproduce, get sick, recover, and leave family records.
@ -76,10 +90,10 @@ The core loop is:
### Physics
- **ヒモ / rope**: connect two targets with a flexible link.
- **ヒモ / rope**: connect two targets with a flexible, inextensible link that may go slack but does not lengthen.
- **棒 / rod**: connect two targets with a rigid link.
- **柵 / fence**: blocks tarinai movement; Q/E rotate by 45 degrees, Shift+Q/E by 5 degrees.
- **バウンス柵 / bounce fence**: fence that rebounds things that hit it.
- **バウンス柵 / bounce fence**: high-restitution fence that strongly launches things that hit it.
- **ゲート柵 / gate fence**: click to open or close.
- **回転体 / rotator**: custom mechanical shape that rotates; click to edit shape and speed.
- **毒ブロック / poison block**: custom hazard block; touching it damages skin; click to edit shape.

View file

@ -13,7 +13,7 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small;
## Runtime Shape
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.164`.
- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.167`.
- Mutable runtime lives on `window`; primary entities are `World`, `Tarinai`, items, structures, ants, family graph, weather, and save snapshots.
- Update loop: `main.js` creates the world/UI loop; `World.update` delegates to `TarinaiSystemOrder.update`; `system_order.js` runs named phases.
- UI loop: pointer/tool/UI intents go through `command_dispatcher.js`; canvas drawing is in render modules; side panels are `ui_*.js`.

View file

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

View file

@ -829,6 +829,27 @@
opacity: 0.52;
}
.colony-limit-control {
margin: 8px 0 4px;
padding: 10px 11px;
border: 1px solid rgba(118, 92, 61, 0.16);
border-radius: 14px;
background: rgba(255, 251, 244, 0.56);
}
.colony-limit-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
color: rgba(68, 55, 41, 0.84);
font-size: 12px;
}
.colony-limit-head small { color: var(--muted); font-size: 10px; }
.colony-limit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
.colony-limit-grid label { display: grid; gap: 4px; min-width: 0; font-size: 11px; color: var(--muted); }
.colony-limit-grid input { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid rgba(100, 78, 52, 0.22); border-radius: 8px; padding: 6px 7px; background: rgba(255,255,255,0.78); color: var(--ink); font: inherit; }
.colony-limit-status { margin-top: 7px; color: rgba(68, 55, 41, 0.78); font-size: 10px; line-height: 1.35; }
/* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */
.tool-card.collapsed {

View file

@ -4,14 +4,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.15.164" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.15.164" />
<link rel="shortcut icon" href="favicon.ico?v=39.15.164" />
<link rel="stylesheet" href="css/base.css?v=39.15.164" />
<link rel="stylesheet" href="css/layout.css?v=39.15.164" />
<link rel="stylesheet" href="css/panel.css?v=39.15.164" />
<link rel="stylesheet" href="css/components.css?v=39.15.164" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.164" />
<link rel="icon" type="image/png" href="assets/ui/favicon.png?v=39.15.167" />
<link rel="apple-touch-icon" href="assets/ui/apple-touch-icon.png?v=39.15.167" />
<link rel="shortcut icon" href="favicon.ico?v=39.15.167" />
<link rel="stylesheet" href="css/base.css?v=39.15.167" />
<link rel="stylesheet" href="css/layout.css?v=39.15.167" />
<link rel="stylesheet" href="css/panel.css?v=39.15.167" />
<link rel="stylesheet" href="css/components.css?v=39.15.167" />
<link rel="stylesheet" href="css/mobile.css?v=39.15.167" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -107,6 +107,14 @@
<div class="temperature-control-head"><span>&#x6C17;&#x6E29;</span><strong id="temperatureValue">10&#x2103;</strong><label class="temperature-auto"><input id="temperatureAutoToggle" type="checkbox" checked> &#x81EA;&#x52D5;</label></div>
<input id="temperatureSlider" class="temperature-slider" type="range" min="-100" max="120" step="1" value="10">
</div>
<div class="colony-limit-control" aria-label="コロニー上限設定">
<div class="colony-limit-head"><span>上限設定</span><small>空欄・0は上限なし</small></div>
<div class="colony-limit-grid">
<label><span>たりない個体数</span><input id="tarinaiPopulationLimitInput" type="number" min="0" step="1" inputmode="numeric" placeholder="上限なし"></label>
<label><span>オブジェクト総数</span><input id="objectLimitInput" type="number" min="0" step="1" inputmode="numeric" placeholder="上限なし"></label>
</div>
<div id="colonyLimitStatus" class="colony-limit-status">たりない 0 / ∞ オブジェクト 0 / ∞</div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x500B;&#x4F53;&#x6570;</button><button data-chart="life">&#x6B32;&#x6C42;</button><button data-chart="objects">環境</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
@ -252,167 +260,167 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=39.15.164" defer></script>
<script src="js/event_bus.js?v=39.15.164" defer></script>
<script src="js/domain_ids.js?v=39.15.164" defer></script>
<script src="js/registry_base.js?v=39.15.164" defer></script>
<script src="js/disease_registry.js?v=39.15.164" defer></script>
<script src="js/sound_pack.js?v=39.15.164" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.164" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.164" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.164" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.164" defer></script>
<script src="js/item_food_definitions.js?v=39.15.164" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.164" defer></script>
<script src="js/item_registry.js?v=39.15.164" defer></script>
<script src="js/data.js?v=39.15.164" defer></script>
<script src="js/ground_types.js?v=39.15.164" defer></script>
<script src="js/input_mode_manager.js?v=39.15.164" defer></script>
<script src="js/math.js?v=39.15.164" defer></script>
<script src="js/geometry_helpers.js?v=39.15.164" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.164" defer></script>
<script src="js/physics_helpers.js?v=39.15.164" defer></script>
<script src="js/placement_preview_system.js?v=39.15.164" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.164" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.164" defer></script>
<script src="js/mechanical_system.js?v=39.15.164" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.164" defer></script>
<script src="js/constraint_system.js?v=39.15.164" defer></script>
<script src="js/physics_world_system.js?v=39.15.164" defer></script>
<script src="js/physics_projection_system.js?v=39.15.164" defer></script>
<script src="js/assets.js?v=39.15.164" defer></script>
<script src="js/audio.js?v=39.15.164" defer></script>
<script src="js/perf_profiler.js?v=39.15.164" defer></script>
<script src="js/display_helpers.js?v=39.15.164" defer></script>
<script src="js/render.js?v=39.15.164" defer></script>
<script src="js/sim_core.js?v=39.15.164" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.164" defer></script>
<script src="js/structures.js?v=39.15.164" defer></script>
<script src="js/item_type_catalog.js?v=39.15.164" defer></script>
<script src="js/items.js?v=39.15.164" defer></script>
<script src="js/item_type_initializers.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.164" defer></script>
<script src="js/item_update_policy.js?v=39.15.164" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.164" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.164" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.164" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.164" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.164" defer></script>
<script src="js/item_runtime.js?v=39.15.164" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.164" defer></script>
<script src="js/item_render_helpers.js?v=39.15.164" defer></script>
<script src="js/item_render_runtime.js?v=39.15.164" defer></script>
<script src="js/burn_motion_util.js?v=39.15.164" defer></script>
<script src="js/ants.js?v=39.15.164" defer></script>
<script src="js/health.js?v=39.15.164" defer></script>
<script src="js/tarinai.js?v=39.15.164" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.164" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.164" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.164" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.164" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.164" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.164" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.164" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.164" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.164" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.164" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.164" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.164" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.164" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.164" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.164" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.164" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.164" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.164" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.164" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.164" defer></script>
<script src="js/tarinai_render.js?v=39.15.164" defer></script>
<script src="js/world.js?v=39.15.164" defer></script>
<script src="js/world_view.js?v=39.15.164" defer></script>
<script src="js/family_graph.js?v=39.15.164" defer></script>
<script src="js/world_reset_presets.js?v=39.15.164" defer></script>
<script src="js/world_family_social.js?v=39.15.164" defer></script>
<script src="js/impact_core_system.js?v=39.15.164" defer></script>
<script src="js/impact_response_system.js?v=39.15.164" defer></script>
<script src="js/collision_response_system.js?v=39.15.164" defer></script>
<script src="js/world_combat_effects.js?v=39.15.164" defer></script>
<script src="js/world_environment.js?v=39.15.164" defer></script>
<script src="js/world_temperature_system.js?v=39.15.164" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.164" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.164" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.164" defer></script>
<script src="js/world_ants_system.js?v=39.15.164" defer></script>
<script src="js/weather_system.js?v=39.15.164" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.164" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.164" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.164" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.164" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.164" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.164" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.164" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.164" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.164" defer></script>
<script src="js/simulation_systems.js?v=39.15.164" defer></script>
<script src="js/system_order.js?v=39.15.164" defer></script>
<script src="js/simulation.js?v=39.15.164" defer></script>
<script src="js/colony_situation_system.js?v=39.15.164" defer></script>
<script src="js/world_update.js?v=39.15.164" defer></script>
<script src="js/world_tool_actions.js?v=39.15.164" defer></script>
<script src="js/world_placement_log.js?v=39.15.164" defer></script>
<script src="js/world_event_effects.js?v=39.15.164" defer></script>
<script src="js/command_dispatcher.js?v=39.15.164" defer></script>
<script src="js/text_catalog.js?v=39.15.164" defer></script>
<script src="js/ui.js?v=39.15.164" defer></script>
<script src="js/ui_log.js?v=39.15.164" defer></script>
<script src="js/ui_helpers.js?v=39.15.164" defer></script>
<script src="js/ui_selected.js?v=39.15.164" defer></script>
<script src="js/save_schema.js?v=39.15.164" defer></script>
<script src="js/snapshot_system.js?v=39.15.164" defer></script>
<script src="js/restore_coordinator.js?v=39.15.164" defer></script>
<script src="js/history_system.js?v=39.15.164" defer></script>
<script src="js/save_codec.js?v=39.15.164" defer></script>
<script src="js/save_storage.js?v=39.15.164" defer></script>
<script src="js/save_system.js?v=39.15.164" defer></script>
<script src="js/ui_tooltips.js?v=39.15.164" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.164" defer></script>
<script src="js/ui_ground.js?v=39.15.164" defer></script>
<script src="js/ui_tools.js?v=39.15.164" defer></script>
<script src="js/ui_input_shared.js?v=39.15.164" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.164" defer></script>
<script src="js/ui_input_touch.js?v=39.15.164" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.164" defer></script>
<script src="js/ui_bind.js?v=39.15.164" defer></script>
<script src="js/ui_charts.js?v=39.15.164" defer></script>
<script src="js/ui_family_data.js?v=39.15.164" defer></script>
<script src="js/ui_family_async.js?v=39.15.164" defer></script>
<script src="js/ui_family_layout.js?v=39.15.164" defer></script>
<script src="js/ui_family_paths.js?v=39.15.164" defer></script>
<script src="js/ui_family_render.js?v=39.15.164" defer></script>
<script src="js/main.js?v=39.15.164" defer></script>
<script src="js/debug_tools.js?v=39.15.164" defer></script>
<script src="js/version.js?v=39.15.167" defer></script>
<script src="js/event_bus.js?v=39.15.167" defer></script>
<script src="js/domain_ids.js?v=39.15.167" defer></script>
<script src="js/registry_base.js?v=39.15.167" defer></script>
<script src="js/disease_registry.js?v=39.15.167" defer></script>
<script src="js/sound_pack.js?v=39.15.167" defer></script>
<script src="js/deterministic_helpers.js?v=39.15.167" defer></script>
<script src="js/item_tool_metadata.js?v=39.15.167" defer></script>
<script src="js/item_tool_definitions.js?v=39.15.167" defer></script>
<script src="js/item_visual_definitions.js?v=39.15.167" defer></script>
<script src="js/item_food_definitions.js?v=39.15.167" defer></script>
<script src="js/item_effect_definitions.js?v=39.15.167" defer></script>
<script src="js/item_registry.js?v=39.15.167" defer></script>
<script src="js/data.js?v=39.15.167" defer></script>
<script src="js/ground_types.js?v=39.15.167" defer></script>
<script src="js/input_mode_manager.js?v=39.15.167" defer></script>
<script src="js/math.js?v=39.15.167" defer></script>
<script src="js/geometry_helpers.js?v=39.15.167" defer></script>
<script src="js/collision_footprint_system.js?v=39.15.167" defer></script>
<script src="js/physics_helpers.js?v=39.15.167" defer></script>
<script src="js/placement_preview_system.js?v=39.15.167" defer></script>
<script src="js/pin_attachment_system.js?v=39.15.167" defer></script>
<script src="js/physics_shape_editor_system.js?v=39.15.167" defer></script>
<script src="js/mechanical_system.js?v=39.15.167" defer></script>
<script src="js/mechanical_shape_bridge.js?v=39.15.167" defer></script>
<script src="js/constraint_system.js?v=39.15.167" defer></script>
<script src="js/physics_world_system.js?v=39.15.167" defer></script>
<script src="js/physics_projection_system.js?v=39.15.167" defer></script>
<script src="js/assets.js?v=39.15.167" defer></script>
<script src="js/audio.js?v=39.15.167" defer></script>
<script src="js/perf_profiler.js?v=39.15.167" defer></script>
<script src="js/display_helpers.js?v=39.15.167" defer></script>
<script src="js/render.js?v=39.15.167" defer></script>
<script src="js/sim_core.js?v=39.15.167" defer></script>
<script src="js/tarinai_seed_factory.js?v=39.15.167" defer></script>
<script src="js/structures.js?v=39.15.167" defer></script>
<script src="js/item_type_catalog.js?v=39.15.167" defer></script>
<script src="js/items.js?v=39.15.167" defer></script>
<script src="js/item_type_initializers.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_support.js?v=39.15.167" defer></script>
<script src="js/item_update_policy.js?v=39.15.167" defer></script>
<script src="js/fire_runtime_system.js?v=39.15.167" defer></script>
<script src="js/robot_cleaner_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_tool_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_ball_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_duplicator_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_pin_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_zunchi_system.js?v=39.15.167" defer></script>
<script src="js/item_dynamic_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_decay_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_growth_system.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_frame.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_decay.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_dynamic.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_step_growth.js?v=39.15.167" defer></script>
<script src="js/update_step_pipeline_runner.js?v=39.15.167" defer></script>
<script src="js/item_lifecycle_pipeline.js?v=39.15.167" defer></script>
<script src="js/item_runtime.js?v=39.15.167" defer></script>
<script src="js/structure_lifecycle.js?v=39.15.167" defer></script>
<script src="js/item_render_helpers.js?v=39.15.167" defer></script>
<script src="js/item_render_runtime.js?v=39.15.167" defer></script>
<script src="js/burn_motion_util.js?v=39.15.167" defer></script>
<script src="js/ants.js?v=39.15.167" defer></script>
<script src="js/health.js?v=39.15.167" defer></script>
<script src="js/tarinai.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_spec.js?v=39.15.167" defer></script>
<script src="js/tarinai_behavior_state.js?v=39.15.167" defer></script>
<script src="js/tarinai_identity_social.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_state.js?v=39.15.167" defer></script>
<script src="js/tarinai_disease_nest.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_effects.js?v=39.15.167" defer></script>
<script src="js/tarinai_needs_core.js?v=39.15.167" defer></script>
<script src="js/tarinai_behavior_text.js?v=39.15.167" defer></script>
<script src="js/tarinai_forced_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_nest_sleep_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_targeting.js?v=39.15.167" defer></script>
<script src="js/tarinai_consumable_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_social_action_runtime.js?v=39.15.167" defer></script>
<script src="js/tarinai_building_behavior.js?v=39.15.167" defer></script>
<script src="js/tarinai_action_definitions.js?v=39.15.167" defer></script>
<script src="js/tarinai_needs_items.js?v=39.15.167" defer></script>
<script src="js/tarinai_food_prototype_mixin.js?v=39.15.167" defer></script>
<script src="js/tarinai_need_planner_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_interaction_context.js?v=39.15.167" defer></script>
<script src="js/tarinai_food_interaction_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_contact_item_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_item_interaction_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_sunbath_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_cursor_care_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_local_environment_system.js?v=39.15.167" defer></script>
<script src="js/tarinai_social_move_life.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_frame.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_ai.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_environment.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_movement.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_step_health.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_pipeline.js?v=39.15.167" defer></script>
<script src="js/tarinai_runtime.js?v=39.15.167" defer></script>
<script src="js/tarinai_render.js?v=39.15.167" defer></script>
<script src="js/world.js?v=39.15.167" defer></script>
<script src="js/world_view.js?v=39.15.167" defer></script>
<script src="js/family_graph.js?v=39.15.167" defer></script>
<script src="js/world_reset_presets.js?v=39.15.167" defer></script>
<script src="js/world_family_social.js?v=39.15.167" defer></script>
<script src="js/impact_core_system.js?v=39.15.167" defer></script>
<script src="js/impact_response_system.js?v=39.15.167" defer></script>
<script src="js/collision_response_system.js?v=39.15.167" defer></script>
<script src="js/world_combat_effects.js?v=39.15.167" defer></script>
<script src="js/world_environment.js?v=39.15.167" defer></script>
<script src="js/world_temperature_system.js?v=39.15.167" defer></script>
<script src="js/world_pathfinding_system.js?v=39.15.167" defer></script>
<script src="js/world_grass_placement_system.js?v=39.15.167" defer></script>
<script src="js/world_spatial_budget.js?v=39.15.167" defer></script>
<script src="js/world_ants_system.js?v=39.15.167" defer></script>
<script src="js/weather_system.js?v=39.15.167" defer></script>
<script src="js/item_update_scheduler.js?v=39.15.167" defer></script>
<script src="js/simulation_runtime_helpers.js?v=39.15.167" defer></script>
<script src="js/tarinai_update_policy.js?v=39.15.167" defer></script>
<script src="js/simulation_environment_system.js?v=39.15.167" defer></script>
<script src="js/simulation_item_ant_system.js?v=39.15.167" defer></script>
<script src="js/simulation_effects_system.js?v=39.15.167" defer></script>
<script src="js/simulation_creature_system.js?v=39.15.167" defer></script>
<script src="js/simulation_maintenance_system.js?v=39.15.167" defer></script>
<script src="js/simulation_ambient_system.js?v=39.15.167" defer></script>
<script src="js/simulation_systems.js?v=39.15.167" defer></script>
<script src="js/system_order.js?v=39.15.167" defer></script>
<script src="js/simulation.js?v=39.15.167" defer></script>
<script src="js/colony_situation_system.js?v=39.15.167" defer></script>
<script src="js/world_update.js?v=39.15.167" defer></script>
<script src="js/world_tool_actions.js?v=39.15.167" defer></script>
<script src="js/world_placement_log.js?v=39.15.167" defer></script>
<script src="js/world_event_effects.js?v=39.15.167" defer></script>
<script src="js/command_dispatcher.js?v=39.15.167" defer></script>
<script src="js/text_catalog.js?v=39.15.167" defer></script>
<script src="js/ui.js?v=39.15.167" defer></script>
<script src="js/ui_log.js?v=39.15.167" defer></script>
<script src="js/ui_helpers.js?v=39.15.167" defer></script>
<script src="js/ui_selected.js?v=39.15.167" defer></script>
<script src="js/save_schema.js?v=39.15.167" defer></script>
<script src="js/snapshot_system.js?v=39.15.167" defer></script>
<script src="js/restore_coordinator.js?v=39.15.167" defer></script>
<script src="js/history_system.js?v=39.15.167" defer></script>
<script src="js/save_codec.js?v=39.15.167" defer></script>
<script src="js/save_storage.js?v=39.15.167" defer></script>
<script src="js/save_system.js?v=39.15.167" defer></script>
<script src="js/ui_tooltips.js?v=39.15.167" defer></script>
<script src="js/ui_layout_dialogs.js?v=39.15.167" defer></script>
<script src="js/ui_ground.js?v=39.15.167" defer></script>
<script src="js/ui_tools.js?v=39.15.167" defer></script>
<script src="js/ui_input_shared.js?v=39.15.167" defer></script>
<script src="js/ui_pointer_action_system.js?v=39.15.167" defer></script>
<script src="js/ui_input_touch.js?v=39.15.167" defer></script>
<script src="js/ui_input_mouse.js?v=39.15.167" defer></script>
<script src="js/ui_bind.js?v=39.15.167" defer></script>
<script src="js/ui_charts.js?v=39.15.167" defer></script>
<script src="js/ui_family_data.js?v=39.15.167" defer></script>
<script src="js/ui_family_async.js?v=39.15.167" defer></script>
<script src="js/ui_family_layout.js?v=39.15.167" defer></script>
<script src="js/ui_family_paths.js?v=39.15.167" defer></script>
<script src="js/ui_family_render.js?v=39.15.167" defer></script>
<script src="js/main.js?v=39.15.167" defer></script>
<script src="js/debug_tools.js?v=39.15.167" defer></script>
</body>
</html>

View file

@ -414,7 +414,7 @@ class AntActor {
audio.antDie?.();
const corpse = new Item("ant_corpse", this.x, this.y);
corpse.amount = 24;
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
this.world.addItem?.(corpse, "ant-corpse");
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
if (home) syncAntNestCount(this.world, home);
this.world.ensureItemBuckets?.("ant-corpse");
@ -439,8 +439,9 @@ class AntActor {
base.antCount = ANT_NEST_START_COUNT;
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
if (!force && this.world.placementBlocked?.(base)) return null;
const addedNest = this.world.addItem?.(base, "ant-nest-built") || null;
if (!addedNest) return null;
audio.antNest?.();
this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base);
this.world.ensureSpatial?.("ant-nest-built");
this.world.drawListDirty = true;
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");

View file

@ -125,6 +125,17 @@
}
global.TarinaiCollisionFootprints = Object.freeze({
// Mechanical-body narrowphase consumes the same SAT primitives as
// placement/hit testing. Keep these delegates public so collision-enabled
// rotators, reciprocators, and poison blocks can physically interact.
rectLocalPoint,
rectWorldVector,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectsOverlap,
rectCircleOverlap,
isSoftPlacementIgnoredType,
itemFootprintRadius,

View file

@ -275,23 +275,36 @@
}
function resolveTarinaiHighSpeedCollisions(worldRef, dt) {
function highSpeedThreshold() {
return PHYSICAL_DAMAGE_SPEED_THRESHOLD;
}
function resolveTarinaiHighSpeedCollisions(worldRef, dt, opts = {}) {
if (!worldRef) return undefined;
const threshold = PHYSICAL_DAMAGE_SPEED_THRESHOLD;
const threshold = Math.max(1, Number(opts.threshold || PHYSICAL_DAMAGE_SPEED_THRESHOLD) || PHYSICAL_DAMAGE_SPEED_THRESHOLD);
const now = worldRef.time || 0;
if (!worldRef.tarinaiCollisionMemo) worldRef.tarinaiCollisionMemo = new Map();
const liveSource = worldRef.tarinai || [];
const live = [];
const providedFastSources = Array.isArray(opts.fastSources);
const liveSource = providedFastSources ? opts.fastSources : (worldRef.tarinai || []);
const fastSources = [];
for (const t of liveSource) {
if (t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t)) live.push(t);
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox(t)) continue;
if (Math.hypot(t.vx || 0, t.vy || 0) >= threshold) fastSources.push(t);
}
if (!fastSources.length) return;
const live = providedFastSources ? null : [];
if (!providedFastSources) {
for (const t of worldRef.tarinai || []) {
if (t && !t.dead && !worldRef.isTarinaiHiddenInNestBox(t)) live.push(t);
}
if (live.length < 2) return;
live.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")));
}
if (live.length < 2) return;
live.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")));
const candidates = [];
const maxCandidates = Math.max(24, Math.min(320, live.length * 6));
const maxCandidates = Math.max(24, Math.min(320, fastSources.length * 12));
const seenPairs = new Set();
for (let i = 0; i < live.length; i += 1) {
const a = live[i];
for (let i = 0; i < fastSources.length; i += 1) {
const a = fastSources[i];
const avx = a.vx || 0;
const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy);
@ -306,7 +319,7 @@
const queryX = ax0 + asx * 0.5;
const queryY = ay0 + asy * 0.5;
const queryRadius = Math.max(140, (a.radius || 22) * 1.6 + moveDist + threshold * Math.max(0.016, Number(dt || 0.016) || 0.016) + 96);
const nearby = typeof worldRef.nearbyTarinai === "function" ? Array.from(worldRef.nearbyTarinai(queryX, queryY, queryRadius, true) || []) : live;
const nearby = typeof worldRef.nearbyTarinai === "function" ? (worldRef.nearbyTarinai(queryX, queryY, queryRadius, true) || []) : (live || worldRef.tarinai || []);
for (const b of nearby) {
if (!b || b === a || b.dead || worldRef.isTarinaiHiddenInNestBox(b)) continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
@ -410,6 +423,7 @@
global.TarinaiCollisionResponseSystem = Object.freeze({
highSpeedThreshold,
resolveBallBallCollisions,
resolveBallInteractions,
resolveTarinaiHighSpeedCollisions,

View file

@ -446,11 +446,11 @@
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
const sum = ia + ib || 1;
const step = Math.max(0.012, Math.min(0.05, Number(dt || 0.016) || 0.016));
const compliance = mode === "rod" ? 0.085 / (step * 60) : 0.46 / (step * 60);
const compliance = mode === "rod" ? 0.085 / (step * 60) : 0;
const target = delta / (1 + compliance);
const cap = mode === "rod"
? Math.max(6.0, Math.min(46, length * maxFraction))
: Math.max(1.5, length * maxFraction);
: Math.max(12, length * maxFraction);
const corr = clamp(target, -cap, cap);
let changed = false;
changed = moveEndpointObject(pa.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), ep(item, 0), dt, strength, 0, mode) || changed;
@ -459,7 +459,15 @@
};
let changed = false;
if (mode === "rope") {
changed = solveOnce(a, b, 0.58, 0.044) || changed;
// A rope may go slack, but must not become longer than its recorded length.
// Re-read anchors between projections because the endpoint objects move.
const iterations = 8;
for (let i = 0; i < iterations; i += 1) {
const aa = endpointWorld(ep(item, 0), item.world || null) || a;
const bb = endpointWorld(ep(item, 1), item.world || null) || b;
if (distXY(aa.x, aa.y, bb.x, bb.y) <= length + 0.25) break;
changed = solveOnce(aa, bb, 1.0, 0.35) || changed;
}
} else {
const iterations = 5;
for (let i = 0; i < iterations; i += 1) {
@ -738,6 +746,21 @@
}
}
}
for (let iter = 0; iter < 4; iter += 1) {
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
for (let i = 0; i < ps.length - 1; i += 1) {
const p1 = ps[i], p2 = ps[i + 1];
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const d = Math.max(0.001, Math.hypot(dx, dy));
if (d <= rest + 0.02) continue;
const corrX = dx * ((d - rest) / d);
const corrY = dy * ((d - rest) / d);
if (i === 0) { p2.x -= corrX; p2.y -= corrY; }
else if (i + 1 === ps.length - 1) { p1.x += corrX; p1.y += corrY; }
else { p1.x += corrX * 0.5; p1.y += corrY * 0.5; p2.x -= corrX * 0.5; p2.y -= corrY * 0.5; }
}
}
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
const mid = ps[Math.floor(ps.length * 0.5)] || ps[0];

View file

@ -549,9 +549,10 @@ function updateFirecracker(item, dt, worldRef) {
fire.flameLife = Math.max(1.2, Number(opts.life || fire.flameLife || 7.5));
fire.vx = 0;
fire.vy = 0;
worldRef.addItem?.(fire, "spawn-fire", { terrain: false });
const addedFire = worldRef.addItem?.(fire, "spawn-fire", { terrain: false }) || null;
if (!addedFire) return null;
worldRef.drawListDirty = true;
return fire;
return addedFire;
}
function scatterFireFrom(item, worldRef) {

View file

@ -516,8 +516,9 @@
if (!Number.isFinite(Number(drop.vx))) drop.vx = vx;
if (!Number.isFinite(Number(drop.vy))) drop.vy = vy;
if (!Number.isFinite(Number(drop.splashFlightTimer))) drop.splashFlightTimer = flight;
if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1;
else { worldRef.items?.push?.(drop); spawned += 1; }
const addedDrop = worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false }) || null;
if (!addedDrop) continue;
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),

View file

@ -42,6 +42,7 @@
const STICKY_BOMB_PANIC_TEXT = "粘着ボムが付いてパニックになっている。";
const STICKY_BOMB_FUSE_SECONDS = 12.0;
const STICKY_BOMB_BLAST_SCALE = 0.68; // identical to TOOL_SIZE_SCALES.small
function tarinaiById(worldRef, id = "") {
return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null;
@ -96,7 +97,7 @@
if (!item || item.dead) return false;
item.stickyBombCarrierId = "";
item.stickyBombTransferCooldown = 0;
item.blastScale = Number(item.blastScale || 1) || 1;
item.blastScale = STICKY_BOMB_BLAST_SCALE;
if (worldRef?.explodeFirecracker) {
return Boolean(worldRef.explodeFirecracker(item));
}

View file

@ -92,12 +92,7 @@ function duplicatorApplyStoredPin(duplicator, worldRef) {
pin.noStickTargetId = "";
pin.noStickTargetUntil = 0;
const added = worldRef?.addItem?.(pin, "duplicator-pin", { terrain: false }) || null;
if (!added && worldRef?.items) {
pin.world = worldRef;
worldRef.items.push(pin);
worldRef.markItemBucketsDirty?.("duplicator-pin");
worldRef.markSpatialDirty?.("duplicator-pin");
}
if (!added) return false;
const ok = global.TarinaiItemDynamicPinSystem.attach(pin, t, worldRef, distXY(dish.x, dish.y, t.x, t.y));
if (!ok) {
pin.amount = 0;

View file

@ -1152,8 +1152,8 @@ draw(ctx, t, lighting = null) {
const angle = itemAngleFor(this);
const pb = window.TarinaiPhysicsBodySystem;
const enabled = pb?.scalar?.(this, "solid", true) !== false;
const segments = (pb?.segments?.(this) || [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 14) || 14) || 14));
const segments = (pb?.segments?.(this) || [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]);
const thick = Math.max(4, Math.min(34, Number(pb?.scalar?.(this, "thickness", 11) || 11) || 11));
ctx.save();
ctx.rotate(angle);
ctx.lineCap = "round";

View file

@ -58,11 +58,11 @@ const TOOL_DEFINITIONS = Object.freeze({
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "oriented_rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
glass_wall: { id: "glass_wall", itemType: "glass_wall", label: "\u30ac\u30e9\u30b9\u58c1", placeable: true, scalable: true, radius: 42, amount: 999, iconText: "\u25ad", collisionShape: "oriented_rect", tooltip: "\u900f\u660e\u306a\u58c1\u3002\u305f\u308a\u306a\u3044\u306f\u901a\u308c\u306a\u3044\u304c\u3001\u898b\u3048\u3066\u3044\u308b\u5bfe\u8c61\u3078\u306f\u8fc2\u56de\u305b\u305a\u306b\u5411\u304b\u3046\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence: { id: "bounce_fence", itemType: "bounce_fence", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u5f37\u70c8\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" },
rotator: { id: "rotator", itemType: "rotator", label: "\u56de\u8ee2\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21bb", collisionShape: "custom_rotator", tooltip: "\u56de\u8ee2\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
poison_block: { id: "poison_block", itemType: "poison_block", label: "\u6bd2\u30d6\u30ed\u30c3\u30af", placeable: true, scalable: false, radius: 44, amount: 999, iconText: "\u2620", collisionShape: "custom_poison_block", tooltip: "\u5f53\u305f\u308b\u3068\u76ae\u819a\u304c\u75fa\u308c\u308b\u3002\u30af\u30ea\u30c3\u30af\u3067\u5f62\u72b6\u304c\u5909\u66f4\u3067\u304d\u308b\u3002" },
reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "\u5f80\u5fa9\u4f53", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "\u21c4", collisionShape: "custom_reciprocator", tooltip: "\u5f80\u5fa9\u3059\u308b\u6a5f\u68b0\u3002\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u5f62\u72b6\u3084\u901f\u5ea6\u3092\u8a2d\u5b9a\u3067\u304d\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34\u6ef4", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "\ud83d\udca7", tooltip: "\u96e8\u306e\u65e5\u306b\u3082\u73fe\u308c\u308b\u6c34\u6ef4\u3002\u305f\u308a\u306a\u3044\u304c\u98f2\u3081\u308b\u3002\u8907\u88fd\u6a5f\u306b\u3082\u30bb\u30c3\u30c8\u3067\u304d\u308b\u3002" },
trace: { id: "trace", itemType: "trace", label: "\u6b7b\u9ab8", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
@ -91,7 +91,7 @@ function isFenceItemType(type = "") { return FENCE_ITEM_TYPES.has(String(type ||
function isRotatableItemType(type = "") { return ROTATABLE_ITEM_TYPES.has(String(type || "")); }
function defaultItemAngle(type = "") {
const t = String(type || "");
if (t === "fence_v" || t === "bounce_fence_v") return Math.PI / 2;
if (t === "fence_v" || t === "bounce_fence_v" || t === "reciprocator") return Math.PI / 2;
return 0;
}
function normalizedItemAngle(angle = 0, fallback = 0) {

View file

@ -114,6 +114,7 @@ function initializeItemTypeState(item, type, x, y) {
}
if (type === "sticky_bomb") {
item.fuseTimer = 12.0;
item.blastScale = 0.68;
item.fuseMax = 12.0;
item.vx = rand(-8, 8);
item.vy = rand(-8, 8);
@ -170,7 +171,7 @@ function initializeItemTypeState(item, type, x, y) {
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: false, speed: 0, direction: 1 },
rail: null,
shape: { model: "segments", thickness: 14, segments: [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]], version: 0 },
shape: { model: "segments", thickness: 11, segments: [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]], version: 0 },
collision: { solid: true, hazard: true },
hazard: { kind: "poison", damage: 7 },
sleep: { awakeUntil: 0 },

View file

@ -181,7 +181,7 @@
if (out.length >= 128) break;
}
if (!out.length) {
if (item?.type === "poison_block") out.push([-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]);
if (item?.type === "poison_block") out.push([-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]);
else out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : []));
}
return out;
@ -236,7 +236,7 @@
const cache = geomCache(item);
const shape = bodyShape(item);
const raw = Array.isArray(shape?.segments) ? shape.segments : [];
const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 14 : 12);
const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 11 : 12);
const key = `${item?.type || ""}|${shapeVersion(item)}|${raw.length}|${rawSegmentSignature(raw)}|${thickKey || ""}`;
if (cache?.localKey === key && cache.localSegments) return cache.localSegments;
const normalized = normalizeRawSegments(item);
@ -248,7 +248,7 @@
return out;
}
function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 14 : 12)))); }
function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 11 : 12)))); }
function extent(item) {
const cache = geomCache(item);

View file

@ -121,7 +121,7 @@
}
function bounceCircleOffRect(obj, rect, radius, restitution = 1.08, worldRef = null, opts = {}) {
function bounceCircleOffRect(obj, rect, radius, restitution = 2.40, worldRef = null, opts = {}) {
if (!obj || !rect) return false;
if (rect.oriented) return circleBounceOffOrientedRect(obj, rect, radius, restitution, worldRef, opts);
const hitRadius = Math.max(2, Number(radius || obj.r || obj.radius || 12) || 12);
@ -194,9 +194,9 @@
const rects = worldRef.nearbySolidObstacleRects(item.x, item.y, searchRadius, { include: it => it?.type === "bounce_fence" || it?.type === "bounce_fence_v", exclude: item, maxChecks: 16 }) || [];
for (const rect of rects) {
if (!rect?.bounce) continue;
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 1.08), worldRef, opts)) {
if (bounceCircleOffRect(item, rect, radius, Number(rect.restitution || 2.40), worldRef, opts)) {
bounced = true;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 760;
const maxSpeed = opts.bounceFenceMaxSpeed ?? 1180;
item.vx = clamp(item.vx || 0, -maxSpeed, maxSpeed);
item.vy = clamp(item.vy || 0, -maxSpeed, maxSpeed);
const now = worldRef.time || 0;

View file

@ -18,18 +18,33 @@
undo: "\u21B6 \u4E00\u3064\u623B\u3059",
clear: "\uD83D\uDDD1 \u5168\u6D88\u3057",
});
const DEFAULT_GRID_SIZE = 20;
function normalizedGridSize(value = DEFAULT_GRID_SIZE) {
return Math.max(10, Math.min(80, Math.round(Number(value || DEFAULT_GRID_SIZE) || DEFAULT_GRID_SIZE)));
}
function snapValue(value, gridSize = DEFAULT_GRID_SIZE) {
const grid = normalizedGridSize(gridSize);
return Math.round((Number(value) || 0) / grid) * grid;
}
function snapPoint(point, gridSize = DEFAULT_GRID_SIZE) {
return { x: snapValue(point?.x, gridSize), y: snapValue(point?.y, gridSize) };
}
function templateSegments(name) {
if (name === "bar") return [[-130, 0, 130, 0]];
if (name === "cross") return [[-118, 0, 118, 0], [0, -90, 0, 90]];
if (name === "bar") return [[-120, 0, 120, 0]];
if (name === "cross") return [[-120, 0, 120, 0], [0, -80, 0, 80]];
if (name === "circle") {
const segments = [];
const n = 18, r = 108;
for (let i = 0; i < n; i += 1) {
const a = i / n * Math.PI * 2;
const b = (i + 1) / n * Math.PI * 2;
segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]);
}
return segments;
// 24-sided lattice circle. More vertices preserve a round silhouette while
// every endpoint remains on the shared 20px editor grid.
const points = [
[120, 0], [120, 40], [100, 60], [80, 80], [60, 100], [40, 120],
[0, 120], [-40, 120], [-60, 100], [-80, 80], [-100, 60], [-120, 40],
[-120, 0], [-120, -40], [-100, -60], [-80, -80], [-60, -100], [-40, -120],
[0, -120], [40, -120], [60, -100], [80, -80], [100, -60], [120, -40],
];
return points.map((point, index) => {
const next = points[(index + 1) % points.length];
return [point[0], point[1], next[0], next[1]];
});
}
return null;
}
@ -65,23 +80,32 @@
function cleanSegments(segments = [], opts = {}) {
const limit = Math.max(1, Number(opts.limit || 96) || 96);
const bound = Math.max(1, Number(opts.bound || 420) || 420);
const minLength = Math.max(0, Number(opts.minLength || 4) || 4);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
const gridSize = normalizedGridSize(opts.gridSize);
const snap = opts.snap !== false;
const minLength = Math.max(gridSize, Number(opts.minLength || gridSize) || gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
const coord = value => {
const raw = snap ? snapValue(value, gridSize) : (Number(value) || 0);
return Math.max(-bound, Math.min(bound, raw));
};
const out = [];
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = Math.max(-bound, Math.min(bound, Number(seg[0]) || 0));
const y1 = Math.max(-bound, Math.min(bound, Number(seg[1]) || 0));
const x2 = Math.max(-bound, Math.min(bound, Number(seg[2]) || 0));
const y2 = Math.max(-bound, Math.min(bound, Number(seg[3]) || 0));
const x1 = coord(seg[0]);
const y1 = coord(seg[1]);
const x2 = coord(seg[2]);
const y2 = coord(seg[3]);
if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]);
if (out.length >= limit) break;
}
if (!out.length) out.push(fallback.slice(0, 4));
if (!out.length) {
const snappedFallback = fallback.slice(0, 4).map(value => coord(value));
out.push(snappedFallback);
}
return out;
}
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0, gridSize = DEFAULT_GRID_SIZE) {
if (!ctx || !canvas) return false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
@ -90,8 +114,9 @@
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
const grid = normalizedGridSize(gridSize);
for (let x = -cx; x <= cx; x += grid) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += grid) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
@ -173,19 +198,20 @@
const prefix = opts.prefix || "rotator";
const cx = opts.cx ?? canvas.width / 2;
const cy = opts.cy ?? canvas.height / 2;
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-78, 0, 78, 0];
const gridSize = normalizedGridSize(opts.gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
let mode = "line";
let segments = (opts.segments || []).map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4);
let segments = cleanSegments(opts.segments || [], { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true });
let draft = null;
let drawing = false;
const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12);
const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle;
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength }); };
const toLocal = (e) => canvasLocalPoint(canvas, e, cx, cy);
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); };
const toLocal = (e) => snapPoint(canvasLocalPoint(canvas, e, cx, cy), gridSize);
const draw = () => {
drawEditorBase(ctx, canvas, cx, cy);
drawEditorBase(ctx, canvas, cx, cy, gridSize);
if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() });
const lw = lineWidth();
drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" });
@ -240,7 +266,7 @@
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
if (Math.hypot(p.x - lx, p.y - ly) >= gridSize) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
@ -257,7 +283,7 @@
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= gridSize) segments.push(seg);
}
draft = null;
clean();
@ -281,7 +307,7 @@
draw,
getSegments: () => segments.map(seg => seg.slice(0, 4)),
setMode,
setSegments(next = []) { segments = next.map(seg => Array.isArray(seg) ? seg.slice(0, 4) : seg).filter(seg => Array.isArray(seg) && seg.length >= 4); draft = null; draw(); },
setSegments(next = []) { segments = cleanSegments(next, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); draft = null; draw(); },
};
}
@ -289,5 +315,9 @@
decorateToolbar,
createSegmentEditorController,
syncInputPair,
snapPoint,
cleanSegments,
templateSegments,
gridSize: DEFAULT_GRID_SIZE,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -29,7 +29,7 @@
function isPhysicsType(typeOrItem) { return isBodyType(typeOrItem) || isConstraintType(typeOrItem); }
function defaultSegments(type) {
if (type === "poison_block") return [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]];
if (type === "poison_block") return [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]];
if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]];
return [[-78, 0, 78, 0]];
}
@ -63,7 +63,7 @@
velocity: { x: 0, y: 0, angular: 0, linear: 0 },
motor: { powered: type !== "poison_block", speed: type === "rotator" ? Math.PI * 0.65 : (type === "reciprocator" ? 92 : 0), direction: 1 },
rail: type === "reciprocator" ? { axisAngle: angle, travel: 150, phase: 0, anchorX: x, anchorY: y } : null,
shape: { model: "segments", thickness: type === "poison_block" ? 14 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
shape: { model: "segments", thickness: type === "poison_block" ? 11 : 12, segments: cloneSegments(null, defaultSegments(type)), version: 0 },
collision: { solid: true, hazard: type === "poison_block" },
hazard: type === "poison_block" ? { kind: "poison", damage: 7 } : null,
sleep: { awakeUntil: 0 },
@ -191,12 +191,12 @@
b.velocity = b.velocity || {};
b.motor = b.motor || {};
b.rail = b.rail || (item.type === "reciprocator" ? { axisAngle: num(item.angle), travel: 150, phase: 0, anchorX: num(item.x), anchorY: num(item.y) } : null);
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 14 : 12, segments: defaultSegments(item.type), version: 0 };
b.shape = b.shape || { model: "segments", thickness: item.type === "poison_block" ? 11 : 12, segments: defaultSegments(item.type), version: 0 };
b.collision = b.collision || { solid: true, hazard: item.type === "poison_block" };
b.sleep = b.sleep || { awakeUntil: 0 };
if (item.type === "poison_block") b.hazard = b.hazard || { kind: "poison", damage: 7 };
switch (key) {
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 14 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break;
case "thickness": b.shape.thickness = Math.max(4, Math.min(34, num(value, item.type === "poison_block" ? 11 : 12))); b.shape.version = (Number(b.shape.version || 0) || 0) + 1; break;
case "motorOn": b.motor.powered = item.type === "poison_block" ? false : !!value; break;
case "motorSpeed": b.motor.speed = Math.max(0, num(value)); break;
case "spin": b.velocity.angular = num(value); break;
@ -466,7 +466,7 @@
body.velocity = { x: num(velArr[0]) / 10, y: num(velArr[1]) / 10, angular: num(velArr[2]) / 1000, linear: num(velArr[3]) / 10 };
body.motor = { powered: bool(motorArr[0], true), speed: num(motorArr[1]) / 10, direction: Math.sign(num(motorArr[2], 1)) || 1 };
body.rail = railArr ? { axisAngle: num(railArr[0]) / 1000, travel: Math.max(24, num(railArr[1], 1500) / 10), phase: clamp(num(railArr[2]) / 1000, -1, 1), anchorX: num(railArr[3], item.x), anchorY: num(railArr[4], item.y) } : null;
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 140 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.shape = { model: "segments", thickness: Math.max(4, Math.min(34, num(shapeArr[0], item.type === "poison_block" ? 110 : 120) / 10)), segments: cloneSegments(shapeArr[1], defaultSegments(item.type)), version: 0 };
body.collision = { solid: collArr[0] !== 0, hazard: collArr[1] !== 0 };
body.hazard = hazardArr ? { kind: hazardArr[0] || "poison", damage: Math.max(1, num(hazardArr[1], 70) / 10) } : null;
body.mass = Math.max(0.2, num(p.mass, Math.round(num(body.mass, 1) * 10)) / 10);

View file

@ -60,7 +60,7 @@
pb?.setScalar?.(item, "thickness", 12, "preview");
pb?.setSegments?.(item, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
} else if (item.type === "poison_block") {
pb?.setScalar?.(item, "thickness", 14, "preview");
pb?.setScalar?.(item, "thickness", 11, "preview");
item.world = worldRef || null;
} else if (item.type === "reciprocator") {
pb?.setScalar?.(item, "railOn", true, "preview");

View file

@ -361,23 +361,29 @@
if (valuesDiffer(w[6], 1)) mask |= 2;
if (valuesDiffer(w[7], 0)) mask |= 4;
if (valuesDiffer(w[9], 0)) mask |= 8;
writeBitFields(writer, [mask], [4]);
if (valuesDiffer(w[10], 0)) mask |= 16;
if (valuesDiffer(w[11], 0)) mask |= 32;
writeBitFields(writer, [mask], [6]);
if (mask & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative
if (mask & 2) writer.u(w[6] || 1); // generation
if (mask & 4) writer.u(w[7] || 0); // dead count
writeSaveString(writer, w[8] || ""); // worldSeed
if (mask & 8) writer.u(w[9] || 0); // birthSerial
if (mask & 16) writer.u(w[10] || 0); // tarinai population limit
if (mask & 32) writer.u(w[11] || 0); // object limit
}
function readWorld(reader) {
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
const tick = reader.u();
const [mask] = readBitFields(reader, [4]);
const [mask] = readBitFields(reader, [6]);
const lastBirthAt = (mask & 1) ? reader.s() : -9990;
const generation = (mask & 2) ? reader.u() : 1;
const deadCount = (mask & 4) ? reader.u() : 0;
const seed = readSaveString(reader);
const birthSerial = (mask & 8) ? reader.u() : 0;
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial];
const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0;
const objectLimit = (mask & 32) ? reader.u() : 0;
return [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
}
function seedPersonalityArray(seed = "", flags = 0) {

View file

@ -8,8 +8,8 @@
if (!ids) throw new Error("TarinaiDomainIds must be loaded before save_schema.js");
if (!itemCatalog) throw new Error("TarinaiItemTypeCatalog must be loaded before save_schema.js");
const SNAPSHOT_VERSION = 22;
const BINARY_SCHEMA_VERSION = 27;
const SNAPSHOT_VERSION = 23;
const BINARY_SCHEMA_VERSION = 28;
function moodValue(index, fallback = "relaxed") {
return ids.canonicalMoodId(ids.enumValue(ids.MOOD_IDS, index, fallback));

View file

@ -4,9 +4,10 @@
// Owns low-frequency ambient Tarinai/grass/rain-water creation.
(function (global) {
function spawnAmbientTarinai(worldRef, dt) {
if (worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
if ((worldRef.canAddTarinai?.(1) ?? true) && worldRef.tarinai.length > 0 && worldRef.tarinai.length <= 10 && worldRef.time - (worldRef.lastBirthAt || -999) > 12 && Math.random() < dt * 0.022) {
const spot = worldRef.edgeSpawnPoint(null, null, 42, true);
const t = worldRef.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, entryTimer: 2.0 });
if (!t) return;
t.wanderAngle = spot.angle;
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
worldRef.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
@ -27,8 +28,7 @@
if (spot) {
const sprout = new Item("grass", spot.x, spot.y);
global.TarinaiGrass.setStage(sprout, 0, { force: true });
if (worldRef.addItem) worldRef.addItem(sprout, "grass-spawn");
else worldRef.items.push(sprout);
worldRef.addItem?.(sprout, "grass-spawn");
}
}
}
@ -39,7 +39,7 @@
// share the rain overlay renderer.
if (global.TarinaiWeatherSystem.shouldDropRainWater(worldRef, dt)) {
const drop = global.TarinaiWeatherSystem.createRainWaterItem(worldRef);
worldRef.addItem?.(drop, "rain-water") || worldRef.items.push(drop);
worldRef.addItem?.(drop, "rain-water");
}
}

View file

@ -119,17 +119,23 @@
worldRef.limitBallChasers();
worldRef.resolveBallInteractions(dt);
if (endBallInteractions) endBallInteractions();
const hasFastTarinai = (worldRef.tarinai || []).some(t => {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) return false;
return Math.hypot(t.vx || 0, t.vy || 0) >= 150;
});
const collisionInterval = hasFastTarinai ? 0 : 0.55;
if (hasFastTarinai || now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + collisionInterval;
let fastTarinai = null;
const collisionSpeedThreshold = global.TarinaiCollisionResponseSystem?.highSpeedThreshold?.() || 165;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (Math.hypot(t.vx || 0, t.vy || 0) < collisionSpeedThreshold) continue;
if (!fastTarinai) fastTarinai = [];
fastTarinai.push(t);
}
if (fastTarinai?.length) {
worldRef.nextTarinaiCollisionCheckAt = now;
worldRef.workStats && (worldRef.workStats.collisionRuns = (worldRef.workStats.collisionRuns || 0) + 1);
const endCollision = global.TarinaiPerf.begin("update.tarinai.collisions") || null;
try { worldRef.resolveTarinaiHighSpeedCollisions(dt); }
try { worldRef.resolveTarinaiHighSpeedCollisions(dt, { fastSources: fastTarinai, threshold: collisionSpeedThreshold }); }
finally { if (endCollision) endCollision(); }
} else if (now >= (worldRef.nextTarinaiCollisionCheckAt || 0)) {
worldRef.nextTarinaiCollisionCheckAt = now + 0.55;
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
} else {
worldRef.workStats && (worldRef.workStats.collisionSkips = (worldRef.workStats.collisionSkips || 0) + 1);
}

View file

@ -147,6 +147,8 @@
q(worldRef?.deadCount, 1, 0),
worldRef?.worldSeed || global.TarinaiSeedFactory.ensureWorldSeed(worldRef) || "",
q(worldRef?.birthSerial, 1, 0),
q(worldRef?.tarinaiPopulationLimit, 1, 0),
q(worldRef?.objectLimit, 1, 0),
];
}
function applyCompactWorld(worldRef, arr = []) {
@ -157,6 +159,8 @@
worldRef.weather = enumValue(WEATHER_IDS, arr[4], "sunny");
worldRef.worldSeed = String(arr[8] || "") || global.TarinaiSeedFactory.createWorldSeed() || `w${Date.now().toString(36)}`;
worldRef.birthSerial = Math.max(0, u(arr[9], 1, 0));
worldRef.tarinaiPopulationLimit = Math.max(0, u(arr[10], 1, 0));
worldRef.objectLimit = Math.max(0, u(arr[11], 1, 0));
worldRef.day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
worldRef.time = totalTime - (worldRef.day - 1) * dayLength;
worldRef.nextWeatherChange = rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);

View file

@ -22,7 +22,12 @@ function selectOwnedStructure(type, range = Infinity) {
}
function selectMateTarget(tarinai, world, range = 440) {
return world?.nearestOther?.(tarinai, range, other => !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease) || null;
const eligible = other => other && other !== tarinai && !other.dead && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease;
if (tarinai?.isTarinaiChampion) {
const champion = world?.nearestOther?.(tarinai, range + 260, other => eligible(other) && other.isTarinaiChampion) || null;
if (champion) return champion;
}
return world?.nearestOther?.(tarinai, range, eligible) || null;
}
function basicCanStartWithTarget(t, world, ctx = {}) {
@ -380,7 +385,7 @@ function createApproachMateActionSpec() {
phrase: "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b",
weight: 48,
selectTarget(t, world) { return selectMateTarget(t, world, 520); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
canStart(t, world, ctx = {}) { return (t.reproductionTimer || 0) <= 12 && (world?.canAddTarinai?.(1) ?? true) && !!(ctx.target ?? this.selectTarget(t, world, ctx)); },
start(t, world, ctx = {}) {
const other = ctx.target ?? this.selectTarget(t, world, ctx);
if (!other) return false;

View file

@ -168,6 +168,7 @@ function findNearbyBuildMaterial(world, tarinai, type, maxDist = 520) {
}
function buildOwnedStructureFromGrass(t, world, type, label) {
if (!(world?.canAddObjects?.(1) ?? true)) return false;
const material = findNearbyBuildMaterial(world, t, type, 520);
if (!material || !globalThis.StructureRegistry) return false;
t.buildPlan = {
@ -208,6 +209,9 @@ function continueBuildPlan(t, world, dt) {
if (!spot) {
return stopFailedBuildPlan(t, world, plan.type, "\u4F5C\u308B\u5834\u6240\u304C\u898B\u3064\u304B\u3089\u306A\u3044");
}
if (!(world?.canAddObjects?.(1) ?? true)) {
return stopFailedBuildPlan(t, world, plan.type, "\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u4e0a\u9650\u306b\u9054\u3057\u3066\u3044\u308b");
}
consumeGrassMaterial(material, structureGrassStageCost(plan.type));
const structure = globalThis.StructureRegistry.create(plan.type, t, spot.x, spot.y, world);
if (!structure) {
@ -219,7 +223,7 @@ function continueBuildPlan(t, world, dt) {
structure.x = t.x;
structure.y = t.y - Math.max(28, (t.radius || 24) * 1.28);
}
const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || (world.items.push(structure), structure);
const addedStructure = world.addItem?.(structure, `build:${plan.type}`) || null;
if (!addedStructure) {
return stopFailedBuildPlan(t, world, plan.type, "\u4f5c\u308b\u5834\u6240\u304c\u898b\u3064\u304b\u3089\u306a\u3044");
}

View file

@ -350,7 +350,7 @@ function processForcedBehaviorQueue(tarinai, world, needs) {
tarinai.sleepSession = null;
}
}
const started = startNeedAction(tarinai, world, choice, action, reasonText, {
const started = globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, action, reasonText, {
forced: true,
forcedId: entry.uid,
forcedReasonText: entry.reasonText || reasonText,

View file

@ -223,7 +223,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72);
const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 };
const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) {
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, fightAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}
@ -232,7 +232,7 @@ function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "\u3
if (!panicAction) return false;
const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 };
const reasonText = reason && reason.endsWith("\u3002") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world);
if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) {
if (globalThis.TarinaiNeedsRuntime?.startNeedAction?.(tarinai, world, choice, panicAction, reasonText)) {
tarinai.lastNeedShockBreaker = null;
return true;
}

View file

@ -111,7 +111,11 @@ function updateNeeds(tarinai, world, dt = 0) {
socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16);
raw.social = socialParts.bond;
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; }
const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true)
? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null)
: null;
if (championMate) { socialParts.mate += 58; socialParts.championMate = 1; raw.social += 58; }
else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; }
const enemy = tarinai.strongestRelation?.("fear");
if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; }
const conflict = evaluateConflictUrge(tarinai, world, 112);
@ -289,6 +293,7 @@ function socialFightMateActionWeights(tarinai) {
fightWeight *= clamp(1 - Math.max(0, Number(parts.bond || 0) || 0) / 180, 0.55, 1.04);
if ((tarinai?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; }
if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; }
if (parts.championMate) { mateWeight *= 5.0; fightWeight *= 0.35; }
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 };
}
@ -469,9 +474,6 @@ function startNeedAction(tarinai, world, choice, action, reasonText, options = {
return Boolean(ran);
}
// Needed by tarinai_needs_core.js and tarinai_forced_behavior.js, which run outside
// this module's private IIFE but start need-driven actions through the same runtime path.
global.startNeedAction = startNeedAction;
function continueCurrentBehavior(tarinai, world, needs, dt) {
const behavior = currentTarinaiBehavior(tarinai);
@ -1189,5 +1191,6 @@ function resolveNeedsCore(dt) {
global.TarinaiNeedsRuntime = Object.freeze({
updateNeeds,
startNeedAction,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -7,6 +7,12 @@ function mateTargetFor(t, world, maxDist = 520) {
if (!t || !world) return null;
const current = currentBehaviorTarget(t) || t.target;
const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04;
const championOk = o => ok(o) && !!t.isTarinaiChampion && !!o.isTarinaiChampion;
if (championOk(current) && dist(t, current) <= maxDist + 260) return current;
if (t.isTarinaiChampion) {
const champion = world.nearestOther?.(t, maxDist + 260, championOk) || null;
if (champion) return champion;
}
if (ok(current) && dist(t, current) <= maxDist + 120) return current;
return world.nearestOther?.(t, maxDist, ok) || null;
}
@ -45,12 +51,15 @@ function socialFightMateWeights(t, other, world, options = {}) {
&& (world.fightPairCooldownRemaining?.(t, other) || 0) <= 0.04
&& d <= Math.max(Number(options.fightRange || 58) || 58, (t.radius || 20) + (other.radius || 20) + 18);
const sameStatus = !!t.isZunchiSlave === !!other.isZunchiSlave;
const championPair = Boolean(t.isTarinaiChampion && other.isTarinaiChampion);
const mateWindow = Number.isFinite(Number(options.mateTimerWindow)) ? Number(options.mateTimerWindow) : 10;
const requireBirthReady = options.requireBirthReady !== false;
const timerOk = requireBirthReady ? ((t.reproductionTimer || 0) <= 0.04 && (other.reproductionTimer || 0) <= 0.04) : ((t.reproductionTimer || 0) <= mateWindow && (other.reproductionTimer || 0) <= mateWindow);
const mateDistanceOk = d <= Math.max(loveMochi ? 54 : 44, (t.radius || 20) + (other.radius || 20) + 14);
const enough = loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44);
const lonelyBond = loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24);
const enough = championPair
? ((t.hunger || 0) < 76 && (other.hunger || 0) < 78 && (t.energy || 0) > 30 && (other.energy || 0) > 30)
: (loveMochi ? ((t.hunger || 0) < 90 && (other.hunger || 0) < 92 && (t.energy || 0) > 18 && (other.energy || 0) > 18) : ((t.hunger || 0) < 55 && (other.hunger || 0) < 60 && (t.energy || 0) > 44 && (other.energy || 0) > 44));
const lonelyBond = championPair || (loveMochi ? ((t.loneliness || 0) > 4 || (other.loneliness || 0) > 4 || (t.affection || 0) > 0 || (other.affection || 0) > 0) : ((t.loneliness || 0) > 24 || (other.loneliness || 0) > 24));
const mateEligible = sameStatus
&& timerOk
&& mateDistanceOk
@ -73,6 +82,7 @@ function socialFightMateWeights(t, other, world, options = {}) {
fightWeight *= clamp(1 - affinity / 150, 0.34, 1.08);
fightWeight *= clamp(1 - fear / 170, fightMochi ? 0.72 : 0.42, 1.05);
fightWeight *= clamp(1.14 - Math.max(0, fearTrait - 1) * 0.16, 0.62, 1.22);
if (championPair) fightWeight *= 0.35;
if (options.mode === "conflict") fightWeight *= 1.35;
}
if (mateWeight > 0) {
@ -82,6 +92,7 @@ function socialFightMateWeights(t, other, world, options = {}) {
mateWeight *= 1 + Math.min(0.70, affinity / 140);
if (loveMochi) mateWeight *= 3.15;
if (fightMochi) mateWeight *= 0.52;
if (championPair) mateWeight *= 5.0;
if (options.mode === "mate") mateWeight *= 1.42;
}
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);

View file

@ -713,14 +713,13 @@
this.world.drawListDirty = true;
this.world.markItemBucketsDirty?.("tarinai-death-plushie-cleanup");
this.world.markSpatialDirty?.("tarinai-death-plushie-cleanup");
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace");
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat");
if (deterministicChance(this.world, "death-grass-spawn", 0.55, this, finalReason)) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) {
const deathGrass = new Item("grass", spot.x, spot.y);
if (this.world.addItem) this.world.addItem(deathGrass, "tarinai-death-grass");
else this.world.items.push(deathGrass);
this.world.addItem?.(deathGrass, "tarinai-death-grass");
}
}
this.world.markDead(this, finalReason);

View file

@ -44,6 +44,9 @@ const ui = {
temperatureValue: document.getElementById("temperatureValue"),
temperatureSlider: document.getElementById("temperatureSlider"),
temperatureAutoToggle: document.getElementById("temperatureAutoToggle"),
tarinaiPopulationLimitInput: document.getElementById("tarinaiPopulationLimitInput"),
objectLimitInput: document.getElementById("objectLimitInput"),
colonyLimitStatus: document.getElementById("colonyLimitStatus"),
statGroundType: document.getElementById("statGroundType"),
groundTypeBtn: document.getElementById("groundTypeBtn"),
colonyChart: document.getElementById("colonyChart"),

View file

@ -154,6 +154,19 @@ function bindUI() {
ui.temperatureSlider?.addEventListener("input", applyTemperatureSlider);
ui.temperatureSlider?.addEventListener("change", applyTemperatureSlider);
const applyColonyLimit = (kind) => {
if (!world) return;
const input = kind === "tarinai" ? ui.tarinaiPopulationLimitInput : ui.objectLimitInput;
if (!input) return;
const value = input.value.trim() === "" ? 0 : Number(input.value);
if (kind === "tarinai") world.setTarinaiPopulationLimit?.(value);
else world.setObjectLimit?.(value);
audio.uiClick?.();
renderStats?.();
};
ui.tarinaiPopulationLimitInput?.addEventListener("change", () => applyColonyLimit("tarinai"));
ui.objectLimitInput?.addEventListener("change", () => applyColonyLimit("object"));
ui.panel?.addEventListener("scroll", () => {
const panel = ui.panel;
if (!panel || !ui.panelQuickTabs) return;

View file

@ -55,6 +55,12 @@ function renderStats() {
ui.temperatureSlider.setAttribute("aria-valuetext", tempLabel);
}
if (ui.temperatureAutoToggle) ui.temperatureAutoToggle.checked = world.temperatureAuto !== false;
const tarinaiLimit = Math.max(0, Number(world.tarinaiPopulationLimit) || 0);
const objectLimit = Math.max(0, Number(world.objectLimit) || 0);
const objectCount = world.activeObjectCount?.() ?? (world.items || []).filter(item => item && !item.dead).length;
if (ui.tarinaiPopulationLimitInput && document.activeElement !== ui.tarinaiPopulationLimitInput) ui.tarinaiPopulationLimitInput.value = tarinaiLimit > 0 ? String(tarinaiLimit) : "";
if (ui.objectLimitInput && document.activeElement !== ui.objectLimitInput) ui.objectLimitInput.value = objectLimit > 0 ? String(objectLimit) : "";
setTextIfChanged(ui.colonyLimitStatus, "colonyLimitStatus", `たりない ${values.pop} / ${tarinaiLimit || "∞"} オブジェクト ${objectCount} / ${objectLimit || "∞"}`);
bindColonyMoodTooltip();
window.TarinaiTooltips.refresh(ui.statColonyMood?.closest?.(".stat") || ui.statColonyMood);
window.TarinaiGroundUI.update(world);

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "39.15.164";
const APP_BUILD = "tarinai-sleep-family-tooltip-v39-15-164";
const APP_VERSION = "39.15.167";
const APP_BUILD = "tarinai-mechanical-collision-grid-limit-audit-v39-15-166";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -13,6 +13,8 @@ class World { constructor() {
this.toolSize = "medium";
this.toolSizes = {};
this.toolAngles = {};
this.tarinaiPopulationLimit = 0;
this.objectLimit = 0;
this.resetRuntimeCollections();
this.pointer = { x: 0, y: 0, inside: false, motion: 0 };
this.hoverGrabTarget = null;

View file

@ -371,6 +371,7 @@
const n = Math.max(1, Math.min(28, Math.floor(count || 1)));
const sx = Number.isFinite(source?.x) ? source.x : (pin?.x || this.w * 0.5);
const sy = Number.isFinite(source?.y) ? source.y : (pin?.y || this.h * 0.5);
let spawned = 0;
for (let i = 0; i < n; i++) {
const a = -Math.PI * 0.15 + deterministicRange(this, "zunchi-burst-angle", -0.92, 0.92, source, pin, i) + (i / Math.max(1, n - 1) - 0.5) * 1.2;
const it = new Item("zunchi", clamp(sx + deterministicRange(this, "zunchi-burst-x", -8, 8, source, pin, i), 40, this.w - 40), clamp(sy + deterministicRange(this, "zunchi-burst-y", -8, 8, source, pin, i), 40, this.h - 40));
@ -378,12 +379,14 @@
it.vx = Math.cos(a) * deterministicRange(this, "zunchi-burst-speed-x", 260, 560, source, pin, i) + deterministicRange(this, "zunchi-burst-jitter-x", -40, 40, source, pin, i);
it.vy = Math.sin(a) * deterministicRange(this, "zunchi-burst-speed-y", 200, 480, source, pin, i) - deterministicRange(this, "zunchi-burst-up", 30, 160, source, pin, i);
it.stage = "fresh";
this.addItem?.(it, "zunchi-burst") || this.items.push(it);
if (this.addItem?.(it, "zunchi-burst")) spawned += 1;
}
if (!spawned) return 0;
this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }));
this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] });
audio.place?.("zunchi");
this.ensureSpatial?.("zunchi-burst");
return spawned;
},
spawnZunchi(x, y, source = null) {
@ -392,8 +395,10 @@
it.stage = "fresh";
it.spawnGrace = Math.max(it.spawnGrace || 0, 10.0);
it.eatProtectedUntil = (this.time || 0) + 90;
this.addItem?.(it, "zunchi-spawn") || this.items.push(it);
const added = this.addItem?.(it, "zunchi-spawn");
if (!added) return null;
audio.place?.("zunchi");
return added;
},
spawnBubble(x, y, text, color = "rgba(42,36,29,0.78)") {

View file

@ -5,6 +5,9 @@
if (!World) throw new Error("World is not available for mixin: world_environment.js");
const Geometry = global.TarinaiGeometry;
const BOUNCE_FENCE_RESTITUTION = 2.40;
const BOUNCE_FENCE_MIN_SPEED = 520;
const BOUNCE_FENCE_MAX_SPEED = 1180;
function angleForItemLocal(it) {
return itemAngleFor(it);
@ -274,7 +277,7 @@
cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)),
bounce, gate, glassWall, gateOpen: Boolean(it.gateOpen),
restitution: bounce ? 1.08 : undefined, minBounceSpeed: bounce ? 260 : undefined
restitution: bounce ? BOUNCE_FENCE_RESTITUTION : undefined, minBounceSpeed: bounce ? BOUNCE_FENCE_MIN_SPEED : undefined
};
},
@ -666,7 +669,7 @@
t.lastSolidObstacleCollisionSource = r.item || null;
t.lastSolidObstacleCollisionAt = this.time || 0;
if (r.bounce) {
const restitution = Number.isFinite(r.restitution) ? r.restitution : 1.08;
const restitution = Number.isFinite(r.restitution) ? r.restitution : BOUNCE_FENCE_RESTITUTION;
const vx = t.vx || 0;
const vy = t.vy || 0;
const toward = vx * nx + vy * ny;
@ -684,13 +687,13 @@
const now = this.time || 0;
if (currentOut < minBounceSpeed && ((t.lastBounceFenceImpulseAt || -999) + 0.16 <= now || toward < 0)) {
const add = minBounceSpeed - currentOut;
t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -520, 520);
t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -520, 520);
t.impulseVx = clamp((t.impulseVx || 0) + nx * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.impulseVy = clamp((t.impulseVy || 0) + ny * add, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.lastBounceFenceImpulseAt = now;
}
}
t.vx = clamp(t.vx || 0, -520, 520);
t.vy = clamp(t.vy || 0, -520, 520);
t.vx = clamp(t.vx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.18);
if (t.bubble && (this.time || 0) >= (t.lastBounceFenceBubbleAt || -999) + 0.55) {
t.lastBounceFenceBubbleAt = this.time || 0;

View file

@ -494,6 +494,7 @@
},
addTarinai(opts = {}) {
if (!(this.canAddTarinai?.(1) ?? true)) return null;
opts = global.TarinaiSeedFactory.prepareAddOptions(this, opts) || opts;
const seedParents = Array.isArray(opts.seedParents) ? opts.seedParents.filter(p => p && typeof p === "object") : [];
if (!opts.genetics && seedParents.length >= 2 && typeof inheritedGenetics === "function") {
@ -611,6 +612,7 @@
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
if (!(this.canAddTarinai?.(1) ?? true)) return false;
if (!this.tarinai?.includes?.(a) || !this.tarinai?.includes?.(b)) return false;
if (!!a.isZunchiSlave !== !!b.isZunchiSlave) return false;
if (this.areParentChild(a, b)) return false;
@ -657,8 +659,10 @@
const bText = buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause });
setBehaviorText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setBehaviorText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
const championPair = Boolean(a.isTarinaiChampion && b.isTarinaiChampion);
const reproductionCooldown = championPair ? Math.max(8, CONFIG.reproductionCooldown * 0.45) + rand(1, 4) : CONFIG.reproductionCooldown + rand(2, 10);
a.reproductionTimer = reproductionCooldown;
b.reproductionTimer = reproductionCooldown;
a.surpriseTimer = Math.max(a.surpriseTimer, 0.35);
b.surpriseTimer = Math.max(b.surpriseTimer, 0.35);
return true;
@ -816,6 +820,7 @@
sizeItemMode: "",
lifeItemMode: "",
});
if (!child) return null;
if (zunchiSlaveChild) child.becomeZunchiSlave?.({ birth: true });
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true;

View file

@ -339,7 +339,7 @@
<button id="rotatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
</div>
<canvas id="rotatorCanvas" width="520" height="360" aria-label="\u56de\u8ee2\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
<p class="rotator-editor-hint">\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u76f4\u7dda\u30fb\u81ea\u7531\u63cf\u753b\u30fb\u6d88\u3057\u30b4\u30e0\u3092\u9078\u3073\u3001\u30c9\u30e9\u30c3\u30b0\u3067\u8f2a\u90ed\u3092\u7de8\u96c6\u3002</p>
<p class="rotator-editor-hint">\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002</p>
<div class="rotator-editor-actions">
<button id="rotatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="rotatorApply" class="btn primary" type="button">\u53cd\u6620</button>
@ -353,11 +353,11 @@
function phyScalar(item, key, fallback = 0) { return physicsApi()?.scalar?.(item, key, fallback) ?? fallback; }
function phySet(item, key, value, reason = "editor") { return physicsApi()?.setScalar?.(item, key, value, reason) || false; }
function cloneSegmentsForRotator(item) {
const fallback = item?.type === "poison_block" ? [[-82, -28, 82, -28], [82, -28, 82, 28], [82, 28, -82, 28], [-82, 28, -82, -28]] : [[-78, 0, 78, 0], [0, -52, 0, 52]];
const fallback = item?.type === "poison_block" ? [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]] : [[-78, 0, 78, 0], [0, -52, 0, 52]];
const src = physicsApi()?.segments?.(item) || fallback;
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
}
function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 14 : 12)) {
function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 11 : 12)) {
return Math.max(...segments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + Math.max(4, thick) + 8;
}
@ -400,7 +400,7 @@
});
if (!controller) return false;
const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI);
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12)));
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12)));
if (titleEl) titleEl.textContent = isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u56de\u8ee2\u4f53";
const speedLabel = speed?.closest?.("label");
const poweredLabel = poweredInput?.closest?.("label");
@ -424,7 +424,7 @@
controller.cleanSegments();
const editedSegments = controller.getSegments();
physicsApi()?.setSegments?.(item, editedSegments, isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))), "editor-thickness");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12))), "editor-thickness");
phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
if (isPoisonBlock) {
phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
@ -478,7 +478,7 @@
<button id="reciprocatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
</div>
<canvas id="reciprocatorCanvas" width="520" height="360" aria-label="\u5f80\u5fa9\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
<p class="rotator-editor-hint">\u4e2d\u592e\u306e\u8f2a\u90ed\u5168\u4f53\u304c\u3001\u8a2d\u5b9a\u3057\u305f\u65b9\u5411\u3078\u5f80\u5fa9\u79fb\u52d5\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002</p>
<p class="rotator-editor-hint">\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002</p>
<div class="rotator-editor-actions">
<button id="reciprocatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="reciprocatorApply" class="btn primary" type="button">\u53cd\u6620</button>
@ -672,7 +672,7 @@
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) };
}
if (item.type === "poison_block") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 14) || 14), segments: cloneSegmentsForRotator(item) };
data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 11) || 11), segments: cloneSegmentsForRotator(item) };
}
if (item.type === "reciprocator") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, railOn: phyScalar(item, "railOn", true) !== false, railMotorSpeed: Number(phyScalar(item, "railMotorSpeed", 92) || 92), railTravel: Number(phyScalar(item, "railTravel", 150) || 150), railPhase: Number(phyScalar(item, "railPhase", 0) || 0), railDir: Number(phyScalar(item, "railDir", 1) || 1), railAxis: Number(phyScalar(item, "railAxis", item.angle || 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
@ -743,7 +743,7 @@
phySet(item, "solid", ph.solid !== false, "copy");
phySet(item, "damage", Math.max(1, Number(ph.damage || 7) || 7), "copy");
phySet(item, "spin", 0, "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 14) || 14)), "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 11) || 11)), "copy");
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
const extent = physicsShapeExtent(item);
item.r = Math.max(item.r || 64, Math.min(460, extent));
@ -1199,6 +1199,10 @@
placeItem(item, dropped = true) {
if (!item) return null;
if (!(this.canAddObjects?.(1) ?? true)) {
showToast(`オブジェクト総数は${this.objectLimit}個までです。`);
return null;
}
if (item.type === "grass" && !this.canAddGrass?.(1)) {
showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
return null;
@ -1220,7 +1224,8 @@
if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear");
const placed = this.addItem?.(item, `place:${item.type}`);
if (!placed) {
if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
if (!(this.canAddObjects?.(1) ?? true)) showToast(`オブジェクト総数は${this.objectLimit}個までです。`);
else if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
return null;
}
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
@ -1357,6 +1362,7 @@
if (tool === "new") {
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });
if (!t) { showToast(`たりない個体数は${this.tarinaiPopulationLimit}匹までです。`); return; }
t.wanderAngle = spot.angle;
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
return;

View file

@ -215,9 +215,7 @@ const RESET_PRESETS = Object.freeze({
item.y = spot.y;
syncPresetMechanicalPose(item);
}
if (worldRef.addItem) worldRef.addItem(item, opts.reason || `preset-${type}`);
else worldRef.items.push(item);
return item;
return worldRef.addItem?.(item, opts.reason || `preset-${type}`) || null;
}
function addPresetGrass(worldRef, count, zone = null) {

View file

@ -7,9 +7,13 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
beginFramePerformanceBudgets() {
const count = Math.max(1, (this.tarinai || []).length || 1);
const mobile = Boolean(global.TarinaiInputMode?.snapshot?.().touchFirst || (global.innerWidth || 9999) <= 760);
const aiCap = mobile ? 8 : 12;
const envCap = mobile ? 4 : 6;
this.workBudget = {
ai: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 2)),
env: Math.max(1, Math.ceil(((this.tarinai || []).length || 1) / 3)),
ai: Math.max(1, Math.min(aiCap, Math.ceil(count / 2))),
env: Math.max(1, Math.min(envCap, Math.ceil(count / 3))),
aiUsed: 0,
envUsed: 0,
};
@ -286,8 +290,46 @@
return false;
},
normalizeColonyLimit(value = 0) {
const n = Math.floor(Number(value) || 0);
return Number.isFinite(n) && n > 0 ? n : 0;
},
activeTarinaiCount() {
let count = 0;
for (const t of this.tarinai || []) if (t && !t.dead) count += 1;
return count;
},
activeObjectCount() {
let count = 0;
for (const item of this.items || []) if (item && !item.dead) count += 1;
return count;
},
canAddTarinai(count = 1) {
const limit = this.normalizeColonyLimit(this.tarinaiPopulationLimit);
return limit <= 0 || this.activeTarinaiCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
},
canAddObjects(count = 1) {
const limit = this.normalizeColonyLimit(this.objectLimit);
return limit <= 0 || this.activeObjectCount() + Math.max(0, Math.floor(Number(count) || 0)) <= limit;
},
setTarinaiPopulationLimit(value = 0) {
this.tarinaiPopulationLimit = this.normalizeColonyLimit(value);
return this.tarinaiPopulationLimit;
},
setObjectLimit(value = 0) {
this.objectLimit = this.normalizeColonyLimit(value);
return this.objectLimit;
},
addItem(item, reason = "add-item", opts = {}) {
if (!item) return null;
if (!this.canAddObjects(1)) return null;
if (item.type === "grass" && !this.canAddGrass?.(1)) return null;
item.world = this;
this.items.push(item);

View file

@ -177,6 +177,42 @@ if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cl
ok("behavior state smoke test passed")
def need_action_runtime_bridge_smoke() -> None:
items_source = read_text(ROOT / "js/tarinai_needs_items.js")
core_source = read_text(ROOT / "js/tarinai_needs_core.js")
forced_source = read_text(ROOT / "js/tarinai_forced_behavior.js")
js = f"""
const vm = require('vm');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.window = context;
context.Tarinai = class Tarinai {{}};
vm.createContext(context);
vm.runInContext({items_source!r}, context, {{ filename: 'tarinai_needs_items.js' }});
if (!context.TarinaiNeedsRuntime) throw new Error('TarinaiNeedsRuntime missing');
if (typeof context.TarinaiNeedsRuntime.startNeedAction !== 'function') throw new Error('startNeedAction bridge missing');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("need-action runtime bridge smoke test failed")
qualified_call = "globalThis.TarinaiNeedsRuntime?.startNeedAction?.("
if core_source.count(qualified_call) != 2:
fail("tarinai_needs_core.js must route both emergency starts through TarinaiNeedsRuntime")
if forced_source.count(qualified_call) != 1:
fail("tarinai_forced_behavior.js must route forced starts through TarinaiNeedsRuntime")
if re.search(r"(?<![.\w])startNeedAction\s*\(", core_source + "\n" + forced_source):
fail("cross-file bare startNeedAction call remains")
ok("need-action runtime bridge is exported and all cross-file calls are qualified")
def check_legacy_behavior_refs() -> None:
tokens = re.compile(r"\b(activeBehavior|currentAction|activeForcedBehavior|intent)\b")
offenders = []
@ -1216,6 +1252,383 @@ if (grass.normalized) throw new Error('grass should not be normalized by per-ite
fail("Item lifecycle pipeline smoke test failed")
ok("Item lifecycle pipeline boundary present")
def mechanical_collision_and_editor_smoke() -> None:
footprints = read_text(ROOT / "js/collision_footprint_system.js")
editor_source = read_text(ROOT / "js/physics_shape_editor_system.js")
placement_source = read_text(ROOT / "js/world_placement_log.js")
for token in ("rectCorners,", "rectAxes,", "projectPoints,", "aabbOverlap,", "rectOverlapInfo,", "rectsOverlap,"):
if token not in footprints:
fail(f"mechanical SAT primitive is not exported: {token}")
if "const DEFAULT_GRID_SIZE = 20;" not in editor_source:
fail("mechanical shape editor grid must use coarse 20px snapping")
if editor_source.count("snapPoint(") < 2 or "gridSize: DEFAULT_GRID_SIZE" not in editor_source:
fail("mechanical shape editor does not route pointer coordinates through grid snapping")
if placement_source.count("20px\\u9593\\u9694") < 2:
fail("mechanical editor hints do not disclose 20px grid snapping")
js = f"""
const vm = require('vm');
const fs = require('fs');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.window = context;
context.TarinaiPerf = {{ begin() {{ return null; }} }};
context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }};
context.normalizedItemAngle = angle => angle;
vm.createContext(context);
for (const path of {repr([str(ROOT / rel) for rel in [
'js/math.js', 'js/geometry_helpers.js', 'js/collision_footprint_system.js',
'js/physics_world_system.js', 'js/mechanical_system.js', 'js/physics_shape_editor_system.js'
]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }});
const fp = context.TarinaiCollisionFootprints;
for (const key of ['rectCorners', 'rectAxes', 'projectPoints', 'aabbOverlap', 'rectOverlapInfo', 'rectsOverlap']) {{
if (typeof fp[key] !== 'function') throw new Error(`missing footprint primitive: ${{key}}`);
}}
const editor = context.TarinaiPhysicsShapeEditorSystem;
if (editor.gridSize !== 20) throw new Error('editor grid size mismatch');
const snapped = editor.snapPoint({{ x: 13, y: 31 }});
if (snapped.x !== 20 || snapped.y !== 40) throw new Error('pointer did not snap to grid');
const cleaned = editor.cleanSegments([[13, 31, 91, 44], [-52, -20, 52, -20]], {{ fallback: [-80, 0, 80, 0] }});
if (!cleaned.length || cleaned.some(seg => seg.some(value => Math.abs(value % 20) > 1e-9))) throw new Error('segment endpoints are not grid aligned');
if (cleaned.some(seg => Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) < 20)) throw new Error('editor retained sub-grid segments');
let serial = 0;
function makeItem(type, x, y) {{
return {{ id: `m${{++serial}}`, type, x, y, angle: 0, r: 64, amount: 999, dead: false }};
}}
function activate(item) {{
const api = context.TarinaiPhysicsBodySystem;
api.setSegments(item, [[-80, 0, 80, 0]], 'test-shape');
api.setScalar(item, 'solid', true, 'test-solid');
if (item.type === 'rotator') {{ api.setScalar(item, 'motorOn', true, 'test'); api.setScalar(item, 'motorSpeed', 1.4, 'test'); }}
if (item.type === 'reciprocator') {{ api.setScalar(item, 'railOn', true, 'test'); api.setScalar(item, 'railAxis', 0, 'test'); api.setScalar(item, 'railMotorSpeed', 80, 'test'); }}
if (item.type === 'poison_block') {{ api.setScalar(item, 'xv', 24, 'test'); api.setScalar(item, 'awakeUntil', 10, 'test'); }}
}}
function pairResult(typeA, typeB, disableB = false) {{
const a = makeItem(typeA, 120, 120);
const b = makeItem(typeB, 120, 128);
const items = [a, b];
const world = {{
items, tarinai: [], time: 1, frameCount: 1, w: 900, h: 700, drawListDirty: false,
itemCounts: {{ rotator: 0, poison_block: 0, reciprocator: 0 }},
ensureItemBuckets() {{}}, markSpatialDirty() {{}}, markItemBucketsDirty() {{}},
nearbyObstacles() {{ return items; }}, nearbyItems() {{ return items; }},
itemsOfType(type) {{ return items.filter(item => item.type === type && !item.dead); }},
}};
for (const item of items) {{ item.world = world; world.itemCounts[item.type] += 1; context.TarinaiPhysicsBodySystem.ensureBody(item, world); activate(item); }}
if (disableB) context.TarinaiPhysicsBodySystem.setScalar(b, 'solid', false, 'test-disabled');
const solved = context.TarinaiMechanicalSystem.resolveMechanicalPairs(world, 0.016, {{ maxPairs: 40 }});
return {{ solved, a, b }};
}}
for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rotator', 'poison_block'], ['reciprocator', 'reciprocator'], ['reciprocator', 'poison_block'], ['poison_block', 'poison_block']]) {{
const result = pairResult(a, b, false);
if (!(result.solved > 0)) throw new Error(`${{a}}/${{b}} collision-on pair did not interact`);
const disabled = pairResult(a, b, true);
if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`);
}}
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("mechanical collision/editor smoke test failed")
ok("collision-enabled mechanical pairs interact and editor geometry snaps to the 20px lattice")
def colony_limits_and_defaults_smoke() -> None:
tool_defs = read_text(ROOT / "js/item_tool_definitions.js")
initializer = read_text(ROOT / "js/item_type_initializers.js")
world_budget = read_text(ROOT / "js/world_spatial_budget.js")
world_family = read_text(ROOT / "js/world_family_social.js")
needs = read_text(ROOT / "js/tarinai_needs_items.js")
social = read_text(ROOT / "js/tarinai_social_action_runtime.js")
snapshot = read_text(ROOT / "js/snapshot_system.js")
codec = read_text(ROOT / "js/save_codec.js")
index = read_text(ROOT / "index.html")
if 't === "reciprocator") return Math.PI / 2' not in tool_defs:
fail("reciprocator default angle is not 90 degrees")
small = "[[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]"
poison_files = [
"js/item_type_initializers.js", "js/physics_world_system.js", "js/item_render_runtime.js",
"js/mechanical_system.js", "js/world_placement_log.js",
]
for rel in poison_files:
body = read_text(ROOT / rel)
if not all(token in body for token in ("-52", "-20", "52", "20")):
fail(f"small poison-block shape missing from {rel}")
if "[-82, -28, 82, -28]" in body:
fail(f"legacy large poison-block shape remains in {rel}")
if 'radius: 44' not in tool_defs or 'thickness: 11' not in initializer:
fail("small poison-block radius/thickness defaults are not synchronized")
for token in ("canAddTarinai(count = 1)", "canAddObjects(count = 1)", "setTarinaiPopulationLimit", "setObjectLimit"):
if token not in world_budget:
fail(f"colony limit runtime missing: {token}")
if "if (!(this.canAddTarinai?.(1) ?? true)) return null;" not in world_family:
fail("tarinai creation/birth is not unconditionally guarded by population limit")
if "if (!this.canAddObjects(1)) return null;" not in world_budget:
fail("object creation is not unconditionally guarded by object limit")
if "ignoreColonyLimit" in world_family or "ignoreColonyLimit" in world_budget:
fail("colony limit bypass option remains in central creation APIs")
unsafe_fallback = re.compile(r"addItem\?\.[^\n;]+\|\|[^\n;]*items\.push")
for rel in (ROOT / "js").glob("*.js"):
if unsafe_fallback.search(read_text(rel)):
fail(f"object-limit bypass fallback remains in {rel.name}")
direct_item_pushes = []
direct_tarinai_pushes = []
for rel in (ROOT / "js").glob("*.js"):
body = read_text(rel)
if re.search(r"\b(?:this|worldRef|world)\.items\.push\(", body):
direct_item_pushes.append(rel.name)
if re.search(r"\b(?:this|worldRef|world)\.tarinai\.push\(", body):
direct_tarinai_pushes.append(rel.name)
if sorted(direct_item_pushes) != ["snapshot_system.js", "world_spatial_budget.js"]:
fail("unexpected direct world item insertion can bypass object limit: " + ", ".join(sorted(direct_item_pushes)))
if sorted(direct_tarinai_pushes) != ["snapshot_system.js", "world_family_social.js"]:
fail("unexpected direct tarinai insertion can bypass population limit: " + ", ".join(sorted(direct_tarinai_pushes)))
guarded_sources = {
"js/item_dynamic_ball_system.js": "if (!addedDrop) continue;",
"js/fire_runtime_system.js": "if (!addedFire) return null;",
"js/tarinai_building_behavior.js": "canAddObjects?.(1)",
"js/world_combat_effects.js": "if (!spawned) return 0;",
}
for rel, token in guarded_sources.items():
if token not in read_text(ROOT / rel):
fail(f"object-limit guard missing from {rel}: {token}")
if "championMate" not in needs or "championPair" not in social or "mateWeight *= 5.0" not in social:
fail("champion-pair breeding preference is incomplete")
for token in ("tarinaiPopulationLimit", "objectLimit"):
if token not in snapshot or token not in codec:
fail(f"colony limit persistence missing: {token}")
for element_id in ("tarinaiPopulationLimitInput", "objectLimitInput", "colonyLimitStatus"):
if f'id="{element_id}"' not in index:
fail(f"colony limit UI missing: {element_id}")
js = f"""
const vm = require('vm');
const fs = require('fs');
class World {{
constructor() {{
this.items = [];
this.tarinai = [];
this.itemCounts = {{}};
this.tarinaiPopulationLimit = 0;
this.objectLimit = 0;
}}
markItemBucketsDirty() {{}}
markSpatialDirty() {{}}
markTerrainDirty() {{}}
markTerrainDirtyAt() {{}}
}}
const context = {{ console, World, CONFIG: {{ grassLimit: Infinity }}, globalThis: null }};
context.globalThis = context;
context.window = context;
context.TarinaiGround = {{ grassLimit() {{ return Infinity; }} }};
context.TarinaiPerf = {{ snapshot() {{ return null; }}, begin() {{ return null; }} }};
vm.createContext(context);
vm.runInContext(fs.readFileSync({str(ROOT / 'js/world_spatial_budget.js')!r}, 'utf8'), context);
const world = new context.World();
if (world.tarinaiPopulationLimit !== 0 || world.objectLimit !== 0) throw new Error('limits must default to unlimited');
world.setObjectLimit(2.9);
if (world.objectLimit !== 2) throw new Error('object limit must normalize to a positive integer');
if (!world.canAddObjects(2) || world.canAddObjects(3)) throw new Error('object capacity query is incorrect');
if (!world.addItem({{ type: 'stone' }}, 'test-1')) throw new Error('first object rejected');
if (!world.addItem({{ type: 'stone' }}, 'test-2')) throw new Error('second object rejected');
if (world.addItem({{ type: 'stone' }}, 'test-3') !== null) throw new Error('object limit not enforced');
world.items[0].dead = true;
if (!world.addItem({{ type: 'stone' }}, 'test-4')) throw new Error('dead object should free capacity');
world.setObjectLimit(1);
if (world.addItem({{ type: 'stone' }}, 'over-limit-existing') !== null) throw new Error('existing over-limit world must reject new objects');
for (const item of world.items) item.dead = true;
if (!world.addItem({{ type: 'stone' }}, 'capacity-restored')) throw new Error('capacity was not restored after all live objects died');
world.setTarinaiPopulationLimit(1.8);
if (world.tarinaiPopulationLimit !== 1) throw new Error('tarinai limit must normalize to a positive integer');
world.tarinai.push({{ dead: false }});
if (world.canAddTarinai(1)) throw new Error('tarinai limit not enforced');
world.tarinai[0].dead = true;
if (!world.canAddTarinai(1)) throw new Error('dead tarinai should free capacity');
world.setObjectLimit('');
if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited');
context.TarinaiSnapshot = {{ version: 23 }};
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 28, FIELD_IDS: ['garden'] }};
context.CONFIG.dayLength = 120;
context.TextEncoder = TextEncoder;
context.TextDecoder = TextDecoder;
vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context);
(async () => {{
const source = {{ v: 23, a: 'tj1', m: [1, 0, 'garden'], w: [0, 0, 0, 120, 0, -9990, 1, 0, 'seed', 0, 12, 345], t: [], i: [] }};
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(source);
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
if (decoded.w[10] !== 12 || decoded.w[11] !== 345) throw new Error('colony limits did not survive save roundtrip');
}})().catch(error => {{ console.error(error); process.exitCode = 1; }});
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("colony limit runtime smoke test failed")
ok("champion breeding, mechanical defaults, and colony limits are wired")
def physics_tuning_smoke() -> None:
editor = read_text(ROOT / "js/physics_shape_editor_system.js")
environment = read_text(ROOT / "js/world_environment.js")
helpers = read_text(ROOT / "js/physics_helpers.js")
constraints = read_text(ROOT / "js/constraint_system.js")
dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.js")
initializer = read_text(ROOT / "js/item_type_initializers.js")
for token in ("BOUNCE_FENCE_RESTITUTION = 2.40", "BOUNCE_FENCE_MIN_SPEED = 520", "BOUNCE_FENCE_MAX_SPEED = 1180"):
if token not in environment:
fail(f"strong bounce-fence tuning missing: {token}")
if "const iterations = 8" not in constraints or "const compliance = mode === \"rod\" ? 0.085 / (step * 60) : 0" not in constraints:
fail("rope endpoint constraint is still compliant/stretchable")
if "STICKY_BOMB_BLAST_SCALE = 0.68" not in dynamic_tools or "item.blastScale = 0.68" not in initializer:
fail("sticky bomb is not synchronized with small-firecracker blast scale")
js = f"""
const vm = require('vm');
const editorSource = {editor!r};
const helperSource = {helpers!r};
const constraintSource = {constraints!r};
const dynamicSource = {dynamic_tools!r};
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.window = context;
context.clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
context.distXY = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1);
context.CONFIG = {{ worldPadding: 0 }};
context.Effect = function Effect() {{}};
context.TarinaiGeometry = {{
pointSegmentDistance(px, py, x1, y1, x2, y2) {{
const dx = x2 - x1, dy = y2 - y1;
const den = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / den));
return Math.hypot(px - (x1 + dx * t), py - (y1 + dy * t));
}},
rectLocalPoint(rect, x, y) {{ return {{ x: x - (rect.cx || 0), y: y - (rect.cy || 0) }}; }},
rectWorldVector(_rect, x, y) {{ return {{ x, y }}; }},
rectWorldNormal(_rect, x, y) {{ return {{ x, y }}; }},
}};
context.TarinaiMechanicalSystem = {{
isMechanicalType() {{ return false; }},
isPowered() {{ return false; }},
applySurfaceVelocityToCircle() {{}},
wakeItem() {{}},
}};
context.TarinaiPhysicsProjectionSystem = {{ markSpatialDirty() {{ return false; }} }};
context.TarinaiMovementUpdateStep = {{ markSleepExternalMotion() {{}} }};
context.itemAngleFor = item => Number(item?.angle || 0) || 0;
context.normalizedItemAngle = angle => Number(angle || 0) || 0;
vm.createContext(context);
vm.runInContext(editorSource, context, {{ filename: 'physics_shape_editor_system.js' }});
const circle = context.TarinaiPhysicsShapeEditorSystem.templateSegments('circle');
if (!Array.isArray(circle) || circle.length !== 24) throw new Error('circle template is not a 24-sided contour');
for (const segment of circle) {{
if (segment.length !== 4 || segment.some(value => value % 20 !== 0)) throw new Error('circle template left the 20px grid');
}}
for (let i = 0; i < circle.length; i += 1) {{
const next = circle[(i + 1) % circle.length];
if (circle[i][2] !== next[0] || circle[i][3] !== next[1]) throw new Error('circle contour is not closed');
}}
vm.runInContext(helperSource, context, {{ filename: 'physics_helpers.js' }});
const ball = {{ x: -4, y: 5, prevX: -14, prevY: 5, vx: 100, vy: 0, r: 5, spinVelocity: 0 }};
const fence = {{ left: 0, right: 10, top: 0, bottom: 10, bounce: true, restitution: 2.40, minBounceSpeed: 520 }};
if (!context.TarinaiPhysics.bounceCircleOffRect(ball, fence, 5, fence.restitution, null, {{ minBounceSpeed: fence.minBounceSpeed }})) throw new Error('bounce-fence collision did not resolve');
if (ball.vx > -519) throw new Error('bounce-fence launch is not substantially stronger');
context.TarinaiPhysicsBodySystem = {{
scalar(item, key, fallback) {{ return Number(item.physicsBody?.[key] ?? fallback); }},
setScalar(item, key, value) {{ item.physicsBody ||= {{}}; item.physicsBody[key] = value; return true; }},
linkScalar(item, key, fallback) {{
const c = item.physicsConstraint || {{}};
const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }};
return Number(c[map[key] || key] ?? fallback);
}},
setLinkScalar(item, key, value) {{
const c = item.physicsConstraint ||= {{}};
const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }};
c[map[key] || key] = value;
return true;
}},
endpoint(item, index) {{ return item.physicsConstraint?.endpoints?.[index] || null; }},
particles(item) {{ return item.physicsConstraint?.particles || null; }},
setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }},
ensureConstraint(item) {{ return item.physicsConstraint; }},
applyConstraintState() {{ return true; }},
normalizeConstraintState(_item, state) {{ return state; }},
syncPoseFromItem() {{ return true; }},
normalizeBodyState() {{ return true; }},
markBodyChanged() {{ return true; }},
}};
vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }});
const a = {{ id: 'a', type: 'ball', x: 0, y: 0, r: 10, dead: false }};
const b = {{ id: 'b', type: 'ball', x: 200, y: 0, r: 10, dead: false }};
const objects = new Map([[a.id, a], [b.id, b]]);
const world = {{
time: 1,
items: [a, b],
tarinai: [],
itemCounts: {{ rope: 1 }},
itemById(id) {{ return objects.get(id) || null; }},
nearbyTarinai() {{ return []; }},
nearbySolidObstacleRects() {{ return []; }},
markSpatialDirty() {{}},
drawListDirty: false,
}};
const rope = {{
id: 'rope-1', type: 'rope', dead: false, amount: 999, world,
physicsConstraint: {{ endpoints: [{{ kind: 'item', id: 'a', x: 0, y: 0 }}, {{ kind: 'item', id: 'b', x: 200, y: 0 }}], length: 100, particles: null }},
}};
context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world);
const tautDistance = Math.hypot(b.x - a.x, b.y - a.y);
if (tautDistance > 100.26) throw new Error(`rope stretched to ${{tautDistance}}px`);
const beforeA = {{ x: a.x, y: a.y }}, beforeB = {{ x: b.x, y: b.y }};
b.x = a.x + 60;
context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world);
if (Math.hypot(a.x - beforeA.x, a.y - beforeA.y) > 0.01) throw new Error('slack rope pushed endpoint A');
if (Math.abs((b.x - a.x) - 60) > 0.01) throw new Error('slack rope pushed endpoints apart');
context.deterministicChance = () => false;
context.Effect = function Effect() {{}};
context.audio = {{ explode() {{}} }};
vm.runInContext(dynamicSource, context, {{ filename: 'item_dynamic_tool_system.js' }});
let capturedScale = null;
const sticky = {{ type: 'sticky_bomb', dead: false, fuseTimer: 0, stickyBombCarrierId: '', stickyBombTransferCooldown: 0, spin: 0, spinVelocity: 0 }};
const stickyWorld = {{ tarinai: [], explodeFirecracker(item) {{ capturedScale = item.blastScale; return true; }} }};
context.TarinaiItemDynamicToolSystem.update(sticky, 1 / 60, stickyWorld);
if (Math.abs(capturedScale - 0.68) > 1e-9) throw new Error('sticky bomb does not match small firecracker blast scale');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("physics tuning smoke test failed")
ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified")
def main() -> None:
data = manifest()
check_files_exist(data)
@ -1227,6 +1640,10 @@ def main() -> None:
node_check([*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js"])
action_spec_smoke()
behavior_state_smoke()
need_action_runtime_bridge_smoke()
mechanical_collision_and_editor_smoke()
physics_tuning_smoke()
colony_limits_and_defaults_smoke()
check_basic_action_spec_runtime()
check_social_action_spec_runtime()
check_action_spec_no_legacy_hooks()

View file

@ -1,6 +1,6 @@
"use strict";
const APP_VERSION = "39.15.164";
const APP_VERSION = "39.15.167";
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.