From 88f618cadf095f168e3596713f2cfc3d10268c95 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 3 Jul 2026 20:57:02 +0900 Subject: [PATCH 1/4] 1.6 --- BUILD_NOTE.txt | 125 +++++++++- GAME_FEATURES.md | 25 +- app_manifest.json | 2 +- css/base.css | 25 ++ css/components.css | 17 +- css/layout.css | 7 +- index.html | 339 ++++++++++++++-------------- js/collision_response_system.js | 13 +- js/fire_runtime_system.js | 16 +- js/ground_types.js | 8 +- js/health.js | 4 +- js/item_dynamic_ball_system.js | 108 +++++++-- js/item_dynamic_system.js | 5 + js/item_dynamic_tool_system.js | 124 ++++++++++ js/item_render_runtime.js | 184 ++++++++++----- js/item_tool_definitions.js | 11 +- js/item_tool_metadata.js | 6 +- js/item_type_initializers.js | 17 +- js/item_update_policy.js | 4 +- js/items.js | 3 +- js/perf_profiler.js | 12 +- js/render.js | 56 +++-- js/robot_cleaner_system.js | 5 +- js/save_codec.js | 4 + js/save_schema.js | 6 +- js/snapshot_system.js | 5 + js/structures.js | 16 +- js/tarinai_action_definitions.js | 11 +- js/tarinai_behavior_text.js | 16 +- js/tarinai_contact_item_system.js | 7 + js/tarinai_disease_nest.js | 3 +- js/tarinai_needs_items.js | 8 +- js/tarinai_nest_sleep_system.js | 1 + js/tarinai_social_action_runtime.js | 2 +- js/tarinai_social_move_life.js | 20 +- js/tarinai_sunbath_system.js | 2 +- js/tarinai_update_step_ai.js | 7 + js/tarinai_update_step_frame.js | 10 +- js/tarinai_update_step_movement.js | 12 + js/ui_charts.js | 146 ++++++++---- js/ui_family_async.js | 4 +- js/ui_family_layout.js | 2 +- js/ui_family_paths.js | 106 ++++++++- js/ui_ground.js | 2 +- js/ui_input_shared.js | 20 +- js/ui_log.js | 7 +- js/version.js | 4 +- js/weather_system.js | 26 ++- js/world_combat_effects.js | 12 +- js/world_family_social.js | 68 ++++-- js/world_placement_log.js | 67 ++++-- js/world_temperature_system.js | 29 ++- js/world_tool_actions.js | 7 +- js/world_view.js | 8 +- service-worker.js | 2 +- 55 files changed, 1304 insertions(+), 452 deletions(-) diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 2809c20..59f4bf0 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -1,9 +1,132 @@ +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. + +tarinai_sticky_bomb_plushie_stats_v1 +- Sticky bomb tooltip changed to the requested short description. +- Colony chart no longer appends the in-progress bucket as a live preview row, preventing rapid graph redraws between two-hour samples. +- Structure items now receive their own update method through the item dynamic dispatch, fixing plushie fling/offscreen destruction. +- Plushie poke fling speed/lifetime were strengthened and marked destroyOffscreen for both carried and uncarried plushies. +- Sticky bomb explosion delegates to World.explodeFirecracker; World.explodeFirecracker now accepts sticky_bomb as a compatible source. + +tarinai_sticky_bomb_plushie_v1 +- Added sticky_bomb as a hazard tool with timed explosion, carrier panic, and contact transfer. +- Changed poke behavior for carried plushies to fling them offscreen before destruction instead of immediate deletion. +- Removed the misleading no-explosion phrase from the flame-firecracker tooltip. + +tarinai_grab_rain_death_reason_v1 +- Held Tarinai now zeroes velocity/impulses during pinch and skips AI/movement while player-held. +- Zunchi has a vector fallback sprite until its image asset is renderable. +- Poked carried plushies are destroyed instead of depending on offscreen fling removal. +- Rain streak overlay is visible during light_rain and no longer disappears entirely with transient effects disabled. +- Restored soil ground label as 砂 while keeping ground UI wording as 地面. +- Stale fire_seen panic text is cleared when no active fire/burning threat remains. +- Family tree death reason label now uses the same stored final reason as the event log. + +tarinai_ground_edgegrass_duplicate_scan_v1 +- Unified ground wording for default ground / ground change log / ground change toast to 地面. +- Removed visual-only edge grass decorations from the cached background renderer. +- Duplicate-processing candidates found but not removed: + - Mechanical geometry helpers overlap across geometry_helpers.js, mechanical_shape_bridge.js, and mechanical_system.js. + - Item update scheduling helpers overlap between item_update_policy.js and item_update_scheduler.js. + - Sleep-place / nest-box search helpers overlap between tarinai_item_targeting.js and tarinai_nest_sleep_system.js. + - Physics bounce response block is repeated inside physics_helpers.js. + - Rotator / reciprocator editor close/apply scaffolding is repeated in world_placement_log.js. + - Drink/juice consumable handling has similar sound/need-relief blocks in tarinai_consumable_behavior.js. + - Family connected-component traversal logic appears in both family_graph.js and ui_family_async.js. +- No duplicate blocks were removed in this build. + +tarinai_tool_fold_fan_area_delete_v1 +- Removed the over-broad collapsed-card min-height override and preserved folded tool-card header height. +- Updated fan tooltip copy from water balloons/balls to lightweight items. +- Changed area-delete to its own text icon instead of reusing the normal delete icon. + +tarinai_right_ui_restore_v1 +- Restored right-panel/tool/collapsible CSS to the stable pre-scroll behavior. +- Removed the later generic scroll clamps from panel-card bodies/tool palette/right UI. +- Kept scrollability only for in-game overlay/editor windows that can exceed the viewport. + +tarinai_right_panel_scroll_fix_v1 +- Removed generic max-height/overflow clamp from panel-card bodies. +- Restored the right panel as the scroll container. +- Preserved internal tool-palette scrolling and sticky operation category. +- Ensured collapsed right-panel cards keep a full visible header. + +tarinai_tool_ui_fix_v1 +- Stabilized folded tool-card height when other UI cards are expanded. +- Kept the operation category sticky while fixing display glitches inside the tool palette scroller. +- Kept tool-palette scrolling while preventing global panel-body height limits from compressing the tool card. + +tarinai_event_ui_king_scroll_v1 +- Hidden robot-cleaner and player/tool operation records from the event log and observation records. +- Reduced visual settings to OFF/ON for effects/shadows and low/high for details. +- Updated family/nest descriptions and implemented champion-only nest displacement during sleep. +- Strengthened flame-firecracker size scaling. +- Shortened intimidation timers. +- Added overflow scrolling safeguards for fixed UI and panel bodies. + +tarinai_stats_cleaning_flame_intimidation_v1 +- Colony chart day markers now use season-day labels such as 春2日. +- Robot cleaner deaths normalize to 掃除. +- Flame firecracker fire amount/count/range now scale with item size. +- Object chart label changed to 環境; population/environment y-axis labels use data max instead of fixed 100. +- Environment chart includes temperature. +- Intimidation state is cleared when its timer ends and timer ranges are shortened. + +tarinai_tooltip_stats_disease_hunger_v1 +- Tarinai canvas tooltip width now adapts to its four fixed lines. +- Colony charts draw dotted gray daily 0:00 markers with day labels. +- Orphan plushies with no live owner are removed. +- Fight disease blocks sunbathing and sunbath curing. +- Disease-active low-health/stress deaths prefer disease cause labels. +- Natural hunger increase is multiplied by 0.75 in awake and sleep paths. + +tarinai_family_routes_v39_15_124 +- Clamped above-generation lineage detours inside the SVG viewport. +- Allowed lineage edges to attach to non-center points on node sides when detouring. +- Removed fight win-rate text from family-tree nodes. + +tarinai_tooltip_family_routing_v1 +- Tarinai hover tooltip is fixed to 4 logical lines: name, behavior, HP/hunger/stress, felt temperature. +- Family tree partner/child link routing now detours around unrelated node cards when a direct horizontal partner line would overlap a node. + +tarinai_pipe_shadow_temperature_shelter_v1 +- Moved pipe shadow down to the pipe-ground contact area and enlarged it slightly. +- Temperature comfort seeking now considers thermally comfortable nest boxes and pipes. +- Tarinai can enter selected nest/pipe shelters for temperature relief. + +tarinai_pipe_layer_fan_sunbath_v1 +- Pipe tooltip wording now uses 土管, not 全土管/土管全部. +- Pipe shadow is moved to the ground-contact mouth area and enlarged slightly. +- Render layer sorting uses bottom-edge/ground-contact Y instead of center Y. +- Fan wind lowers felt temperature by up to 5C. +- Sunbath disease recovery chance is 25%. + +tarinai_pipe_visual_tooltip_v1 +- Updated water-balloon / pipe tool descriptions in the tool UI. +- Touching toys (ball / water balloon) can slightly increase openness over time. +- Pipe visuals are revised to read as a pipe embedded in the ground instead of a jar. +- Pipe tooltip occupant listing is reinforced by aggregating live occupants across all pipes. + +tarinai_pipe_tooltip_temp_raindrop_v1 +- Pipe tooltips list tarinai inside all pipes instead of only the hovered pipe. +- Fully hidden tarinai inside a nest box or pipe are skipped by direct tarinai tooltips. +- Pipe interior temperature correction now moves toward 20C by at most +/-5C. +- Water-balloon splash drops are created through the same rain-water item initializer used by rain. +- Version: 39.15.136 + +tarinai_pipe_tooltip_temp_v1 +- Pipe tooltips list tarinai inside all pipes instead of only the hovered pipe. +- Fully hidden tarinai inside a nest box or pipe are skipped by direct tarinai tooltips. +- Pipe interior temperature correction now moves toward 20C by at most +/-5C. +- Version: 39.15.117 + tarinai_water_balloon_fan_ui_v1 - Fan drawing now shows the actual forward wind cone/range. - Mobile rotator/reciprocator editor dialogs are scrollable; reciprocator canvas shares editor sizing. - Balloon tool is replaced by water balloon visuals/text. - Water balloons splash water when popped; low-openness tarinai panic and flee with behavior text: 水風船が割れておどろいている。 -- Version: 39.15.116 +- Version: 39.15.117 tarinai_king_fix_v10 - Fire tool button hidden; flames spawn only from flame firecracker. diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index 0907f3a..272cfa7 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -1,3 +1,26 @@ +## 39.15.136 ツールチップ・統計・病気・空腹調整 +- たりないツールチップは4行固定のまま、横幅を内容に合わせて可変化。 +- コロニー統計に毎日0:00の灰色点線と日付ラベルを表示。 +- 持ち主IDが空、または持ち主が生存個体に存在しないぬいぐるみは削除。 +- きずつき病の個体は日光浴を開始せず、日光浴中に発症した場合も中断する。日光浴回復対象からも除外。 +- 病気中に体力切れ/ストレス過多で死亡した場合、病死として病名を優先記録するよう補強。 +- 空腹度合いの自然上昇幅を通常時・睡眠時ともに3/4倍へ調整。 + +## 39.15.136 水風船・扇風機差分 + +- 水風船の飛沫は雨滴と同じ `Item("water")` 生成経路に統一。専用タイプや専用IDは使わず、短時間だけ速度を持つ水滴として扱う。 +- 水風船の破裂飛沫を増量し、飛距離・初速・飛翔時間を拡大。 +- 水風船は射撃命中、爆竹/爆発病の爆風、げんこつ、火、鋲で破裂する。 +- 扇風機の本体角度と風向きを分離。風向き変更・首振りで変化するのは風範囲/矢印のみ。 +- 扇風機の風が火に当たると、火を単に弱めず、風下へ火種を飛ばして拡散する。 +- たりないが水風船で遊ぶ時の楽しさ・押し出し効果をボール寄りに調整。 + +## 39.15.118 土管ツールチップ・雨滴統一 +- 土管ツールチップは、どの土管にカーソルを合わせても土管内のたりないを一覧表示する。 +- 巣箱・土管に入り透明化済みのたりないは、個体ツールチップを出さない。 +- 土管内の体感温度補正は20℃へ最大±5℃まで寄せる。 +- 水風船破裂時に生成される水滴は、雨で生成される水滴と同じ初期化を使う。 + # tarinai_ Game Features 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. @@ -172,7 +195,7 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju - Service worker caching allows the static app shell to remain available after load, depending on browser support. -## 39.15.116 土管外観・内部温度 +## 39.15.117 土管外観・内部温度 - 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。 - 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。 diff --git a/app_manifest.json b/app_manifest.json index a7f37e2..0d17dce 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.116", + "version": "39.15.136", "css": [ "css/base.css", "css/layout.css", diff --git a/css/base.css b/css/base.css index f87ca66..d90a089 100644 --- a/css/base.css +++ b/css/base.css @@ -732,3 +732,28 @@ h1 { .fan-editor-controls label { grid-template-columns: 1fr; align-items: stretch; } .fan-editor-panel { width: calc(100vw - 16px); } } + +/* v39.15.130: only in-game overlay windows scroll when they exceed the viewport. + Right-panel cards and tool categories intentionally keep the older stable layout. */ +.field-dialog, +.signboard-editor, +.rotator-editor { + overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + align-items: flex-start; + justify-items: center; + box-sizing: border-box; + padding: 12px; +} + +.field-dialog-panel, +.signboard-editor-panel, +.rotator-editor-panel { + max-height: calc(100dvh - 24px); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + box-sizing: border-box; +} diff --git a/css/components.css b/css/components.css index 9d4c636..f78d734 100644 --- a/css/components.css +++ b/css/components.css @@ -802,7 +802,6 @@ /* Collapsed event card must shrink to its header. layout.css gives .log-card a min-height, which left a large blank panel when folded. */ -.collapsible-card.collapsed { min-height: 0 !important; } .log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; } .log-card.collapsed .panel-card-body { display: none !important; } @@ -839,3 +838,19 @@ .temperature-slider:disabled { opacity: 0.52; } + + +/* v39.15.131: keep the folded tool card usable when other right-side cards are expanded. */ +.tool-card.collapsed { + min-height: 64px !important; + height: auto !important; + flex: 0 0 auto !important; + flex-basis: auto !important; + overflow: visible !important; +} +.tool-card.collapsed .panel-card-toggle { + min-height: 46px; +} +.tool-card.collapsed .panel-card-body { + display: none !important; +} diff --git a/css/layout.css b/css/layout.css index 07707eb..9e4dbb5 100644 --- a/css/layout.css +++ b/css/layout.css @@ -4,7 +4,6 @@ .tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); } .tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); --tool-icon-zoom: 1.36; } .tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } -.tool[data-tool="area_delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } .tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); } .tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; } @@ -28,13 +27,13 @@ .tool[data-tool="reciprocator"].tool-emoji-icon::before, .tool[data-tool="undo"].tool-emoji-icon::before, .tool[data-tool="redo"].tool-emoji-icon::before { font-size: 29px; font-family: var(--font, sans-serif); } -.tool[data-tool="delete"], -.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; } +.tool[data-tool="delete"] { --tool-icon-zoom: 1.42; } +.tool[data-tool="area_delete"] { --tool-icon-zoom: 1.20; } .tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; } .tool[data-tool="area_delete"].tool-emoji-icon::before { font-size: 31px; line-height: 1; - color: #f3a7aa; + color: #5d7fb9; text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16); filter: none; transform: translateY(-54%) scale(var(--tool-icon-zoom, 1)); diff --git a/index.html b/index.html index f017603..648018b 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -107,7 +107,7 @@
気温10℃
-
+
@@ -191,25 +191,20 @@
エフェクト破片、雨、光の演出
- - - +
影地面の落ち影とスプライトの影
- - - +
描画装飾と解像度の設定
-
@@ -257,164 +252,164 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/collision_response_system.js b/js/collision_response_system.js index a7ba81f..ac4b020 100644 --- a/js/collision_response_system.js +++ b/js/collision_response_system.js @@ -238,10 +238,15 @@ const relativeSpeed = Math.hypot(relVx, relVy); const playIntent = t.state === "play_ball" || t.target === ball; const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); - const impulseBase = isBalloon ? 34 : 48; - const impulse = clamp(impulseBase + relativeSpeed * (isBalloon ? 0.48 : 0.72) + (playIntent ? (isBalloon ? 34 : 52) : 0) + (playful ? (isBalloon ? 18 : 24) : 0), impulseBase, isDangerous ? 360 : (isBalloon ? 165 : 260)); - ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon ? 0.32 : 0.52); - ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon ? 0.32 : 0.52); + const balloonPlayLikeBall = isBalloon && (playIntent || playful); + const impulseBase = isBalloon && !balloonPlayLikeBall ? 34 : 48; + const impulse = clamp( + impulseBase + relativeSpeed * (isBalloon && !balloonPlayLikeBall ? 0.48 : 0.72) + (playIntent ? 52 : 0) + (playful ? 24 : 0), + impulseBase, + isDangerous ? 360 : (isBalloon && !balloonPlayLikeBall ? 165 : 260) + ); + ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon && !balloonPlayLikeBall ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon && !balloonPlayLikeBall ? 0.32 : 0.52); + ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon && !balloonPlayLikeBall ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon && !balloonPlayLikeBall ? 0.32 : 0.52); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); diff --git a/js/fire_runtime_system.js b/js/fire_runtime_system.js index 461a019..964f26d 100644 --- a/js/fire_runtime_system.js +++ b/js/fire_runtime_system.js @@ -415,15 +415,17 @@ function updateFirecracker(item, dt, worldRef) { const existing = activeFires(worldRef).length; const limit = activeFireLimit(); const room = Math.max(0, limit - existing); - const desired = Math.max(5, Math.round(8 * Math.max(0.75, Math.min(1.4, (item.r || 15) / 15)))); - const count = Math.max(3, Math.min(desired, room > 0 ? room : 3)); + const sizeScale = clamp((Number(item.r || 15) || 15) / 15, 0.45, 3.0); + const flameMass = Math.pow(sizeScale, 1.28); + const desired = Math.max(2, Math.round(5 + 5.5 * flameMass)); + const count = Math.max(2, Math.min(desired, room > 0 ? room : Math.max(2, Math.round(2 + 2.5 * Math.min(flameMass, 1.6))))); for (let i = 0; i < count; i++) { const a = (Math.PI * 2 * i / count) + global.deterministicRange(worldRef, "flame-cracker-angle", -0.20, 0.20, item, i); - const d = global.deterministicRange(worldRef, "flame-cracker-radius", 28, 118, item, i); + const d = global.deterministicRange(worldRef, "flame-cracker-radius", 18 + 10 * sizeScale, 68 + 56 * flameMass, item, i); const fire = spawnFireAt(worldRef, item.x + Math.cos(a) * d, item.y + Math.sin(a) * d, { - radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 10, 18, item, i), - amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 55, 120, item, i), - life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 4.0, 8.5, item, i), + radius: global.deterministicRange(worldRef, "flame-cracker-fire-radius", 6 + 3 * sizeScale, 10 + 6.5 * flameMass, item, i), + amount: global.deterministicRange(worldRef, "flame-cracker-fire-amount", 22 + 34 * flameMass, 46 + 84 * flameMass, item, i), + life: global.deterministicRange(worldRef, "flame-cracker-fire-life", 2.4 + 1.0 * sizeScale, 4.6 + 3.2 * flameMass, item, i), source: item, }); if (fire) { @@ -433,7 +435,7 @@ function updateFirecracker(item, dt, worldRef) { } pushFireEffect(worldRef, item.x + Math.cos(a) * Math.min(d, 28), item.y + Math.sin(a) * Math.min(d, 28), 14, item); } - worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * 3.0), life: 0.36, color: "rgba(255,126,54,0.58)" })); + worldRef.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(42, (item.r || 15) * (2.4 + sizeScale * 0.65)), life: 0.30 + sizeScale * 0.045, color: "rgba(255,126,54,0.58)" })); item.amount = 0; worldRef.markItemBucketsDirty?.("flame-firecracker"); worldRef.markSpatialDirty?.("flame-firecracker"); diff --git a/js/ground_types.js b/js/ground_types.js index fe0a1c1..5f0e911 100644 --- a/js/ground_types.js +++ b/js/ground_types.js @@ -2,8 +2,8 @@ const GROUND_TYPE_ORDER = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); const GROUND_TYPES = Object.freeze({ - // \u30c7\u30d5\u30a9\u30eb\u30c8\u5730\u9762ID\u3002\u8868\u793a\u540d\u306f\u300c\u7802\u300d\u3002 - soil: Object.freeze({ id: "soil", label: "\u7802", description: "\u666e\u901a\u306e\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), + // \u30c7\u30d5\u30a9\u30eb\u30c8\u5730\u9762ID\u3002\u8868\u793a\u540d\u306f\u300c\u5730\u9762\u300d\u3002 + soil: Object.freeze({ id: "soil", label: "砂", description: "普通の砂地。", stressMultiplier: 1.0, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), dirt: Object.freeze({ id: "dirt", label: "\u571f", description: "\u8349\u304c\u3088\u304f\u80b2\u3064\u5730\u9762\u3002", stressMultiplier: 1.0, grassMultiplier: 1.75, grassGrowthMultiplier: 2.45 }), concrete: Object.freeze({ id: "concrete", label: "\u30b3\u30f3\u30af\u30ea\u30fc\u30c8", description: "\u8349\u304c\u751f\u3048\u306a\u3044", stressMultiplier: 1.0, grassMultiplier: 0.0, grassGrowthMultiplier: 0.0, grassLimit: 0 }), blanket: Object.freeze({ id: "blanket", label: "\u3075\u308f\u3075\u308f\u6bdb\u5e03", description: "\u30b9\u30c8\u30ec\u30b9\u304c\u5897\u3048\u306b\u304f\u3044", stressMultiplier: 0.5, grassMultiplier: 1.0, grassGrowthMultiplier: 1.0 }), @@ -21,7 +21,7 @@ function groundExists(id = "soil") { } function groundLabel(id = "soil") { - return groundDefinition(id).label || "\u7802"; + return groundDefinition(id).label || "\u5730\u9762"; } function normalizedGroundStressMultiplier(id = "soil") { @@ -64,7 +64,7 @@ function nextGroundTypeId(current = "soil") { function groundTooltipText(id = "soil") { const def = groundDefinition(id); const desc = def.description || "\u30af\u30ea\u30c3\u30af\u3067\u5207\u308a\u66ff\u3048"; - return `${def.label || "\u3058\u3081\u3093"}\n${desc}`; + return `${def.label || "\u5730\u9762"}\n${desc}`; } window.TarinaiGround = Object.freeze({ diff --git a/js/health.js b/js/health.js index c4fee5f..9885868 100644 --- a/js/health.js +++ b/js/health.js @@ -32,12 +32,14 @@ const HEALTH = (() => { dwarfDrug: "\u77ee\u5c0f\u85ac", freeze: "\u51CD\u7D50", heatstroke: "\u71B1\u4E2D\u75C7", + cleaning: "掃除", }); const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; function causeLabel(reason = "") { const text = String(reason || ""); if (!text) return ""; + if (/掃除|ロボ掃除機|robot.?cleaner|cleaned/.test(text)) return CAUSES.cleaning; if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); if (/\u51CD\u7D50|\u4F4E\u6E29|\u5BD2\u3055|cold|freeze|freezing/i.test(text)) return CAUSES.freeze; if (/\u71B1\u4E2D\u75C7|\u9AD8\u6E29|\u6691\u3055|heat|hot/i.test(text)) return CAUSES.heatstroke; @@ -163,7 +165,7 @@ const HEALTH = (() => { const cause = dominantDeathCause(t, text, opts) || CAUSES.accident; if (cause.includes(WEAKENED_SUFFIX)) return cause; if (opts.fromDamage) return cause; - const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.fire && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke; + const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.fire && cause !== CAUSES.weakness && cause !== CAUSES.freeze && cause !== CAUSES.heatstroke && cause !== CAUSES.cleaning; if (canWeaken && (opts.weakened || (hasLingeringMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause); return cause; } diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index fd1df43..d47cb8e 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -227,13 +227,27 @@ return ((base % twoPi) + twoPi) % twoPi; } + function fanWindAngleFor(item) { + return normalizeAngleLocal(item?.angle, item?.fanSwingCenter ?? 0); + } + + function ensureFanBodyAngle(item) { + if (!item || item.type !== "fan") return 0; + const body = Number(item.fanBodyAngle); + if (Number.isFinite(body)) return normalizeAngleLocal(body, item.angle || 0); + item.fanBodyAngle = normalizeAngleLocal(item.angle || item.fanSwingCenter || 0, 0); + return item.fanBodyAngle; + } + function updateFanSwing(item, dt) { if (!item || item.dead || item.type !== "fan") return false; + ensureFanBodyAngle(item); if (!item.fanSwingOn) { - item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : itemAngleFor(item); + item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : fanWindAngleFor(item); + item.angle = normalizeAngleLocal(item.fanSwingCenter, item.angle || 0); return false; } - const center = normalizeAngleLocal(item.fanSwingCenter, itemAngleFor(item)); + const center = normalizeAngleLocal(item.fanSwingCenter, fanWindAngleFor(item)); const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180); const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180); item.fanSwingCenter = center; @@ -250,7 +264,7 @@ const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62); const dx = x - fan.x; const dy = y - fan.y; - const angle = itemAngleFor(fan); + const angle = fanWindAngleFor(fan); const ax = Math.cos(angle); const ay = Math.sin(angle); const along = dx * ax + dy * ay; @@ -271,11 +285,12 @@ const now = worldRef?.time || 0; item.prevX = item.x; item.prevY = item.y; + ensureFanBodyAngle(item); item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt; item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt; updateFanSwing(item, frameDt); - const angle = itemAngleFor(item); + const angle = fanWindAngleFor(item); const ax = Math.cos(angle); const ay = Math.sin(angle); const range = 320; @@ -306,6 +321,46 @@ return true; } + function spreadFireByFan(fan, fire, wind, dt, worldRef) { + if (!fan || !fire || !worldRef || fire.dead || fire.type !== "fire") return false; + const now = worldRef.time || 0; + const falloff = Math.max(0, Number(wind?.falloff || 0) || 0); + if (falloff < 0.16 || (fire.amount || 0) < 18) return false; + const interval = Math.max(0.10, 0.34 - falloff * 0.18); + if ((fire.lastFanSpreadAt || -999) + interval > now) return false; + fire.lastFanSpreadAt = now; + const ax = Math.cos(wind.angle); + const ay = Math.sin(wind.angle); + const side = global.deterministicRange(worldRef, "fan-fire-side", -22, 22, fan, fire, Math.round(now * 10)); + const dist = global.deterministicRange(worldRef, "fan-fire-distance", 58, 118, fan, fire, Math.round(now * 10)) * (0.82 + falloff * 0.58); + const x = clamp((fire.x || 0) + ax * dist - ay * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.w || 0) - (CONFIG.worldPadding || 30))); + const y = clamp((fire.y || 0) + ay * dist + ax * side, CONFIG.worldPadding || 30, Math.max(CONFIG.worldPadding || 30, (worldRef.h || 0) - (CONFIG.worldPadding || 30))); + const spawned = global.TarinaiItemDynamicToolSystem?.spawnFireAt?.(worldRef, x, y, { + radius: Math.max(8, (fire.r || 14) * (0.55 + falloff * 0.22)), + amount: Math.max(18, Math.min(86, (fire.amount || 60) * (0.20 + falloff * 0.22))), + life: 2.2 + falloff * 3.4, + source: fan, + mergeRadius: 24, + }); + if (spawned) { + // A fan should carry embers forward instead of merely dimming the source flame. + // The source loses a little fuel, while the downwind flame handles normal fire contacts. + fire.amount = Math.max(4, (fire.amount || 0) - (2.2 + falloff * 3.8)); + worldRef.effects?.push(new Effect("flame", fire.x + ax * Math.max(12, fire.r || 14), fire.y + ay * Math.max(12, fire.r || 14), { + size: Math.max(7, (fire.r || 14) * 0.72), + life: 0.22, + color: "rgba(255,126,54,0.58)", + vx: ax * (80 + falloff * 120), + vy: ay * (80 + falloff * 120), + })); + worldRef.markItemBucketsDirty?.("fan-fire-spread"); + worldRef.markSpatialDirty?.("fan-fire-spread"); + worldRef.drawListDirty = true; + return true; + } + return false; + } + function applyFanToItem(fan, target, wind, dt, worldRef) { const type = target.type || ""; let massFactor = 0; @@ -314,7 +369,10 @@ else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90; else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42; else if (type === "zunchi") massFactor = 0.30; - else if (type === "fire") massFactor = 0.18; + else if (type === "fire") { + spreadFireByFan(fan, target, wind, dt, worldRef); + return; + } else return; const force = 236 * wind.falloff * massFactor; @@ -326,7 +384,6 @@ target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820); if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12); else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38); - if (type === "fire") target.amount = Math.max(0, (target.amount ?? 1) - wind.falloff * dt * 1.0); worldRef?.markSpatialDirty?.("fan-wind-item"); } @@ -353,8 +410,9 @@ const radius = Math.max(26, (balloon.r || 20) + 22); for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) { if (!it || it.dead || it === balloon) continue; - if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker") { - const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + (it.type === "fire" ? 16 : 4); + if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker" || it.type === "firecracker") { + const extra = it.type === "fire" ? 16 : (it.type === "firecracker" || it.type === "flame_firecracker" ? 8 : 4); + const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + extra; if (hit) { popBalloon(balloon, worldRef, it); return; @@ -410,22 +468,26 @@ function spawnWaterFromBalloon(balloon, worldRef) { if (!balloon || !worldRef) return 0; const baseR = Math.max(14, Number(balloon.r || 20) || 20); - const count = Math.max(7, Math.min(13, Math.round(baseR * 0.42))); + const count = Math.max(12, Math.min(22, Math.round(baseR * 0.68))); let spawned = 0; for (let i = 0; i < count; i++) { if (typeof global.Item !== "function") break; const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i); - const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.10, baseR * 0.58, balloon, i); - const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 150, 420, balloon, i); - const drop = new global.Item("water", balloon.x + Math.cos(a) * d, balloon.y + Math.sin(a) * d); - drop.r = deterministicRange(worldRef || balloon, "water-balloon-drop-radius", 5.5, 10.5, balloon, i); - drop.amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 10, 28, balloon, i); - drop.prevX = drop.x; - drop.prevY = drop.y; - drop.vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.18; - drop.vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.18; - drop.splashFlightTimer = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.30, 0.62, balloon, i); - drop.splashSourceId = balloon.id || ""; + const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.12, baseR * 0.95, balloon, i); + const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 280, 760, balloon, i); + const x = balloon.x + Math.cos(a) * d; + const y = balloon.y + Math.sin(a) * d; + const vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.28; + const vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.28; + const flight = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.42, 0.88, balloon, i); + const amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 24, 58, balloon, i); + const drop = global.TarinaiWeatherSystem?.createRainWaterItem + ? global.TarinaiWeatherSystem.createRainWaterItem(worldRef, { x, y, amount, vx, vy, splashFlightTimer: flight }) + : new global.Item("water", x, y); + if (!Number.isFinite(Number(drop.amount))) drop.amount = amount; + 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; } if (i < 8) { @@ -538,7 +600,11 @@ worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s })); } spawnWaterFromBalloon(balloon, worldRef); - const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" : (cause?.type === "genkotsu" ? "げんこつ" : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "何か")); + const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" + : (cause?.type === "firecracker" ? "爆竹" + : (cause?.type === "shoot" ? "射撃" + : (cause?.type === "genkotsu" ? "げんこつ" + : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "強い衝撃")))); if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) { worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] }); } diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index ddf31de..c3bc34a 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -9,6 +9,7 @@ switch (item.type) { case "firecracker": case "flame_firecracker": + case "sticky_bomb": case "fire": case "genkotsu": case "rotator": @@ -34,6 +35,10 @@ if (global.TarinaiItemDynamicPinSystem.isPin(item)) { return global.TarinaiItemDynamicPinSystem.update(item, dt, worldRef) !== false; } + if (item.isStructure && typeof item.update === "function") { + item.update(dt, worldRef); + return true; + } return false; } } diff --git a/js/item_dynamic_tool_system.js b/js/item_dynamic_tool_system.js index 2c18c1a..c5761fd 100644 --- a/js/item_dynamic_tool_system.js +++ b/js/item_dynamic_tool_system.js @@ -40,6 +40,128 @@ } + const STICKY_BOMB_PANIC_TEXT = "くっつきボムが付いてパニックになっている。"; + + function tarinaiById(worldRef, id = "") { + return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === id) || null; + } + + function panicStickyBombCarrier(t, item, worldRef, force = false) { + if (!t || t.dead) return false; + const now = Number(worldRef?.time || t.world?.time || 0) || 0; + if (!force && now < Number(item.stickyBombPanicCooldown || 0)) return false; + item.stickyBombPanicCooldown = now + 0.55; + t.thought = STICKY_BOMB_PANIC_TEXT; + t.lastPanicCause = "sticky_bomb"; + t.lastPanicDetail = STICKY_BOMB_PANIC_TEXT; + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.38); + t.fearTimer = Math.max(t.fearTimer || 0, 1.15); + if (typeof t.enterPanic === "function") { + t.enterPanic({ threat: item, target: item, reason: STICKY_BOMB_PANIC_TEXT, fear: 1.45, wake: true, cause: "sticky_bomb", surpriseTimer: 0.45, awakeLockTimer: 0.28, bubble: "!!", bubbleCooldown: 0.55 }); + } else { + t.setActionState?.("panic", { target: item, reason: STICKY_BOMB_PANIC_TEXT, wake: true, sleeping: false }); + } + if (t.addStress) t.addStress(1.8, { threshold: 5 }); + return true; + } + + function attachStickyBomb(item, carrier, worldRef, reason = "attach") { + if (!item || !carrier || carrier.dead) return false; + if (item.stickyBombCarrierId === carrier.id) return false; + const old = tarinaiById(worldRef, item.stickyBombCarrierId); + item.stickyBombCarrierId = carrier.id; + item.stickyBombTransferCooldown = 0.42; + item.vx = 0; + item.vy = 0; + item.x = carrier.x; + item.y = carrier.y - Math.max(10, (carrier.radius || 20) * 0.55); + item.prevX = item.x; + item.prevY = item.y; + item.noStickUntil = 0; + panicStickyBombCarrier(carrier, item, worldRef, true); + if (old && old !== carrier) { + old.surpriseTimer = Math.max(old.surpriseTimer || 0, 0.18); + old.thought = "くっつきボムが移った"; + } + if (worldRef?.effects && typeof Effect !== "undefined") { + worldRef.effects.push(new Effect("ring", item.x, item.y, { size: Math.max(16, (item.r || 16) * 1.45), life: 0.18, color: reason === "transfer" ? "rgba(255,210,72,0.52)" : "rgba(255,128,78,0.48)" })); + } + worldRef?.markItemBucketsDirty?.("sticky-bomb-attach"); + worldRef?.markSpatialDirty?.("sticky-bomb-attach"); + return true; + } + + function explodeStickyBomb(item, worldRef) { + if (!item || item.dead) return false; + item.stickyBombCarrierId = ""; + item.stickyBombTransferCooldown = 0; + item.blastScale = Number(item.blastScale || 1) || 1; + if (worldRef?.explodeFirecracker) { + return Boolean(worldRef.explodeFirecracker(item)); + } + item.amount = 0; + item.hp = 0; + audio.explode?.(); + worldRef?.log?.("くっつきボムが爆発した。", "accident"); + return true; + } + + function updateStickyBomb(item, dt, worldRef) { + if (!item || item.dead || item.type !== "sticky_bomb") return false; + const step = Math.max(0.016, Number(dt || 0.016) || 0.016); + item.fuseTimer = Math.max(0, Number(item.fuseTimer ?? 6) - step); + item.stickyBombTransferCooldown = Math.max(0, Number(item.stickyBombTransferCooldown || 0) - step); + item.spin = (Number(item.spin || 0) || 0) + (Number(item.spinVelocity || 0) || 0) * step; + if (item.fuseTimer <= 0) return explodeStickyBomb(item, worldRef); + + const carrier = tarinaiById(worldRef, item.stickyBombCarrierId); + if (carrier) { + const side = Number(carrier.facing || carrier.faceDir || 1) || 1; + item.x = carrier.x + side * Math.max(6, (carrier.radius || 20) * 0.32); + item.y = carrier.y - Math.max(10, (carrier.radius || 20) * 0.62); + item.prevX = item.x; + item.prevY = item.y; + item.vx = 0; + item.vy = 0; + panicStickyBombCarrier(carrier, item, worldRef, false); + if (item.stickyBombTransferCooldown <= 0) { + let best = null; + let bestD = Infinity; + for (const other of worldRef?.nearbyTarinai?.(carrier.x, carrier.y, Math.max(72, (carrier.radius || 20) * 3.0), true) || []) { + if (!other || other.dead || other === carrier || other.id === carrier.id) continue; + const d = distXY(carrier.x, carrier.y, other.x, other.y); + if (d <= Math.max(30, (carrier.radius || 18) + (other.radius || 18) + 10) && d < bestD) { best = other; bestD = d; } + } + if (best) attachStickyBomb(item, best, worldRef, "transfer"); + } + } else { + item.stickyBombCarrierId = ""; + item.prevX = item.x; + item.prevY = item.y; + item.x += (Number(item.vx || 0) || 0) * step; + item.y += (Number(item.vy || 0) || 0) * step; + item.vx *= Math.pow(0.65, step); + item.vy *= Math.pow(0.65, step); + const p = Math.max(28, CONFIG.worldPadding || 30); + item.x = clamp(item.x, p, (worldRef?.w || 1) - p); + item.y = clamp(item.y, p, (worldRef?.h || 1) - p); + let best = null; + let bestD = Infinity; + for (const t of worldRef?.nearbyTarinai?.(item.x, item.y, Math.max(72, (item.r || 16) + 56), true) || []) { + if (!t || t.dead) continue; + const d = distXY(item.x, item.y, t.x, t.y); + if (d <= Math.max(34, (item.r || 16) + (t.radius || 18) + 8) && d < bestD) { best = t; bestD = d; } + } + if (best) attachStickyBomb(item, best, worldRef, "attach"); + } + if (worldRef?.effects && typeof Effect !== "undefined" && item.fuseTimer < Math.max(1.2, (item.fuseMax || 6) * 0.35) && deterministicChance(worldRef, "sticky-bomb-spark", step * 5.8, item, Math.floor((worldRef.time || 0) * 20))) { + worldRef.effects.push(new Effect("fight", item.x, item.y - (item.r || 16) * 0.55, { size: 4 + Math.random() * 4, life: 0.16, color: "rgba(255,224,92,0.78)", vy: -20 })); + } + worldRef && (worldRef.drawListDirty = true); + return true; + } + + function updateFlexibleLink(item, dt, worldRef) { return Boolean(global.TarinaiConstraintSystem.updateFlexibleLink(item, dt, worldRef)); @@ -60,6 +182,7 @@ if (!item || item.dead) return false; if (item.type === "firecracker") return fireApi().updateFirecracker(item, dt, worldRef); if (item.type === "flame_firecracker") return fireApi().updateFlameFirecracker(item, dt, worldRef); + if (item.type === "sticky_bomb") return updateStickyBomb(item, dt, worldRef); if (item.type === "fire") return fireApi().updateFire(item, dt, worldRef); if (item.type === "genkotsu") return updateGenkotsu(item, dt, worldRef); if (item.type === "rotator") return updateRotator(item, dt, worldRef); @@ -96,6 +219,7 @@ igniteGrassByFire: (...args) => fireApi().igniteGrassByFire(...args), spawnFireAt: (...args) => fireApi().spawnFireAt(...args), scatterFireFrom: (...args) => fireApi().scatterFireFrom(...args), + updateStickyBomb, compactWorldFires: (...args) => fireApi().compactWorldFires(...args), }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 6dfb943..61f2f4f 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -1,5 +1,35 @@ "use strict"; + +function drawZunchiFallbackSprite(ctx, r = 15, alpha = 1) { + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.fillStyle = "rgba(102, 76, 42, 0.92)"; + ctx.strokeStyle = "rgba(54, 39, 23, 0.42)"; + ctx.lineWidth = Math.max(1.1, r * 0.08); + const lumps = [ + [-0.58, 0.12, 0.42, 0.26], + [-0.24, -0.05, 0.48, 0.32], + [0.20, 0.07, 0.50, 0.30], + [0.54, 0.16, 0.34, 0.22], + [0.02, -0.31, 0.34, 0.22], + ]; + for (const [x, y, w, h] of lumps) { + ctx.beginPath(); + ctx.ellipse(r * x, r * y, r * w, r * h, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.globalAlpha *= 0.48; + ctx.strokeStyle = "rgba(255, 245, 210, 0.86)"; + ctx.lineWidth = Math.max(1.0, r * 0.06); + ctx.beginPath(); + ctx.moveTo(-r * 0.78, -r * 0.20); + ctx.quadraticCurveTo(-r * 0.20, -r * 0.50, r * 0.58, -r * 0.16); + ctx.stroke(); + ctx.restore(); +} + // Item field rendering. Kept separate from construction and lifecycle logic. Object.assign(Item.prototype, { draw(ctx, t, lighting = null) { @@ -282,82 +312,76 @@ draw(ctx, t, lighting = null) { } else if (this.type === "pipe") { const r = this.r || 36; ctx.save(); - const body = styleNight ? "#506d5f" : (styleWarm ? "#7f9a72" : "#6f8d75"); - const side = styleNight ? "#3e574d" : (styleWarm ? "#637f5d" : "#587260"); - const lip = styleNight ? "#779083" : (styleWarm ? "#adc49b" : "#94b199"); - const lipDark = styleNight ? "#2f4640" : "#486858"; - const inner = styleNight ? "#0d1514" : "#152420"; - const outline = styleNight ? "rgba(9,15,15,0.86)" : "rgba(30,50,42,0.78)"; - const topY = -r * 0.42; - const bottomY = r * 0.54; - const topRx = r * 1.04; - const topRy = r * 0.43; - const bottomRx = r * 0.94; - const bottomRy = r * 0.34; + const side = styleNight ? "#3b544c" : (styleWarm ? "#648166" : "#577064"); + const lip = styleNight ? "#80998d" : (styleWarm ? "#b4c6af" : "#9fb7aa"); + const rimShadow = styleNight ? "#26413b" : "#4b6a60"; + const inner = styleNight ? "#0b1212" : "#14211f"; + const outline = styleNight ? "rgba(8,14,14,0.88)" : "rgba(26,46,40,0.80)"; + const mouthY = -r * 0.16; + const mouthRx = r * 1.05; + const mouthRy = r * 0.37; + const innerRx = r * 0.70; + const innerRy = r * 0.24; + const frontY = r * 0.34; + const frontRx = r * 0.97; + const frontRy = r * 0.30; - // Shadow under the visible front edge. - ctx.fillStyle = "rgba(18,24,22,0.20)"; + ctx.fillStyle = "rgba(18,24,22,0.21)"; ctx.beginPath(); - ctx.ellipse(0, r * 0.73, r * 1.12, r * 0.30, 0, 0, Math.PI * 2); + ctx.ellipse(0, frontY + r * 0.10, r * 1.36, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); - // Cylindrical side wall: the lower edge is visible because the pipe is viewed from above-front. + // Exposed cylindrical wall: only the front half is visible so the pipe feels buried, + // not like a free-standing jar. ctx.fillStyle = side; ctx.strokeStyle = outline; ctx.lineWidth = Math.max(2.2, r * 0.07); ctx.beginPath(); - ctx.moveTo(-topRx, topY); - ctx.bezierCurveTo(-topRx * 0.98, topY + r * 0.30, -bottomRx * 1.02, bottomY - r * 0.16, -bottomRx, bottomY); - ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, Math.PI, 0, true); - ctx.bezierCurveTo(topRx * 1.02, bottomY - r * 0.16, topRx * 0.98, topY + r * 0.30, topRx, topY); - ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI, true); + ctx.moveTo(-mouthRx * 0.92, mouthY + r * 0.03); + ctx.bezierCurveTo(-mouthRx * 0.98, mouthY + r * 0.22, -frontRx * 1.00, frontY - r * 0.06, -frontRx, frontY); + ctx.ellipse(0, frontY, frontRx, frontRy, 0, Math.PI, 0, true); + ctx.bezierCurveTo(frontRx * 1.00, frontY - r * 0.06, mouthRx * 0.98, mouthY + r * 0.22, mouthRx * 0.92, mouthY + r * 0.03); ctx.closePath(); ctx.fill(); ctx.stroke(); - // Front lower rim shade. - ctx.fillStyle = body; + // Ground collar around the mouth to make it read as a pipe opening in the ground. + ctx.fillStyle = "rgba(92,108,104,0.22)"; ctx.beginPath(); - ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, 0, Math.PI); - ctx.lineTo(bottomRx * 0.88, bottomY + r * 0.08); - ctx.ellipse(0, bottomY + r * 0.08, bottomRx * 0.88, bottomRy * 0.58, 0, 0, Math.PI, true); - ctx.closePath(); + ctx.ellipse(0, mouthY + r * 0.03, mouthRx * 1.08, mouthRy * 1.15, 0, 0, Math.PI * 2); ctx.fill(); - // Top lip and visible round opening. ctx.fillStyle = lip; ctx.strokeStyle = outline; ctx.lineWidth = Math.max(2.6, r * 0.08); ctx.beginPath(); - ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI * 2); + ctx.ellipse(0, mouthY, mouthRx, mouthRy, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = inner; - ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.88)" : "rgba(14,26,23,0.90)"; - ctx.lineWidth = Math.max(2.0, r * 0.065); - ctx.beginPath(); - ctx.ellipse(0, topY + r * 0.02, r * 0.64, r * 0.24, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.strokeStyle = lipDark; - ctx.lineWidth = Math.max(1.4, r * 0.045); - ctx.beginPath(); - ctx.ellipse(0, topY + r * 0.02, r * 0.78, r * 0.32, 0, Math.PI * 0.04, Math.PI * 0.96); - ctx.stroke(); - // Upper-left highlight for the rim and side. - ctx.globalAlpha = 0.34; - ctx.fillStyle = "rgba(255,255,255,0.86)"; + ctx.strokeStyle = rimShadow; + ctx.lineWidth = Math.max(1.6, r * 0.05); ctx.beginPath(); - ctx.ellipse(-r * 0.42, topY - r * 0.17, r * 0.24, r * 0.055, -0.10, 0, Math.PI * 2); - ctx.fill(); - ctx.globalAlpha = 0.22; - ctx.strokeStyle = "rgba(255,255,255,0.92)"; - ctx.lineWidth = Math.max(1.1, r * 0.026); - ctx.beginPath(); - ctx.moveTo(-r * 0.72, -r * 0.10); - ctx.bezierCurveTo(-r * 0.66, r * 0.18, -r * 0.62, r * 0.32, -r * 0.48, r * 0.50); + ctx.ellipse(0, mouthY + r * 0.01, mouthRx * 0.86, mouthRy * 0.70, 0, Math.PI * 1.02, Math.PI * 1.98); ctx.stroke(); + + ctx.fillStyle = inner; + ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.90)" : "rgba(14,24,22,0.92)"; + ctx.lineWidth = Math.max(2.0, r * 0.065); + ctx.beginPath(); + ctx.ellipse(0, mouthY + r * 0.01, innerRx, innerRy, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + ctx.save(); + ctx.globalAlpha = 0.24; + ctx.strokeStyle = "rgba(255,255,255,0.46)"; + ctx.lineWidth = Math.max(1.2, r * 0.035); + ctx.beginPath(); + ctx.moveTo(-mouthRx * 0.60, mouthY + r * 0.12); + ctx.quadraticCurveTo(-mouthRx * 0.68, frontY - r * 0.12, -mouthRx * 0.48, frontY + r * 0.12); + ctx.stroke(); + ctx.restore(); ctx.restore(); } else if (this.type === "robot_cleaner") { ctx.save(); @@ -737,10 +761,11 @@ draw(ctx, t, lighting = null) { ctx.restore(); } else if (this.type === "fan") { const r = this.r || 26; - const angle = itemAngleFor(this); + const bodyAngle = Number.isFinite(Number(this.fanBodyAngle)) ? normalizedItemAngle(Number(this.fanBodyAngle), itemAngleFor(this)) : itemAngleFor(this); + const windAngle = itemAngleFor(this); const spin = (this.fanSpin || 0) + t * 10.5; ctx.save(); - ctx.rotate(angle); + ctx.rotate(windAngle); const windRange = 320; const windStart = r * 1.02; const windSpread = 0.62; @@ -801,7 +826,7 @@ draw(ctx, t, lighting = null) { ctx.globalAlpha = fanAlpha; ctx.restore(); ctx.save(); - ctx.rotate(angle); + ctx.rotate(bodyAngle); if (itemShadowsEnabled()) { ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(52,40,26,0.16)"; ctx.shadowBlur = 3; @@ -902,6 +927,49 @@ draw(ctx, t, lighting = null) { ctx.fill(); } ctx.restore(); + } else if (this.type === "sticky_bomb") { + const r = this.r || 16; + const fuse = clamp((this.fuseTimer ?? 6) / Math.max(this.fuseMax || 6, 0.1), 0, 1); + const flash = fuse < 0.34 ? (0.5 + Math.sin(t * 24 + this.seed) * 0.5) : 0; + ctx.save(); + ctx.rotate((this.spin || 0) * 0.18 + Math.sin(t * 2.0 + this.seed) * 0.05); + ctx.fillStyle = flash > 0.62 ? "#ffdf4e" : "#26272d"; + ctx.strokeStyle = "rgba(12,13,18,0.86)"; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.78, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,0.20)"; + ctx.beginPath(); + ctx.ellipse(-r * 0.28, -r * 0.32, r * 0.20, r * 0.12, -0.55, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#d7d7d2"; + ctx.strokeStyle = "rgba(38,38,36,0.82)"; + ctx.lineWidth = 1.5; + roundedRect(ctx, -r * 0.28, -r * 0.98, r * 0.56, r * 0.28, r * 0.08); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = flash > 0.1 ? "rgba(255,238,90,0.95)" : "rgba(214,102,44,0.82)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(r * 0.08, -r * 1.02); + ctx.quadraticCurveTo(r * 0.42, -r * 1.30, r * 0.88, -r * 1.08); + ctx.stroke(); + ctx.fillStyle = flash > 0.1 ? "rgba(255,238,92,0.96)" : "rgba(255,92,42,0.80)"; + ctx.beginPath(); + ctx.arc(r * 0.92, -r * 1.06, 2.2 + flash * 2.0, 0, Math.PI * 2); + ctx.fill(); + if (this.stickyBombCarrierId) { + ctx.fillStyle = "rgba(255,86,70,0.92)"; + ctx.beginPath(); + ctx.moveTo(0, r * 0.72); + ctx.lineTo(-r * 0.22, r * 1.02); + ctx.lineTo(r * 0.22, r * 1.02); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); } else if (this.type === "firecracker" || this.type === "flame_firecracker") { const flameMode = this.type === "flame_firecracker"; const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1); @@ -1201,12 +1269,14 @@ draw(ctx, t, lighting = null) { } else if (this.type === "zunchi") { const zunchiId = this.zunchiVariant || "zunchi"; const img = images.get(zunchiId) || images.get("zunchi"); + const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46); + ctx.globalAlpha *= stageAlpha; if (img) { const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi"); const ratio = metrics?.ratio || 178 / 236; - const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46); - ctx.globalAlpha *= stageAlpha; ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio); + } else { + drawZunchiFallbackSprite(ctx, this.r, 1); } } else if (this.type === "trace") { ctx.fillStyle = "rgba(122, 100, 76, 0.12)"; diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index 4bd77fa..3089e86 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -6,7 +6,7 @@ const TOOL_DEFINITIONS = Object.freeze({ observe: { id: "observe", label: "\u305f\u308a\u306a\u3044\u30c7\u30fc\u30bf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, clear_tool: { id: "clear_tool", label: "\u89e3\u9664", placeable: false, scalable: false, iconText: "\u2715", tooltip: "\u9078\u629e\u4e2d\u306e\u9053\u5177\u30dc\u30bf\u30f3\u3092\u89e3\u9664\u3057\u3001\u901a\u5e38\u306e\u89b3\u5bdf\u72b6\u614b\u306b\u623b\u3059\u3002" }, delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, - area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" }, + area_delete: { id: "area_delete", label: "\u7bc4\u56f2\u524a\u9664", placeable: false, scalable: false, iconText: "▦", tooltip: "\u30ab\u30fc\u30bd\u30eb\u5468\u56f2\u306e\u5e83\u3044\u7bc4\u56f2\u306b\u3042\u308b\u7f6e\u304d\u3082\u306e\u3084\u6c5a\u308c\u3092\u307e\u3068\u3081\u3066\u6d88\u3059\u3002" }, copy: { id: "copy", label: "\u30b3\u30d4\u30fc", placeable: false, scalable: false, iconText: "\u29c9", tooltip: "\u7f6e\u3044\u305f\u30a2\u30a4\u30c6\u30e0\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30b3\u30d4\u30fc\u3057\u3001\u6b21\u306e\u30af\u30ea\u30c3\u30af\u3067\u8a2d\u5b9a\u3054\u3068\u8cbc\u308a\u4ed8\u3051\u308b\u3002" }, undo: { id: "undo", label: "\u623b\u3059", placeable: false, scalable: false, iconText: "\u21b6", tooltip: "\u76f4\u524d\u306e\u64cd\u4f5c\u3092\u53d6\u308a\u6d88\u3059\u3002" }, redo: { id: "redo", label: "\u9032\u3080", placeable: false, scalable: false, iconText: "\u21b7", tooltip: "\u53d6\u308a\u6d88\u3057\u305f\u64cd\u4f5c\u3092\u3084\u308a\u76f4\u3059\u3002" }, @@ -40,18 +40,19 @@ const TOOL_DEFINITIONS = Object.freeze({ genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, - pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u4e2d\u306b\u5165\u3063\u3066\u7720\u308c\u308b\u4e38\u3044\u571f\u7ba1\u3002\u51fa\u308b\u6642\u306f\u30e9\u30f3\u30c0\u30e0\u306a\u571f\u7ba1\u304b\u3089\u51fa\u308b\u3002\u5bdd\u5fc3\u5730\u306f\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u6bb5\u4e0b\u3002" }, + pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "移動できる地下空間。たりないが眠ることもある。" }, dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "水の入った風船。軽くて風に流される。画鋲・火・げんこつで割れ、水が飛び散って近くのたりないが驚く。" }, - fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。水風船やボールを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" }, + balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "たりないのおもちゃ。割れると水滴が飛び散る。" }, + fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。軽いものを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" }, signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, fire: { id: "fire", itemType: "fire", label: "\u708e", placeable: false, scalable: false, radius: 18, amount: 140, iconText: "\uD83D\uDD25", collisionShape: "circle", simulationOnly: true }, - flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u30bf\u30a4\u30de\u30fc\u5f8c\u306b\u7206\u767a\u305b\u305a\u3001\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" }, + flame_firecracker: { id: "flame_firecracker", itemType: "flame_firecracker", label: "\u706b\u708e\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, iconText: "\uD83E\uDDE8", collisionShape: "circle", tooltip: "\u5468\u56f2\u306b\u708e\u3092\u6492\u304d\u6563\u3089\u3059\u3002" }, + sticky_bomb: { id: "sticky_bomb", itemType: "sticky_bomb", label: "\u304f\u3063\u3064\u304d\u30dc\u30e0", placeable: true, scalable: false, radius: 16, amount: 999, iconText: "\uD83D\uDCA3", collisionShape: "circle", tooltip: "\u4e00\u5b9a\u6642\u9593\u3067\u7206\u767a\u3059\u308b\u7206\u5f3e\u3002\u4ed8\u7740\u3057\u305f\u500b\u4f53\u306f\u30d1\u30cb\u30c3\u30af\u306b\u306a\u308b\u3002\u4ed6\u500b\u4f53\u306b\u89e6\u308c\u308b\u3068\u30dc\u30e0\u304c\u305d\u3063\u3061\u306b\u79fb\u52d5\u3059\u308b\u3002" }, pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" }, fence_v: { id: "fence_v", itemType: "fence_v", label: "\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" }, diff --git a/js/item_tool_metadata.js b/js/item_tool_metadata.js index b7f08c1..a5fefc4 100644 --- a/js/item_tool_metadata.js +++ b/js/item_tool_metadata.js @@ -5,11 +5,11 @@ const ITEM_TRAITS = Object.freeze({ obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]), - hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + hazard: new Set(["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]), sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]), - draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), + draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "sticky_bomb", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), }); @@ -29,7 +29,7 @@ { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] }, { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] }, { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS }, - { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] }, + { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 742f264..4ca3e1b 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -8,7 +8,7 @@ function initializeItemTypeState(item, type, x, y) { if (type === "water" || type === "zunda_juice") item.roles.drink = true; if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true; - if (["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true; + if (["firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") { item.grassStage = Math.floor(rand(0, 2.999)); @@ -76,6 +76,7 @@ function initializeItemTypeState(item, type, x, y) { if (type === "fan") { item.amount = 999; item.angle = defaultItemAngle(type); + item.fanBodyAngle = item.angle; item.fanSpin = rand(0, Math.PI * 2); item.fanPulse = rand(0, Math.PI * 2); item.lastFanEffectAt = -999; @@ -111,6 +112,20 @@ function initializeItemTypeState(item, type, x, y) { item.fuseTimer = 5; item.fuseMax = 5; } + if (type === "sticky_bomb") { + item.fuseTimer = 6.0; + item.fuseMax = 6.0; + item.vx = rand(-8, 8); + item.vy = rand(-8, 8); + item.prevX = x; + item.prevY = y; + item.spin = rand(0, Math.PI * 2); + item.spinVelocity = rand(-2.8, 2.8); + item.stickyBombCarrierId = ""; + item.stickyBombTransferCooldown = 0; + item.stickyBombPanicCooldown = 0; + item.stickyBombArmedAt = 0; + } if (type === "fire") { item.amount = Math.max(Number(item.amount || 0) || 0, 120); item.r = Math.max(Number(item.r || 0) || 0, 14); diff --git a/js/item_update_policy.js b/js/item_update_policy.js index 58bc630..3a1bfdb 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -5,10 +5,10 @@ // helpers and the item scheduler both read this single policy instead of // duplicating item-type timing rules. (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]); + const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "robot_cleaner"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const SCHEDULED_ITEM_TYPES = Object.freeze([ - "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo", + "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "pushpin", "oshibyo", "plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", diff --git a/js/items.js b/js/items.js index ad4b7d8..31e5fda 100644 --- a/js/items.js +++ b/js/items.js @@ -599,6 +599,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { oshibyo: 0.62, firecracker: 0.90, flame_firecracker: 0.90, + sticky_bomb: 0.92, fire: 1.05, giant_drug: 1.22, dwarf_drug: 0.56, @@ -607,7 +608,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (isConsumableSpriteType(type)) scale *= 1.95; else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85; else if (type === "duplicator") scale *= 1.55; - else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08; + else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "sticky_bomb", "fire", "genkotsu"].includes(type)) scale *= 1.08; else if (isParamEffectItemType(type)) scale *= 1.35; if (watermark) scale *= 1.42; ctx.scale(scale, scale); diff --git a/js/perf_profiler.js b/js/perf_profiler.js index 1ac525b..e10a8e3 100644 --- a/js/perf_profiler.js +++ b/js/perf_profiler.js @@ -19,9 +19,9 @@ let lastSmoothAt = 0; const storageKey = "tarinai_visual_settings_v1"; const allowed = { - effects: new Set(["off", "low", "mid", "high"]), - shadows: new Set(["off", "low", "mid", "high"]), - details: new Set(["low", "mid", "high"]), + effects: new Set(["off", "high"]), + shadows: new Set(["off", "high"]), + details: new Set(["low", "high"]), }; const defaults = Object.freeze({ effects: "high", shadows: "high", details: "high" }); let visualSettings = loadVisualSettings(); @@ -83,12 +83,11 @@ const raw = String(value || ""); if (key === "effects" || key === "shadows") { if (raw === "off") return "off"; - if (raw === "on") return "high"; - if (raw === "low" || raw === "mid" || raw === "high") return raw; + if (raw === "on" || raw === "low" || raw === "mid" || raw === "high") return "high"; } if (key === "details") { if (raw === "low") return "low"; - if (raw === "mid" || raw === "high") return raw; + if (raw === "mid" || raw === "high") return "high"; } return defaults[key]; } @@ -120,7 +119,6 @@ function dprScaleForDetails(details) { if (details === "low") return 0.72; - if (details === "mid") return 0.86; return 1; } diff --git a/js/render.js b/js/render.js index b4301d5..a8694cf 100644 --- a/js/render.js +++ b/js/render.js @@ -762,9 +762,27 @@ function isBehindSpriteLayerItem(it) { function renderLayerSortY(entity) { if (!entity) return 0; - if (entity.type === "bed" || entity.type === "grass_bed") return (entity.y || 0) - Math.max(10, (entity.r || 20) * 0.45); - if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return (entity.y || 0) + Math.max(6, (entity.radius || 20) * 0.28); - return entity.y || 0; + const y = Number.isFinite(Number(entity.y)) ? Number(entity.y) : 0; + // Layering should be decided by the bottom edge/ground contact, not by the center. + // This prevents tall or large items from flipping in front/behind too early. + if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) { + return y + Math.max(8, (Number(entity.radius) || 20) * 0.86); + } + if (entity.kind === "queen" || entity.kind === "worker") return y + Math.max(4, Number(entity.size || 10) || 10); + const type = String(entity.type || ""); + const r = Math.max(0, Number(entity.r || entity.radius || entity.size || 0) || 0); + if (type === "bed" || type === "grass_bed") return y + Math.max(8, r * 0.72); + if (type === "nest_box") return y + Math.max(10, r * 0.76); + if (type === "pipe") return y + Math.max(8, r * 0.64); + if (type === "fan") return y + Math.max(10, r * 0.95); + if (type === "rotator" || type === "reciprocator" || type === "poison_block") { + const reach = TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem?.reach?.(entity) || r; + return y + Math.max(r, reach * 0.32); + } + if (type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") { + return y + Math.max(8, r * 0.52); + } + return y + Math.max(0, r); } function renderLayerKindRank(entity) { @@ -1240,8 +1258,9 @@ function drawGardenBed(w, h, lighting) { activeCtx.fill(); } - // edge grasses and tiny flowers - const edgeGrassCount = groundType === "laboratory" || groundType === "concrete" || groundType === "blanket" || groundType === "ice" ? 0 : fieldType === "park" ? 24 : fieldType === "cage" ? 5 : 14; + // Edge grass decorations were only visual and could be mistaken for actual grass items. + // Keep the field edge clear; real grass is rendered through item/terrain systems. + const edgeGrassCount = 0; for (let i = 0; i < edgeGrassCount; i++) { const side = i % 2; const px = side === 0 ? randSeed(5000 + i, x + 36, x + bw - 36) : randSeed(5400 + i, x + 30, x + bw - 30); @@ -1372,10 +1391,11 @@ function drawAtmosphericLighting(ctx, w, h, lighting) { ctx.restore(); } -function drawRain(ctx, w, h, t) { +function drawRain(ctx, w, h, t, opts = {}) { if (world.weather !== "light_rain") return; ctx.save(); - const count = 96; + const rainAlphaScale = Math.max(0.25, Math.min(1.25, Number(opts.alphaScale ?? 1) || 1)); + const count = Math.round(118 * rainAlphaScale); const angle = -0.32; // Rain visual effect: diagonal streaks, separate from fallen water item sprites. const slant = Math.tan(angle); for (let i = 0; i < count; i++) { @@ -1391,7 +1411,7 @@ function drawRain(ctx, w, h, t) { const alpha = y < 60 ? clamp((y + 90) / 150, 0, 1) : 1; const dx = Math.sin(angle) * len; const dy = Math.cos(angle) * len; - ctx.globalAlpha = (0.18 + 0.34 * randSeed(19000 + i, 0.35, 1.0)) * alpha; + ctx.globalAlpha = (0.22 + 0.40 * randSeed(19000 + i, 0.35, 1.0)) * alpha * rainAlphaScale; ctx.strokeStyle = "rgba(88, 150, 218, 0.86)"; ctx.lineWidth = randSeed(18700 + i, 0.8, 1.45); ctx.lineCap = "round"; @@ -1400,7 +1420,7 @@ function drawRain(ctx, w, h, t) { ctx.lineTo(x + dx * 0.5, y + dy * 0.5); ctx.stroke(); if (i % 4 === 0) { - ctx.globalAlpha = 0.13 * alpha; + ctx.globalAlpha = 0.16 * alpha * rainAlphaScale; ctx.strokeStyle = "rgba(255,255,255,0.70)"; ctx.lineWidth = 0.7; ctx.beginPath(); @@ -1585,7 +1605,7 @@ function render() { // Light affecting the whole visible scene, including characters and ground. const endLighting = renderPerfBegin("render.lightingWeather"); if (renderVisualLevel("details") !== "low") drawAtmosphericLighting(ctx, screenW, screenH, lighting); - if (effectLevel !== "off" && world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time); + if (world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time, { alphaScale: effectLevel === "off" ? 0.42 : 1 }); renderPerfEnd(endLighting); const endUiOverlay = renderPerfBegin("render.uiOverlay"); @@ -1635,10 +1655,15 @@ function drawPointerItemTooltip(ctx, world) { if (!target || !rawLines.length) return; ctx.save(); ctx.font = "12px Yomogi, sans-serif"; - const maxTextW = 238; + const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y }; + const canvasW = world.viewportW || world.w || ctx.canvas.width || 1; + const canvasH = world.viewportH || world.h || ctx.canvas.height || 1; + const isTarinaiTooltip = info?.kind === "tarinai"; + const maxTextW = isTarinaiTooltip ? Math.max(120, canvasW - 38) : 238; const ellipsize = (text, maxW) => canvasEllipsizeText(ctx, text, maxW); const wrapLine = (text, maxW) => { const s = String(text || ""); + if (isTarinaiTooltip) return [ellipsize(s, maxW)]; if (ctx.measureText(s).width <= maxW) return [s]; if (s.includes("\u3001")) { const parts = s.split("\u3001"); @@ -1659,14 +1684,13 @@ function drawPointerItemTooltip(ctx, world) { }; let lines = []; for (const line of rawLines) lines.push(...wrapLine(line, maxTextW)); - if (lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)]; + if (!isTarinaiTooltip && lines.length > 4) lines = [...lines.slice(0, 3), ellipsize(lines.slice(3).join("\u3001"), maxTextW)]; + if (isTarinaiTooltip) lines = lines.slice(0, 4); const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); const lineH = 17; - const w = Math.min(280, Math.max(92, textW + 22)); + const maxBoxW = isTarinaiTooltip ? Math.max(120, canvasW - 16) : 280; + const w = Math.min(maxBoxW, Math.max(92, textW + 22)); const h = Math.max(38, 16 + lines.length * lineH); - const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y }; - const canvasW = world.viewportW || world.w || ctx.canvas.width || 1; - const canvasH = world.viewportH || world.h || ctx.canvas.height || 1; const x = clamp(pos.x - w / 2, 8, canvasW - w - 8); const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, canvasH - h - 8); ctx.fillStyle = "rgba(46,38,30,0.66)"; diff --git a/js/robot_cleaner_system.js b/js/robot_cleaner_system.js index 203407a..66f6355 100644 --- a/js/robot_cleaner_system.js +++ b/js/robot_cleaner_system.js @@ -200,7 +200,8 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains const type = String(typeOverride || item.robotCleanerTargetType || target.type || ""); const label = robotTargetLabel(type); let removed = false; - const cleanReason = "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u5438\u3044\u8fbc\u307e\u308c\u305f"; + const cleanReason = "掃除"; + const cleanLogReason = "ロボ掃除機に吸い込まれた"; if (type === "tarinai" || isRobotTarinaiTarget(target)) { triggerRobotPanic(item, target, worldRef, true); if (typeof target.die === "function") { @@ -215,7 +216,7 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains } else { removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, target, { reason: "robot-cleaner", - userReason: type === "grass_bed" ? "\u30ed\u30dc\u6383\u9664\u6a5f\u306b\u7247\u4ed8\u3051\u3089\u308c\u305f" : cleanReason, + userReason: type === "grass_bed" ? "ロボ掃除機に片付けられた" : cleanLogReason, wake: true, breaker: item, })); diff --git a/js/save_codec.js b/js/save_codec.js index 1121602..addf7aa 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -26,6 +26,7 @@ const FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("firecracker"); const FLAME_FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("flame_firecracker"); const BALLOON_TYPE_ID = ITEM_TYPE_IDS.indexOf("balloon"); + const STICKY_BOMB_TYPE_ID = ITEM_TYPE_IDS.indexOf("sticky_bomb"); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -636,6 +637,8 @@ writer.s(extra[0] || 0); } else if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) { // firecracker/flame/fire writer.s(extra[0] || 0); + } else if (typeId === STICKY_BOMB_TYPE_ID) { // sticky bomb + writer.s(extra[0] || 0); writer.s(extra[1] ?? -1); } else if (PIN_TYPE_IDS.has(typeId)) { const target = extra[1] ?? -1; let mask = 0; @@ -701,6 +704,7 @@ if (typeId === 23) return [reader.s(), reader.s()]; if (typeId === 24) return [reader.s()]; if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()]; + if (typeId === STICKY_BOMB_TYPE_ID) return [reader.s(), reader.s()]; if (PIN_TYPE_IDS.has(typeId)) { const [state, mask] = readBitFields(reader, [2, 4]); return [ diff --git a/js/save_schema.js b/js/save_schema.js index 330eaa1..8ed69e3 100644 --- a/js/save_schema.js +++ b/js/save_schema.js @@ -4,8 +4,8 @@ // Owns stable save identifiers shared by snapshot_system.js and save_codec.js. // Current-format save identifiers only. (function (global) { - const SNAPSHOT_VERSION = 19; - const BINARY_SCHEMA_VERSION = 24; + const SNAPSHOT_VERSION = 20; + const BINARY_SCHEMA_VERSION = 25; const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); @@ -21,7 +21,7 @@ "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe" + "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe", "sticky_bomb" ]); function typeIds(names) { diff --git a/js/snapshot_system.js b/js/snapshot_system.js index 20d971f..c0b8c41 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -295,6 +295,7 @@ if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; if (type === "ant_corpse") return [q(item.decayTimer, 10)]; if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)]; + if (type === "sticky_bomb") return [q(item.fuseTimer, 10), tarinaiIndex.get(item.stickyBombCarrierId) ?? -1]; if (type === "fire") return [q(item.flameLife, 10)]; if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)]; if (type === "fan") return [ @@ -304,6 +305,7 @@ q(item.fanSwingRange ?? (35 * Math.PI / 180), 1000), q(item.fanSwingSpeed ?? (48 * Math.PI / 180), 1000), q(item.fanSwingPhase || 0, 1000), + q(item.fanBodyAngle ?? item.angle ?? 0, 1000), ]; if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0]; if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)]; @@ -368,6 +370,8 @@ item.decayTimer = u(extra[0], 10); } else if (type === "firecracker" || type === "flame_firecracker") { item.fuseTimer = u(extra[0], 10); item.fuseMax = Math.max(item.fuseMax || 5, item.fuseTimer || 0); + } else if (type === "sticky_bomb") { + item.fuseTimer = u(extra[0], 10, item.fuseTimer || 6); item.fuseMax = Math.max(item.fuseMax || 6, item.fuseTimer || 0); item.stickyBombCarrierId = tarinaiList[extra[1]]?.id || ""; } else if (type === "fire") { item.flameLife = u(extra[0], 10, item.flameLife || 6); } else if (typeof global.isPinType === "function" && global.isPinType(type)) { @@ -380,6 +384,7 @@ item.fanSwingRange = Math.max(0, Math.min(150 * Math.PI / 180, u(extra[3], 1000, 35 * Math.PI / 180))); item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * Math.PI / 180))); item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0); + item.fanBodyAngle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[6], 1000, item.angle), item.angle) : u(extra[6], 1000, item.angle); } else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) { const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback); diff --git a/js/structures.js b/js/structures.js index 089ab6d..1785ab4 100644 --- a/js/structures.js +++ b/js/structures.js @@ -71,6 +71,8 @@ this.dead = true; this.hp = 0; this.amount = 0; + global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, { type: "fling_out", name: "画面外" }); + global.TarinaiStructureLifecycle?.triggerOwnedStructureDestroyedPanic?.(worldRef, this, null, "plushie-fling-out"); worldRef?.markItemBucketsDirty?.("plushie-fling-out"); worldRef?.markSpatialDirty?.("plushie-fling-out"); } @@ -78,7 +80,7 @@ return; } const owner = this.owner(worldRef); - if (!owner) { + if (!this.ownerId || !owner) { this.dead = true; this.hp = 0; this.amount = 0; @@ -170,7 +172,7 @@ dy = -0.24; len = Math.hypot(dx, dy); } - const base = Math.max(520, Math.min(980, Number(options.speed || 650) || 650)); + const base = Math.max(520 / 3, Math.min(980 / 3, Number(options.speed || (650 / 3)) || (650 / 3))); vx = dx / len * base; vy = dy / len * base - Math.max(80, base * 0.18); if (options.source === "poke") { @@ -179,14 +181,14 @@ vy = Math.min(vy, -base * 0.26); } speed = Math.hypot(vx, vy); - } else if (speed < 520) { - const scale = 520 / Math.max(1, speed); + } else if (speed < 520 / 3) { + const scale = (520 / 3) / Math.max(1, speed); vx *= scale; vy *= scale; speed = Math.hypot(vx, vy); } - this.vx = clamp(vx, -1100, 1100); - this.vy = clamp(vy, -1100, 980); + this.vx = clamp(vx, -1700, 1700); + this.vy = clamp(vy, -1350, 1100); this.prevX = this.x; this.prevY = this.y; this.detachedUntil = world ? Math.max(this.detachedUntil || 0, (Number(world.time || 0) || 0) + 0.35) : (this.detachedUntil || 0); @@ -194,7 +196,7 @@ const spinSign = Math.sign(this.vx) || (Math.random() < 0.5 ? -1 : 1); const autoSpin = spinSign * (11 + Math.min(14, speed / 80)); this.spinVelocity = clamp(Number(options.spinVelocity || autoSpin) || autoSpin, -28, 28); - this.plushieFlingTimer = Math.max(1.3, Math.min(3.4, Number(options.life || 2.7) || 2.7)); + this.plushieFlingTimer = Math.max(1.3, Math.min(options.destroyOffscreen ? 5.4 : 3.4, Number(options.life || 2.7) || 2.7)); this.plushieTrailTimer = 0; this.carriedById = ""; this.onHead = false; diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index d6b8810..5fa83a1 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -243,6 +243,15 @@ function createTemperatureComfortActionSpec() { if (!target) return false; t.target = target; if (distXY(t.x, t.y, target.x, target.y) < Math.max(24, (t.radius || 20) * 0.9)) { + const shelter = target.shelterItem && !target.shelterItem.dead + ? target.shelterItem + : ((target.type === "nest_box" || target.type === "pipe") ? target : null); + if (shelter && (shelter.type === "nest_box" || shelter.type === "pipe")) { + if (typeof t.enterNestBox === "function" && t.enterNestBox(shelter, dt, { purpose: "temperature" })) { + t.thought = shelter.type === "pipe" ? "土管の中で気温をしのいでいる" : "巣箱の中で気温をしのいでいる"; + return true; + } + } const next = this.selectTarget(t, world, { force: true, requireComfort: true }); if (!next || distXY(t.x, t.y, next.x, next.y) < Math.max(28, (t.radius || 20))) return "finished"; t.target = next; @@ -439,7 +448,7 @@ function createIntimidateEnemyActionSpec() { const rival = ctx.target ?? this.selectTarget(t, world, ctx); if (!isLiveTarinaiEntity(rival) || rival === t || rival.isTarinaiChampion) return false; t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); - t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.85); + t.intimidateTimer = Math.max(t.intimidateTimer || 0, 0.42); return true; }, tick(t, world) { diff --git a/js/tarinai_behavior_text.js b/js/tarinai_behavior_text.js index a27b360..f048da3 100644 --- a/js/tarinai_behavior_text.js +++ b/js/tarinai_behavior_text.js @@ -145,9 +145,23 @@ function causeForBehaviorText(tarinai, behavior = {}, action = null, options = { return causePhraseForReason(behavior?.need || action?.need || "fulfill", action, tarinai, options); } + +function hasVisibleFireThreatForBehavior(tarinai) { + if (!tarinai || !tarinai.world) return false; + const target = tarinai.target || currentBehaviorTarget?.(tarinai) || null; + if (target && !target.dead && (target.type === "fire" || target.type === "flame_firecracker" || target.burning || (target.burnTimer || 0) > 0.02)) return true; + if ((tarinai.firePanicTimer || 0) > 0.02) return true; + const fires = typeof tarinai.world.itemsOfType === "function" ? tarinai.world.itemsOfType("fire") : (tarinai.world.items || []).filter(it => it && it.type === "fire"); + return (fires || []).some(f => f && !f.dead && (f.amount || 0) > 0 && distXY(tarinai.x, tarinai.y, f.x, f.y) <= Math.max(190, (f.r || 14) + 180)); +} + function composeTarinaiBehaviorText(tarinai, behavior = null, options = {}) { if (tarinai && (tarinai.burning || (tarinai.burnTimer || 0) > 0.02)) return globalThis.TarinaiItemDynamicToolSystem?.fireBurningText || "\u71c3\u3048\u3066\u9003\u3052\u307e\u3069\u3063\u3066\u3044\u308b\u3002"; - if (tarinai && ((tarinai.firePanicTimer || 0) > 0.02 || (tarinai.state === "panic" && tarinai.lastPanicCause === "fire_seen"))) return globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002"; + if (tarinai && hasVisibleFireThreatForBehavior(tarinai)) return globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002"; + if (tarinai && tarinai.lastPanicCause === "fire_seen" && !hasVisibleFireThreatForBehavior(tarinai)) { + tarinai.lastPanicCause = ""; + if (tarinai.lastPanicDetail === (globalThis.TarinaiItemDynamicToolSystem?.fireSeenText || "\u708e\u3092\u898b\u3066\u9a5a\u3044\u3066\u3044\u308b\u3002")) tarinai.lastPanicDetail = ""; + } const b = behavior || (currentTarinaiBehavior(tarinai)) || null; if (!b) return ""; const stored = String(b.text || b.reason || "").trim(); diff --git a/js/tarinai_contact_item_system.js b/js/tarinai_contact_item_system.js index e8893f4..84d2fa2 100644 --- a/js/tarinai_contact_item_system.js +++ b/js/tarinai_contact_item_system.js @@ -74,6 +74,13 @@ const d = contact.distance; if (it.type === "ball" || it.type === "balloon") { const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1); + if (touch > 0.02) { + const now = tarinai.world?.time || 0; + if (typeof tarinai.adjustPersonality === "function" && now >= (tarinai.nextToyOpennessAt || 0)) { + tarinai.nextToyOpennessAt = now + rand(3.2, 5.6); + tarinai.adjustPersonality("openness", 0.0011 + touch * 0.0015, "after touching a toy"); + } + } if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) { tarinai.goodMode = "smile"; applyNeedRelief(tarinai, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) }); diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 6324dd5..7f8c2f6 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -263,7 +263,8 @@ const alreadyInside = this.insideNestBoxId === box.id; const now = this.world?.time || 0; if (!alreadyInside && occupants.length >= capacity) { - if (options?.forceDisplace) { + const kingSleepDisplace = Boolean(this.isTarinaiChampion && box.type === "nest_box" && (this.state === "seek_bed" || this.state === "sleep" || this.sleeping)); + if (options?.forceDisplace || kingSleepDisplace) { const victim = occupants.find(t => t && t !== this && !t.dead && !t.isTarinaiChampion) || occupants.find(t => t && t !== this && !t.dead); if (victim) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index c0b0bf4..3a2eca6 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -982,11 +982,12 @@ function resolveNeedsCore(dt) { const currentFelt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15); if (Number.isFinite(Number(currentFelt)) && Number(currentFelt) >= 22) return false; if ((this.sunbathCooldown || 0) > 0) return false; + if (this.fightDisease) return false; if (this.stuckPushpinId || this.currentLodgedPin?.()) return false; if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_water", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false; if (this.hunger > (options.leisure ? 62 : 72) || this.energy < (options.leisure ? 24 : 16)) return false; const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; - const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; + const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease; if (sick && healthNeed >= 34) return true; return options.leisure ? healthNeed < needThreshold("health", "start") : healthNeed >= Math.max(38, needThreshold("health", "start") - 12); }, @@ -994,13 +995,12 @@ function resolveNeedsCore(dt) { tryRecoverBySunbath() { if (this.sunbathRecoveryChecked) return false; this.sunbathRecoveryChecked = true; - const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; - if (!sick || Math.random() >= 0.75) return false; + const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease; + if (!sick || Math.random() >= 0.25) return false; let healed = false; if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("\u65e5\u5149\u6d74\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u3063\u305f") || healed; if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("\u65e5\u5149\u6d74\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f") || healed; if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("\u65e5\u5149\u6d74\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f") || healed; - if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("\u65e5\u5149\u6d74\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f") || healed; if (healed) this.world?.log?.(`${this.name}\u306f\u65e5\u5149\u6d74\u3067\u75c5\u6c17\u304c\u8efd\u304f\u306a\u3063\u305f\u3002`, "event", { participants: [this] }); return healed; }, diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index 1f32293..13ecf0e 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -89,6 +89,7 @@ if (sleepTargetBlocked(worldRef, tarinai, item)) return false; const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1; const occupancy = bedOccupancy(worldRef, item); + if (tarinai?.isTarinaiChampion && item.type === "nest_box") return true; return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id); } diff --git a/js/tarinai_social_action_runtime.js b/js/tarinai_social_action_runtime.js index 1a03375..cb24e17 100644 --- a/js/tarinai_social_action_runtime.js +++ b/js/tarinai_social_action_runtime.js @@ -223,7 +223,7 @@ function updatePanicBehavior(t, world, dt, needs) { if (t.state !== "panic") t.setActionState?.("panic", { target: nest, reason: kingFleeReason, wake: true, sleeping: false }); t.fearTimer = Math.max(t.fearTimer || 0, 0.34); if (dist(t, nest) <= Math.max(50, (t.radius || 20) + (nest.r || 24) + 18)) { - t.enterNestBox?.(nest, dt, { forceDisplace: true, reason: kingFleeReason }); + t.enterNestBox?.(nest, dt, { forceDisplace: false, reason: kingFleeReason }); } return true; } diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 8a6df91..9930b60 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -56,6 +56,7 @@ if (d > maxDist) continue; const padding = Math.max(16, (tarinai.radius || 20) * 0.72); if (typeof worldRef.targetBlockedByObstacle === "function" && worldRef.targetBlockedByObstacle(tarinai, item, padding)) continue; + if (typeof worldRef.sleepPlaceAvailableFor === "function" && !worldRef.sleepPlaceAvailableFor(tarinai, item)) continue; const occupants = typeof worldRef.nestBoxOccupants === "function" ? worldRef.nestBoxOccupants(item, Infinity) : []; const capacity = typeof worldRef.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(item) : 5; const fullnessPenalty = Math.max(0, occupants.length - capacity + 1) * 18; @@ -97,7 +98,7 @@ avoider.thought = TARINAI_KING_FLEE_REASON; avoider.fearTimer = Math.max(avoider.fearTimer || 0, 1.05 * avoider.personalityProfile().fear); if (nest && distXY(avoider.x, avoider.y, nest.x, nest.y) <= Math.max(50, (avoider.radius || 20) + (nest.r || 24) + 18)) { - avoider.enterNestBox?.(nest, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON }); + avoider.enterNestBox?.(nest, dt, { forceDisplace: false, reason: TARINAI_KING_FLEE_REASON }); } return true; } @@ -323,7 +324,7 @@ this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1); - this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115); + this.hunger = clamp(this.hunger + dt * 0.018 * 0.75 * sleepHungerScale, 0, 115); if (this.target && !this.target.dead && isNestContainerItem(this.target)) { this.updateNestBoxPresence(dt); this.vx = 0; @@ -671,13 +672,20 @@ checkLife(dt = 0.016) { let reason = ""; + const activeDiseaseDeathReason = () => { + if (this.zunchiDisease) return "ずんち病"; + if (this.sleepDisease) return "ねむり病"; + if (this.explosionDisease) return "爆発病"; + if (this.fightDisease) return "きずつき病"; + return ""; + }; if (this.energy <= 0) { - reason = this.temperatureDeathReason?.() || this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; + reason = this.temperatureDeathReason?.() || activeDiseaseDeathReason() || this.dominantDeathCause("", { lowHealth: true }) || "事故"; } - else if (this.hunger >= 108) reason = "\u98e2\u9913"; - else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; + else if (this.hunger >= 108) reason = "飢餓"; + else if (this.age >= this.lifeSpan) reason = "天寿を全うした"; else if (this.stress >= 124) { - reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; + reason = activeDiseaseDeathReason() || this.dominantDeathCause("ストレス過多", { stressDeath: true }) || "ストレス過多"; } if (reason) this.die(reason); }, diff --git a/js/tarinai_sunbath_system.js b/js/tarinai_sunbath_system.js index 8aefdaa..dcd1244 100644 --- a/js/tarinai_sunbath_system.js +++ b/js/tarinai_sunbath_system.js @@ -24,7 +24,7 @@ } this.energy = clamp((this.energy || 0) + dt * 0.03, 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(this) : (Number(this.maxEnergy) || 100)); const felt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15); - if (!this.isSunbathTime() || Number(felt) >= 22 || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; + if (!this.isSunbathTime() || Number(felt) >= 22 || this.hunger > 82 || this.energy < 8 || this.state === "panic" || this.fightDisease || this.stuckPushpinId || this.currentLodgedPin?.()) this.sunbathTimer = 0; if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { this.finishSunbath?.(); this._activeSunbathSpriteId = ""; diff --git a/js/tarinai_update_step_ai.js b/js/tarinai_update_step_ai.js index 3fe2c3a..446d60d 100644 --- a/js/tarinai_update_step_ai.js +++ b/js/tarinai_update_step_ai.js @@ -6,6 +6,13 @@ (function (global) { function update(t, dt, context = {}) { if (!t || t.dead) return { done: true }; + if (t.playerHeld || t._heldByPlayer) { + t.aiTimer = 0; + t.target = null; + t.targetKey = ""; + t.updateFacing?.(); + return { done: false, held: true }; + } t.behaviorLockTimer = Math.max(0, (t.behaviorLockTimer || 0) - dt); t.aiTimer += dt; const urgentFoodNeed = Number(t.needRaw?.food ?? t.needs?.food ?? 0) || 0; diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index 199ccb5..42d9978 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -52,7 +52,13 @@ applyNeedShock(t, { safety: dt * 4 }); if (t.state !== "fight") t.fearTimer = Math.max(t.fearTimer || 0, 0.10); } - if (t.intimidateTimer <= 0.04) t.intimidateTargetId = null; + if (t.intimidateTimer <= 0.04) { + t.intimidateTargetId = null; + if (t.state === "intimidate" || t.state === "ant_intimidate") { + t.spriteLockUntil = 0; + t.setActionState?.("idle", { target: null, reason: "威嚇を終えた", clearTarget: true, sleeping: false }); + } + } if (t.birthRitualTimer > 0) { const beforeBirthTimer = t.birthRitualTimer; t.birthRitualTimer = Math.max(0, t.birthRitualTimer - dt); @@ -147,7 +153,7 @@ const tempStatus = t.temperatureStatus || t.world?.temperatureStatusFor?.(t.tempComfort ?? (CONFIG.standardTemperature ?? 15), t) || { discomfort: 0, stressExcess: 0, harmExcess: 0, direction: "comfort" }; const coldDiscomfort = tempStatus.direction === "cold" ? Math.max(0, tempStatus.discomfort || 0) : 0; const hungerScale = t.metabolicHungerMultiplier ? t.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(t) : 1); - t.hunger += CONFIG.hungerPerMinute * minute * t.trait.hunger * hungerScale * (1 + coldDiscomfort * 0.018); + t.hunger += CONFIG.hungerPerMinute * minute * 0.75 * t.trait.hunger * hungerScale * (1 + coldDiscomfort * 0.018); t.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - t.trait.social); t.energy -= CONFIG.energyPerMinute * minute * t.trait.sleep * (t.state === "sleep" ? 0.025 : 1); if (t.state === "sleep" || t.sleeping) { diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index e7c560b..6a2ad1e 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -223,6 +223,18 @@ function update(t, dt, context = {}) { if (!t || t.dead) return { done: true }; + if (t.playerHeld || t._heldByPlayer) { + t.vx = 0; + t.vy = 0; + t.impulseVx = 0; + t.impulseVy = 0; + t.target = null; + t.targetKey = ""; + t.updateNestBoxPresence?.(dt); + t._lastPhysicalVelocityX = 0; + t._lastPhysicalVelocityY = 0; + return { done: false, held: true }; + } const motionDt = Math.max(0.001, Math.min(0.05, Number(context.motionDt ?? dt) || 0.016)); if (context.motionOnly && /^(sleep|eat|sunbath|birth_ritual|breed|reproduction|intimidate|ant_intimidate)$/.test(String(t.state || ""))) { return passivePhysicalMotion(t, motionDt, context); diff --git a/js/ui_charts.js b/js/ui_charts.js index e90ed4d..481fc10 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -2,7 +2,7 @@ function colonyMoodTip() { const mood = world?.evaluateColonyMood?.(true) || world?.colonyMood || null; - return String(mood?.description || "\u5927\u304d\u306a\u554f\u984c\u304c\u5c11\u306a\u304f\u3001\u843d\u3061\u7740\u3044\u305f\u72b6\u614b\u3067\u3059\u3002"); + return String(mood?.description || "大きな問題が少なく、落ち着いた状態です。"); } function bindColonyMoodTooltip() { @@ -14,24 +14,29 @@ function bindColonyMoodTooltip() { window.TarinaiTooltips.bind(target, colonyMoodTip); } +function colonyChartObjectKeys() { + return ["grass", "zunchi", "trace", "grass_bed", "water"]; +} + function renderStats() { const alive = world.tarinai; const avg = (fn) => alive.length ? alive.reduce((a, t) => a + fn(t), 0) / alive.length : 0; const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const needAverages = Object.fromEntries(needKeys.map(key => [`need_${key}`, avg(t => Number(t.needs?.[key] || 0) || 0)])); + const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15); const values = { pop: alive.length, dead: world.deadCount, stress: avg(t => t.stress), + temperature: Number(temp) || 0, ...needAverages, objects: { ...(world.itemCounts || {}) }, }; setTextIfChanged(ui.statPop, "pop", values.pop); setTextIfChanged(ui.statDead, "dead", values.dead); - const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "\u5b89\u5b9a" }; - setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "\u5b89\u5b9a"); - const temp = world.updateTemperature?.(0) ?? world.currentTemperature ?? (CONFIG.standardTemperature ?? 15); - const tempLabel = `${Math.round(temp)}\u2103`; + const colonyMood = world.evaluateColonyMood?.() || world.colonyMood || { label: "安定" }; + setTextIfChanged(ui.statColonyMood, "colonyMood", colonyMood?.label || "安定"); + const tempLabel = `${Math.round(temp)}℃`; setTextIfChanged(ui.temperatureValue, "temperature", tempLabel); if (ui.temperatureSlider) { ui.temperatureSlider.disabled = world.temperatureAuto !== false; @@ -60,7 +65,7 @@ function updateColonyHistory(values) { uiCache.chartBucketAccum = null; uiCache.lastChartDraw = ""; } - const objectKeys = ["grass", "zunchi", "trace", "grass_bed", "water"]; + const objectKeys = colonyChartObjectKeys(); const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const makeBucket = (start) => ({ start, @@ -69,6 +74,7 @@ function updateColonyHistory(values) { sums: { pop: 0, stress: 0, + temperature: 0, ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), }, objects: Object.fromEntries(objectKeys.map(k => [k, 0])), @@ -85,6 +91,7 @@ function updateColonyHistory(values) { t: bucket.end, pop: bucket.sums.pop / bucket.count, stress: bucket.sums.stress / bucket.count, + temperature: bucket.sums.temperature / bucket.count, ...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])), ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), }; @@ -102,22 +109,63 @@ function updateColonyHistory(values) { bucket.count += 1; bucket.sums.pop += values.pop || 0; bucket.sums.stress += values.stress || 0; + bucket.sums.temperature += Number(values.temperature || 0) || 0; for (const k of needKeys) bucket.sums[`need_${k}`] += values[`need_${k}`] || 0; for (const k of objectKeys) bucket.objects[k] += (values.objects || {})[k] || 0; } function chartRows(values) { - const bucket = uiCache.chartBucketAccum; - const objectKeys = ["grass", "zunchi", "trace", "grass_bed", "water"]; + // The colony chart is sampled in two-hour buckets. Do not append a live + // preview row here; it makes the graph redraw every frame even though the + // recorded statistics have not advanced yet. + if (uiCache.chartHistory.length) return uiCache.chartHistory.slice(); + const objectKeys = colonyChartObjectKeys(); const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; - const preview = bucket && bucket.count > 0 ? { - t: Math.max(world.time || 0, bucket.start || 0), - pop: bucket.sums.pop / bucket.count, - stress: bucket.sums.stress / bucket.count, - ...Object.fromEntries(needKeys.map(k => [`need_${k}`, (bucket.sums[`need_${k}`] || 0) / bucket.count])), - ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, bucket.objects[k] / bucket.count])), - } : null; - return uiCache.chartHistory.length ? [...uiCache.chartHistory, ...(preview ? [preview] : [])] : [preview || { t: world.time || 0, ...values }]; + return [{ + t: Math.max(0, Number(uiCache.chartBucketStart || 0) || 0), + pop: 0, + stress: 0, + temperature: 0, + ...Object.fromEntries(needKeys.map(k => [`need_${k}`, 0])), + ...Object.fromEntries(objectKeys.map(k => [`obj_${k}`, 0])), + }]; +} + +function chartSeasonDayLabelForTime(t = 0) { + const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); + const dayNumber = Math.floor(Math.max(0, Number(t || 0) || 0) / dayLength) + 1; + const seasonIndex = Math.floor(((dayNumber - 1) % 20) / 5); + const season = ["春", "夏", "秋", "冬"][seasonIndex] || "春"; + const seasonDay = ((dayNumber - 1) % 5) + 1; + return `${season}${seasonDay}日`; +} + +function niceChartMax(raw = 0) { + const value = Math.max(1, Number(raw) || 0); + const pow = Math.pow(10, Math.floor(Math.log10(value))); + const unit = value / pow; + const nice = unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10; + return nice * pow; +} + +function chartScaleForRows(mode, rows, series) { + if (mode === "life") return { min: 0, max: 100, percent: true }; + const vals = []; + for (const s of series || []) for (const row of rows || []) vals.push(Number(row?.[s.key] || 0)); + const finite = vals.filter(Number.isFinite); + const minRaw = Math.min(0, ...finite); + const maxRaw = Math.max(1, ...finite); + const max = niceChartMax(maxRaw); + const min = minRaw < 0 ? -niceChartMax(Math.abs(minRaw)) : 0; + return { min, max, percent: false }; +} + +function chartAxisLabel(value, scale) { + const n = Number(value) || 0; + if (scale?.percent) return String(Math.round(n)); + if (Math.abs(n) >= 1000) return `${Math.round(n / 100) / 10}k`; + if (Math.abs(n) >= 10) return String(Math.round(n)); + return String(Math.round(n * 10) / 10); } function drawColonyChart(values) { @@ -133,7 +181,9 @@ function drawColonyChart(values) { const last = rows[rows.length - 1] || values; const series = chartSeries(mode); const seriesSnapshot = series.map(s => `${s.key}:${Math.round(Number(last[s.key] || 0) * 10)}`).join(","); - const snapshot = `${mode}:${rows.length}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`; + const dayLength = Math.max(1, Number(CONFIG.dayLength || 120) || 120); + const visibleDay = Math.floor(Math.max(0, Number(world.time || 0) || 0) / dayLength); + const snapshot = `${mode}:${rows.length}:${visibleDay}:${seriesSnapshot}:${chart.clientWidth}x${chart.clientHeight}`; if (uiCache.lastChartDraw === snapshot) return; uiCache.lastChartDraw = snapshot; const cssW = Math.max(280, Math.floor(chart.clientWidth || chart.offsetWidth || 320)); @@ -141,18 +191,37 @@ function drawColonyChart(values) { const pad = { l: 30, r: 12, t: 12, b: 24 }; const w = cssW - pad.l - pad.r; const h = cssH - pad.t - pad.b; - const maxVal = Math.max(10, ...series.flatMap(s => rows.map(r => Number(r[s.key] || 0)))); - const xFor = (i) => rows.length <= 1 ? pad.l + w : pad.l + (i / (rows.length - 1)) * w; + const scale = chartScaleForRows(mode, rows, series); + const times = rows.map(r => Math.max(0, Number(r.t || 0) || 0)); + const minT = Math.min(...times, Math.max(0, Number(world.time || 0) || 0)); + const maxT = Math.max(...times, Math.max(0, Number(world.time || 0) || 0), minT + 1); + const xForTime = (t) => pad.l + clamp((Number(t || 0) - minT) / Math.max(1, maxT - minT), 0, 1) * w; + const xFor = (i) => xForTime(rows[i]?.t ?? maxT); const yFor = (row, s) => { const raw = Number(row[s.key] || 0); - const scale = s.percent ? 100 : maxVal; - const v = clamp(raw / Math.max(scale, 1) * 100, 0, 100); - return pad.t + h - h * v / 100; + if (scale.percent) { + const v = clamp(raw / 100, 0, 1); + return pad.t + h - h * v; + } + const denom = Math.max(1, scale.max - scale.min); + const v = clamp((raw - scale.min) / denom, 0, 1); + return pad.t + h - h * v; }; const grid = [0, 25, 50, 75, 100].map(yv => { const y = pad.t + h - h * yv / 100; - return `${yv}`; + const rawLabel = scale.percent ? yv : scale.min + (scale.max - scale.min) * yv / 100; + return `${chartAxisLabel(rawLabel, scale)}`; }).join(""); + const firstDay = Math.ceil(minT / dayLength); + const lastDay = Math.floor(maxT / dayLength); + const dayMarkers = []; + for (let d = firstDay; d <= lastDay; d += 1) { + const t = d * dayLength; + if (t < minT - 0.001 || t > maxT + 0.001) continue; + const x = xForTime(t); + const label = chartSeasonDayLabelForTime(t); + dayMarkers.push(`${label}`); + } const lines = series.map(s => { const pts = rows.map((row, i) => `${xFor(i).toFixed(1)},${yFor(row, s).toFixed(1)}`).join(" "); const last = rows[rows.length - 1]; @@ -160,32 +229,33 @@ function drawColonyChart(values) { const cy = yFor(last, s).toFixed(1); return ``; }).join(""); - chart.innerHTML = ``; + chart.innerHTML = ``; if (ui.colonyChartLegend) ui.colonyChartLegend.innerHTML = series.map(s => `${s.label} ${formatChartValue(s.key, rows[rows.length - 1] || values)}`).join(""); } - function chartSeries(mode = "population") { if (mode === "life") return [ - { key: "stress", label: "\u30b9\u30c8\u30ec\u30b9", color: "#d96262", percent: true }, - { key: "need_food", label: "\u6442\u990c", color: "#e4a33f", percent: true }, - { key: "need_sleep", label: "\u7761\u7720", color: "#6f8edc", percent: true }, - { key: "need_health", label: "\u5065\u5eb7", color: "#2e9d68", percent: true }, - { key: "need_safety", label: "\u5b89\u5168", color: "#d65e46", percent: true }, - { key: "need_social", label: "\u95a2\u4fc2", color: "#9b78d4", percent: true }, - { key: "need_fulfill", label: "\u5145\u8db3", color: "#0072b2", percent: true }, + { key: "stress", label: "ストレス", color: "#d96262", percent: true }, + { key: "need_food", label: "摂餌", color: "#e4a33f", percent: true }, + { key: "need_sleep", label: "睡眠", color: "#6f8edc", percent: true }, + { key: "need_health", label: "健康", color: "#2e9d68", percent: true }, + { key: "need_safety", label: "安全", color: "#d65e46", percent: true }, + { key: "need_social", label: "関係", color: "#9b78d4", percent: true }, + { key: "need_fulfill", label: "充足", color: "#0072b2", percent: true }, ]; if (mode === "objects") return [ - { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, - { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f5a2f" }, - { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, - { key: "obj_grass_bed", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", color: "#78ff45" }, - { key: "obj_water", label: "\u96e8\u6ef4", color: "#5aa8d6" }, + { key: "obj_grass", label: "草", color: "#5b9d61" }, + { key: "obj_zunchi", label: "ずんち", color: "#6f5a2f" }, + { key: "obj_trace", label: "死骸", color: "#8a7054" }, + { key: "obj_grass_bed", label: "かんたんベッド", color: "#78ff45" }, + { key: "obj_water", label: "雨滴", color: "#5aa8d6" }, + { key: "temperature", label: "気温", color: "#c46f3c" }, ]; - return [{ key: "pop", label: "\u500b\u4f53", color: "#5d8ee6" }]; + return [{ key: "pop", label: "個体", color: "#5d8ee6" }]; } function formatChartValue(key, values) { const v = values[key] ?? 0; + if (key === "temperature") return `${Math.round(Number(v) || 0)}℃`; return String(Math.round(v)); } diff --git a/js/ui_family_async.js b/js/ui_family_async.js index 8be6a25..7fbaf05 100644 --- a/js/ui_family_async.js +++ b/js/ui_family_async.js @@ -156,7 +156,9 @@ function familyStatusText(n) { function familyDeathReasonLabel(rawReason, entity = null) { const text = String(rawReason || ""); if (!text) return ""; - if (entity?.briefDeathReason) return entity.briefDeathReason(text); + // The event log already records the normalized final death reason. Use the + // same stored value here so the family tree and event UI cannot diverge. + return text; const collisionText = text.replace(/\u3067\u8870\u5f31.*$/, ""); const collisionWith = collisionText.match(/([^\u3001\u3002\s]+?)(?:\u306b|\u3068\u306e)\u885d\u7a81/); if (collisionWith) { diff --git a/js/ui_family_layout.js b/js/ui_family_layout.js index 04f0df2..165b699 100644 --- a/js/ui_family_layout.js +++ b/js/ui_family_layout.js @@ -53,7 +53,7 @@ function lineageTreeNodeHtml(n, x, y) { \u4F53${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "size")))} \u653B${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "attack")))} \u901F${escapeHtml(lineageSignedPercentText(lineageGeneticPercentFromNode(n, "speed")))} - \u52DD${escapeHtml(lineageFightRateText(n))}` : ""; + ` : ""; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const childCls = (n?.parents || []).length ? " child" : ""; const iconBaseScale = live diff --git a/js/ui_family_paths.js b/js/ui_family_paths.js index 83025fe..c9a839a 100644 --- a/js/ui_family_paths.js +++ b/js/ui_family_paths.js @@ -42,6 +42,66 @@ function lineageVerticalLaneClear(x, y1, y2, rects) { return !(rects || []).some(r => x > r.left && x < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom)); } +function lineageRouteNodeRects(positions, nodeW, nodeH, excludeIds = new Set(), pad = 8) { + const rects = []; + for (const pos of positions?.values?.() || []) { + const id = pos?.node?.id; + if (!pos || (id && excludeIds.has(id))) continue; + rects.push({ + left: pos.x - pad, + right: pos.x + nodeW + pad, + top: pos.y - pad, + bottom: pos.y + nodeH + pad, + }); + } + return rects; +} + +function lineageHorizontalLaneClear(y, x1, x2, rects) { + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + return !(rects || []).some(r => y > r.top && y < r.bottom && Math.max(left, r.left) < Math.min(right, r.right)); +} + +function lineageLanePenalty(y, x1, x2, rects) { + let penalty = 0; + for (const r of rects || []) { + const overlap = Math.max(0, Math.min(Math.max(x1, x2), r.right) - Math.max(Math.min(x1, x2), r.left)); + if (overlap <= 0) continue; + if (y > r.top && y < r.bottom) penalty += 100000 + overlap; + else penalty += Math.max(0, 26 - Math.min(Math.abs(y - r.top), Math.abs(y - r.bottom))) * 3; + } + return penalty; +} + +function lineageChooseHorizontalDetourY(y, x1, x2, a, b, nodeW, nodeH, rects, preferredY = NaN, context = {}) { + const rowTop = Math.min(a.y, b.y); + const rowBottom = Math.max(a.y, b.y) + nodeH; + const minY = Math.max(8, Number(context.minRouteY ?? 10) || 10); + const maxY = Number.isFinite(Number(context.height)) ? Math.max(minY, Number(context.height) - 8) : Number.POSITIVE_INFINITY; + const candidates = []; + if (Number.isFinite(preferredY)) candidates.push(preferredY); + // Escaping above the first generation is allowed, but keep the route inside + // the visible SVG instead of letting it disappear above the canvas. + candidates.push(rowTop - 48, rowTop - 32, rowTop - 18, rowBottom + 18, rowBottom + 32, rowBottom + 48); + const unique = Array.from(new Set(candidates + .map(v => clamp(Math.round(v * 10) / 10, minY, maxY)))) + .filter(Number.isFinite); + unique.sort((ya, yb) => { + const ca = lineageLanePenalty(ya, x1, x2, rects) + Math.abs(ya - y) * 0.08; + const cb = lineageLanePenalty(yb, x1, x2, rects) + Math.abs(yb - y) * 0.08; + return ca - cb; + }); + return unique[0] ?? clamp(y, minY, maxY); +} + +function lineageAutoSideOffsetForLane(laneY, rowTop, rowBottom, nodeH) { + if (!Number.isFinite(laneY)) return 0; + if (laneY < rowTop - 2) return -nodeH * 0.30; + if (laneY > rowBottom + 2) return nodeH * 0.30; + return 0; +} + function lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context = {}) { const ay = a.y + nodeH / 2; const by = b.y + nodeH / 2; @@ -76,31 +136,53 @@ function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context = return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: railX, unionY: Math.max(aMidY, bMidY) }; } -function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) { +function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) { const left = a.x <= b.x ? a : b; const right = left === a ? b : a; - const midYLeft = left.y + nodeH / 2 + attachOffsetA; - const midYRight = right.y + nodeH / 2 + attachOffsetB; + let leftOffset = left === a ? attachOffsetA : attachOffsetB; + let rightOffset = right === a ? attachOffsetA : attachOffsetB; const x1 = left.x + nodeW; const x2 = right.x; if (x2 <= x1) return { d: "", segments: [] }; - const direct = left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10 + const exclude = new Set([left.node?.id, right.node?.id].filter(Boolean)); + const rects = lineageRouteNodeRects(context.positions, nodeW, nodeH, exclude, 10); + let midYLeft = left.y + nodeH / 2 + leftOffset; + let midYRight = right.y + nodeH / 2 + rightOffset; + const directY = (midYLeft + midYRight) / 2; + const directClear = lineageHorizontalLaneClear(directY, x1, x2, rects); + const direct = directClear && left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10 && (!Number.isFinite(unionY) || unionY <= Math.max(midYLeft, midYRight) + 16); if (direct) { - const y = (midYLeft + midYRight) / 2; + const y = directY; return { d: `M ${x1.toFixed(1)} ${y.toFixed(1)} L ${x2.toFixed(1)} ${y.toFixed(1)}`, segments: [{ kind: "h", x1, y1: y, x2, y2: y }] }; } - const bend = 10; - const y = Number.isFinite(unionY) ? unionY : Math.max(midYLeft, midYRight); + const gap = x2 - x1; + const bend = Math.max(10, Math.min(24, gap * 0.20)); + const y = directClear && Number.isFinite(unionY) && lineageHorizontalLaneClear(unionY, x1, x2, rects) + ? clamp(unionY, 10, Number.isFinite(Number(context.height)) ? Number(context.height) - 8 : Number.POSITIVE_INFINITY) + : lineageChooseHorizontalDetourY(directY, x1, x2, left, right, nodeW, nodeH, rects, unionY, context); + // If the route escapes above/below the row, attach from the corresponding + // side portion of the node instead of forcing every edge out of side-center. + const autoOffset = lineageAutoSideOffsetForLane(y, Math.min(left.y, right.y), Math.max(left.y, right.y) + nodeH, nodeH); + if (Math.abs(autoOffset) > 0.1) { + const minOffset = -nodeH * 0.38; + const maxOffset = nodeH * 0.38; + leftOffset = clamp(leftOffset + autoOffset, minOffset, maxOffset); + rightOffset = clamp(rightOffset + autoOffset, minOffset, maxOffset); + midYLeft = left.y + nodeH / 2 + leftOffset; + midYRight = right.y + nodeH / 2 + rightOffset; + } + const xLeftBend = Math.min(x1 + bend, x2 - 4); + const xRightBend = Math.max(x2 - bend, x1 + 4); const points = [ - [x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight] + [x1, midYLeft], [xLeftBend, midYLeft], [xLeftBend, y], [xRightBend, y], [xRightBend, midYRight], [x2, midYRight] ]; - return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y }; + return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (xLeftBend + xRightBend) / 2, unionY: y }; } function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) { if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context); - return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB); + return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB, context); } function lineageChildAnchorOnMarriageLine(partner, fallbackX, fallbackY) { const horizontals = (partner?.segments || []) @@ -395,8 +477,8 @@ function lineageBuildLinks(nodes, idSet, layout) { // Marriage line stays at the parent-node center. The child trunk drops // to a visible point below the parents before branching, so the fork is // never hidden behind a node. - const attachA = 0; - const attachB = 0; + const attachA = group.attachOffsets?.get?.(left.node?.id) || 0; + const attachB = group.attachOffsets?.get?.(right.node?.id) || 0; const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB, layout); const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY; diff --git a/js/ui_ground.js b/js/ui_ground.js index 5f33404..264615b 100644 --- a/js/ui_ground.js +++ b/js/ui_ground.js @@ -27,7 +27,7 @@ function updateGroundUI(worldRef = null) { const uiRef = global.ui || {}; const def = groundDef(worldRef); - const label = def.label || "\u7802"; + const label = def.label || "\u5730\u9762"; const statEl = uiRef.statGroundType || document.getElementById("statGroundType"); const btnEl = uiRef.groundTypeBtn || document.getElementById("groundTypeBtn"); if (statEl) setTextIfChanged(statEl, "groundType", label); diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 7fdd528..9c4a1bf 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -247,12 +247,22 @@ target.prevY = target.y; } if (uiCache.grabKind === "item") syncGrabbedPhysicsItem(target, "pinch-move"); - target.vx = clamp(uiCache.grabVelocityX || dx * 16, -820, 820); - target.vy = clamp(uiCache.grabVelocityY || dy * 16, -820, 820); if (uiCache.grabKind === "tarinai") { + // While a Tarinai is held, its AI/passive physics must not keep the last + // drag velocity. Otherwise the body starts sliding when the cursor is + // stopped but the button is still down. + target.vx = 0; + target.vy = 0; + target.impulseVx = 0; + target.impulseVy = 0; + target._lastHeldByPlayerAt = world.time || 0; target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - } else if ((target.type === "ball" || target.type === "balloon")) { + } else { + target.vx = clamp(uiCache.grabVelocityX || dx * 16, -820, 820); + target.vy = clamp(uiCache.grabVelocityY || dy * 16, -820, 820); + } + if ((target.type === "ball" || target.type === "balloon")) { target.prevX = target.x - dx; target.prevY = target.y - dy; target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); @@ -303,6 +313,10 @@ target.playerHeld = false; target._heldByPlayer = false; if (uiCache.grabKind === "tarinai") { + target.vx = 0; + target.vy = 0; + target.impulseVx = 0; + target.impulseVy = 0; target.pinchThoughtTimer = 0; if (target.thought === "つままれている") target.thought = ""; const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(target) : null; diff --git a/js/ui_log.js b/js/ui_log.js index 9df3cd6..43b46bb 100644 --- a/js/ui_log.js +++ b/js/ui_log.js @@ -84,8 +84,11 @@ function pushLogNotification(entry = {}) { window.pushLogNotification = pushLogNotification; function shouldShowInEventLog(entry = {}) { - if (!entry) return false; - if (entry.eventType === "player_item_placement") return false; + if (!entry || entry.hiddenFromObservation) return false; + const type = String(entry.eventType || ""); + const text = String(entry.text || ""); + if (type === "player_item_placement" || type.includes("player") || type.includes("tool") || type.includes("robot")) return false; + if (/ロボ掃除機|掃除機/.test(text)) return false; return true; } diff --git a/js/version.js b/js/version.js index 8dff869..8557af3 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.116"; - const APP_BUILD = "tarinai-pipe-v39-15-116"; + const APP_VERSION = "39.15.136"; + const APP_BUILD = "tarinai-pipe-v39-15-136"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/weather_system.js b/js/weather_system.js index 2550529..86cf8c0 100644 --- a/js/weather_system.js +++ b/js/weather_system.js @@ -37,9 +37,29 @@ ); } - function createRainWaterItem(worldRef) { - const drop = new Item("water", rand(58, worldRef.w - 58), rand(58, worldRef.h - 58)); - drop.amount = rand(24, 58); + function createRainWaterItem(worldRef, opts = {}) { + const px = Number(opts?.x); + const py = Number(opts?.y); + const hasX = Number.isFinite(px); + const hasY = Number.isFinite(py); + const x = hasX ? px : rand(58, worldRef.w - 58); + const y = hasY ? py : rand(58, worldRef.h - 58); + const drop = new Item("water", x, y); + const amount = Number(opts?.amount); + drop.amount = Number.isFinite(amount) ? amount : rand(24, 58); + // Rain drops, placed water drops, and water-balloon splashes all use the + // same Item("water") object. Optional velocity only gives that same water + // item a short ballistic motion; it is not a separate splash item type. + const vx = Number(opts?.vx); + const vy = Number(opts?.vy); + if (Number.isFinite(vx) || Number.isFinite(vy)) { + drop.prevX = drop.x; + drop.prevY = drop.y; + drop.vx = Number.isFinite(vx) ? vx : 0; + drop.vy = Number.isFinite(vy) ? vy : 0; + } + const flight = Number(opts?.splashFlightTimer ?? opts?.flightTimer); + if (Number.isFinite(flight) && flight > 0) drop.splashFlightTimer = flight; return drop; } diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 1612380..d1086fa 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -82,7 +82,7 @@ }, explodeFirecracker(it) { - if (!it || it.dead || it.type !== "firecracker") return; + if (!it || it.dead || (it.type !== "firecracker" && it.type !== "sticky_bomb")) return false; it.amount = 0; const x = it.x, y = it.y; const blastRadius = 240 * (it.blastScale || 1); @@ -165,6 +165,11 @@ const a = deterministicAngle(this, "firecracker-ball-overlap", it, ball); dx = Math.cos(a); dy = Math.sin(a); d = 1; } + if (ball.type === "balloon") { + globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, this, it); + blastedBalls += 1; + continue; + } const blast = 430 + p * 960; this.applyImpulse(ball, dx / d * blast + deterministicRange(this, "firecracker-ball-jitter-x", -80, 80, it, ball), dy / d * blast + deterministicRange(this, "firecracker-ball-jitter-y", -80, 80, it, ball)); ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "firecracker-ball-spin", -24, 24, it, ball), -38, 38); @@ -176,6 +181,7 @@ const blastedPins = this.blastLoosePinsFrom(x, y, blastRadius, it, it.blastScale || 1, "firecracker"); audio.explode(); this.log((blastedBalls || blastedPins) ? "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8efd\u3044\u7269\u304c\u6025\u52a0\u901f\u3057\u305f\u3002" : "\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u8fba\u308a\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident"); + return true; }, explodeDiseaseTarinai(t) { @@ -254,6 +260,10 @@ const a = deterministicAngle(this, "disease-explosion-ball-overlap", t, ball); dx = Math.cos(a); dy = Math.sin(a); d = 1; } + if (ball.type === "balloon") { + globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, this, t); + continue; + } const blast = 430 + q * 960; this.applyImpulse(ball, dx / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-x", -80, 80, t, ball), dy / d * blast * blastScale + deterministicRange(this, "disease-explosion-ball-jitter-y", -80, 80, t, ball)); ball.spinVelocity = clamp((ball.spinVelocity || 0) + deterministicRange(this, "disease-explosion-ball-spin", -24, 24, t, ball), -38, 38); diff --git a/js/world_family_social.js b/js/world_family_social.js index 5faa1f3..6c1d70d 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -463,11 +463,38 @@ nestBoxTooltipLines(box) { if (!box || box.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(box.type) : (box.type === "nest_box" || box.type === "pipe"))) return []; - const occupants = this.nestBoxOccupants(box, Infinity); - const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5); - const more = occupants.length > names.length ? `\u3001\u307b\u304b${occupants.length - names.length}\u5339` : ""; - const label = box.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; - return [label, names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "\u5185\u90E8\u6C17\u6E29")].filter(Boolean); + const isPipe = box.type === "pipe"; + let occupants = []; + if (isPipe) { + const seen = new Set(); + const pipes = (this.items || []).filter(it => it && !it.dead && it.type === "pipe"); + for (const pipe of pipes) { + const direct = this.nestBoxOccupants?.(pipe, Infinity) || []; + for (const t of direct) { + if (!t || t.dead) continue; + const key = t.id || t.name || `${t.x},${t.y}`; + if (seen.has(key)) continue; + seen.add(key); + occupants.push(t); + } + } + for (const t of (this.tarinai || [])) { + if (!t || t.dead || !t.insideNestBoxId) continue; + const holder = this.nestBoxById?.(t.insideNestBoxId) || pipes.find(it => it.id === t.insideNestBoxId) || null; + if (!holder || holder.dead || holder.type !== "pipe") continue; + const key = t.id || t.name || `${t.x},${t.y}`; + if (seen.has(key)) continue; + seen.add(key); + occupants.push(t); + } + } else { + occupants = this.nestBoxOccupants(box, Infinity); + } + const names = occupants.map(t => t?.name).filter(Boolean).slice(0, isPipe ? 8 : 5); + const more = occupants.length > names.length ? `、ほか${occupants.length - names.length}匹` : ""; + const label = isPipe ? "土管" : "巣箱"; + const insideLabel = isPipe ? "土管に入ってる子" : "入ってる子"; + return [label, names.length ? `${insideLabel}: ${names.join("、")}${more}` : `${insideLabel}: なし`, this.temperatureTooltipLineFor?.(box, "内部気温")].filter(Boolean); }, @@ -488,16 +515,21 @@ let best = null, bestD = Infinity; for (const t of this.tarinai) { if (!t || t.dead) continue; + if (this.isTarinaiHiddenInNestBox?.(t)) continue; const d = distXY(p.x, p.y, t.x, t.y); const limit = Math.max(46, (t.radius || 20) * 1.35); if (d <= limit && d < bestD) { best = t; bestD = d; } } if (!best) return null; - const behavior = (typeof behaviorText === "function" ? behaviorText(best) : "") || window.TEXT_CATALOG?.stateLabel?.(best.state, best) || best.thought || ""; + const nameLine = String(best.name || "たりない"); + const behavior = (typeof behaviorText === "function" ? behaviorText(best) : "") + || window.TEXT_CATALOG?.stateLabel?.(best.state, best) + || best.thought + || "様子を見ている"; const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(best) : (Number(best.maxEnergy) || 100); - const statusLine = `\u4F53\u529B: ${Math.round(best.energy || 0)}/${Math.round(maxEnergy)}\u3000\u7A7A\u8179: ${Math.round(best.hunger || 0)}\u3000\u30B9\u30C8\u30EC\u30B9: ${Math.round(best.stress || 0)}`; - const tempLine = this.temperatureTooltipLineFor(best, "\u4F53\u611F\u6C17\u6E29"); - return { target: best, lines: [best.name || "\u305F\u308A\u306A\u3044", behavior, statusLine, tempLine].filter(Boolean), kind: "tarinai" }; + const statusLine = `体力: ${Math.round(best.energy || 0)}/${Math.round(maxEnergy)} 空腹: ${Math.round(best.hunger || 0)} ストレス: ${Math.round(best.stress || 0)}`; + const tempLine = this.temperatureTooltipLineFor(best, "体感気温") || "体感気温: --℃"; + return { target: best, lines: [nameLine, String(behavior), statusLine, tempLine], kind: "tarinai" }; }, pointerOwnedBedInfo() { @@ -830,10 +862,10 @@ if (!this.canFightPair(actor, target)) return false; actor.intimidationCooldown = Math.max(actor.intimidationCooldown || 0, deterministicRange(this, "fight-mochi-intimidate-repeat-cooldown-actor", 5.5, 8.5, actor, target)); target.intimidationCooldown = Math.max(target.intimidationCooldown || 0, deterministicRange(this, "fight-mochi-intimidate-repeat-cooldown-target", 4.5, 7.5, actor, target)); - actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, deterministicRange(this, "fight-mochi-intimidate-actor-timer", 0.85, 1.35, actor, target)); + actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, deterministicRange(this, "fight-mochi-intimidate-actor-timer", 0.32, 0.58, actor, target)); actor.intimidateTargetId = target.id; actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "fight-mochi-intimidate-target-timer", 0.75, 1.25, actor, target)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "fight-mochi-intimidate-target-timer", 0.30, 0.56, actor, target)); const targetProfile = socialPersonalityProfile(target); target.fearTimer = Math.max(target.fearTimer || 0, 0.72 * targetProfile.fear); actor.adjustRelation(target, -0.05, 0.06, "intimidate"); @@ -897,7 +929,7 @@ target.fightCooldown = CONFIG.fightCooldown + deterministicRange(this, "intimidation-target-cooldown", 1, 4, actor, target); actor.intimidationCooldown = Math.max(actor.intimidationCooldown || 0, deterministicRange(this, "intimidation-repeat-cooldown-actor", 7.5, 11.5, actor, target)); target.intimidationCooldown = Math.max(target.intimidationCooldown || 0, deterministicRange(this, "intimidation-repeat-cooldown-target", 5.5, 9.0, actor, target)); - actor.intimidateTimer = Math.max(actor.intimidateTimer, deterministicRange(this, "intimidation-actor-timer", 1.05, 1.65, actor, target)); + actor.intimidateTimer = Math.max(actor.intimidateTimer, deterministicRange(this, "intimidation-actor-timer", 0.34, 0.62, actor, target)); actor.intimidateTargetId = target.id; actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); setBehaviorText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" }); @@ -907,7 +939,7 @@ color: "rgba(145, 106, 55, 0.70)", })); this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)"); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.95, 1.55, actor, target)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-target-prep-timer", 0.28, 0.52, actor, target)); target.fearTimer = Math.max(target.fearTimer, 0.65 * targetProfile.fear); if (!deterministicChance(this, "intimidation-success", successChance, actor, target)) { actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation"); @@ -925,8 +957,8 @@ actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation"); target.defeatedById = actor.id; target.fightWinnerId = actor.id; - target.defeatedTimer = Math.max(target.defeatedTimer, deterministicRange(this, "intimidation-defeated-timer", 1.8, 2.8, actor, target)); - target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-success-timer", 1.2, 2.0, actor, target)); + target.defeatedTimer = Math.max(target.defeatedTimer, deterministicRange(this, "intimidation-defeated-timer", 0.8, 1.35, actor, target)); + target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, deterministicRange(this, "intimidation-success-timer", 0.34, 0.70, actor, target)); if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" }); else { target.fearTimer = Math.max(target.fearTimer, 1.35 * targetProfile.fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); } this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); @@ -939,8 +971,10 @@ actor.lastLog = target.lastLog = this.time; } this.markFightPairCooldown?.(actor, target, Math.max(6.5, CONFIG.fightCooldown + 3)); - actor.postConflictPeaceTimer = Math.max(actor.postConflictPeaceTimer || 0, deterministicRange(this, "intimidation-success-peace-actor", 3.8, 6.2, actor, target)); - target.postConflictPeaceTimer = Math.max(target.postConflictPeaceTimer || 0, deterministicRange(this, "intimidation-success-peace-target", 3.8, 6.2, actor, target)); + actor.intimidateTimer = Math.min(actor.intimidateTimer || 0, 0.62); + target.intimidatedTimer = Math.min(target.intimidatedTimer || 0, 0.70); + actor.postConflictPeaceTimer = Math.max(actor.postConflictPeaceTimer || 0, deterministicRange(this, "intimidation-success-peace-actor", 2.4, 3.8, actor, target)); + target.postConflictPeaceTimer = Math.max(target.postConflictPeaceTimer || 0, deterministicRange(this, "intimidation-success-peace-target", 2.4, 3.8, actor, target)); return true; }, diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 13ddc58..6756d38 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -225,14 +225,14 @@

扇風機

-
クリックした扇風機の首振りを設定します。保存すると、その扇風機だけに反映されます。
+
クリックした扇風機の風向きと首振りを設定します。保存しても本体の見た目の向きは変えず、風向き矢印だけが変わります。
-

振り幅0度なら固定向きです。Q/Eで置く向きとは別に、配置済み扇風機をクリックして調整できます。

+

振り幅0度なら固定向きです。配置済み扇風機の本体角度は固定したまま、風向きだけを調整します。

@@ -270,6 +270,7 @@ const apply = dialog.querySelector("#fanEditorApply"); const cancel = dialog.querySelector("#fanEditorCancel"); const closeBtn = dialog.querySelector("#fanEditorClose"); + if (!Number.isFinite(Number(item.fanBodyAngle))) item.fanBodyAngle = itemAngleFor(item); const currentAngle = item.fanSwingOn ? (Number(item.fanSwingCenter) || itemAngleFor(item)) : itemAngleFor(item); const centerDeg = degForAngle(currentAngle); const rangeDeg = Math.round(Math.max(0, Math.min(150, Number(item.fanSwingRange ?? (35 * Math.PI / 180)) * 180 / Math.PI || 35))); @@ -290,11 +291,13 @@ const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0)); const rDeg = Math.max(0, Math.min(150, Number(swingRangeNum?.value || swingRange?.value || 0) || 0)); const sDeg = Math.max(0, Math.min(360, Number(swingSpeedNum?.value || swingSpeed?.value || 0) || 0)); + const previousBodyAngle = Number.isFinite(Number(item.fanBodyAngle)) ? Number(item.fanBodyAngle) : itemAngleFor(item); item.fanSwingCenter = (typeof normalizedItemAngle === "function" ? normalizedItemAngle(cDeg * Math.PI / 180, 0) : cDeg * Math.PI / 180); item.fanSwingRange = rDeg * Math.PI / 180; item.fanSwingSpeed = sDeg * Math.PI / 180; item.fanSwingOn = forceStop ? false : Boolean(enabled?.checked && rDeg > 0 && sDeg > 0); item.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0; + item.fanBodyAngle = previousBodyAngle; item.angle = item.fanSwingCenter; worldRef?.markSpatialDirty?.("fan-swing-settings"); if (worldRef) worldRef.drawListDirty = true; @@ -663,6 +666,7 @@ data.fanSwingRange = Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0; data.fanSwingSpeed = Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0; data.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0; + data.fanBodyAngle = Number(item.fanBodyAngle ?? item.angle ?? 0) || 0; } if (item.type === "rotator") { data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) }; @@ -707,6 +711,7 @@ item.fanSwingRange = Number(data.fanSwingRange ?? (35 * Math.PI / 180)) || 0; item.fanSwingSpeed = Number(data.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0; item.fanSwingPhase = Number(data.fanSwingPhase || 0) || 0; + item.fanBodyAngle = Number(data.fanBodyAngle ?? item.fanBodyAngle ?? item.angle ?? 0) || 0; } if (item.type === "signboard") item.text = String(data.text || ""); if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen); @@ -1294,25 +1299,39 @@ plushieTargetD = d; } } - if (plushieTarget && typeof plushieTarget.fling === "function") { + if (plushieTarget) { const owner = plushieTarget.carriedById ? this.liveTarinaiById?.(plushieTarget.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === plushieTarget.carriedById) : null; - const cx = owner ? owner.x : plushieTarget.x; - const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : plushieTarget.y; - let dx = (Number(cx || 0) || 0) - (Number(x || 0) || 0); - let dy = (Number(cy || 0) || 0) - (Number(y || 0) || 0); - if (Math.hypot(dx, dy) < 12 || Math.abs(dx) < Math.abs(dy) * 0.36) { - dx = Math.sign(dx) || Number(owner?.facing || owner?.faceDir || 1) || 1; - dy = -0.42; + if (owner && typeof plushieTarget.fling === "function") { + const side = Math.sign((owner.x || 0) - ((this.w || 0) / 2)) || Number(owner.facing || owner.faceDir || 1) || 1; + const speed = 1550 / 3; + const vx = side * speed; + const vy = -560 / 3; + if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) { + this.effects?.push(new Effect("ring", x, y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" })); + this.drawListDirty = true; + audio.poke?.(); + return; + } } - const len = Math.max(1, Math.hypot(dx, dy)); - const speed = 920; - const vx = dx / len * speed; - const vy = Math.min(dy / len * speed - 190, -260); - if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 3.1 })) { - this.effects?.push(new Effect("ring", x, y, { size: 24, life: 0.18, color: "rgba(190,142,92,0.34)" })); - this.drawListDirty = true; - audio.poke?.(); - return; + if (typeof plushieTarget.fling === "function") { + const cx = owner ? owner.x : plushieTarget.x; + const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : plushieTarget.y; + let dx = (Number(cx || 0) || 0) - (Number(x || 0) || 0); + let dy = (Number(cy || 0) || 0) - (Number(y || 0) || 0); + if (Math.hypot(dx, dy) < 12 || Math.abs(dx) < Math.abs(dy) * 0.36) { + dx = Math.sign(dx) || Number(owner?.facing || owner?.faceDir || 1) || 1; + dy = -0.42; + } + const len = Math.max(1, Math.hypot(dx, dy)); + const speed = 1350 / 3; + const vx = dx / len * speed; + const vy = Math.min(dy / len * speed - 80, -120); + if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 4.8, destroyOffscreen: true })) { + this.effects?.push(new Effect("ring", x, y, { size: 24, life: 0.18, color: "rgba(190,142,92,0.34)" })); + this.drawListDirty = true; + audio.poke?.(); + return; + } } } @@ -1383,8 +1402,14 @@ }, shouldHideFromObservationRecords(entry = {}) { - const type = typeof entry === "string" ? "" : (entry.eventType || ""); - return type === "ball_poke" || type === "avoid_relationship" || type === "mechanism_poke"; + const type = typeof entry === "string" ? "" : String(entry.eventType || ""); + const text = typeof entry === "string" ? String(entry || "") : String(entry.text || ""); + if (type === "ball_poke" || type === "avoid_relationship" || type === "mechanism_poke") return true; + if (/^(player_|tool_|tool:)/.test(type) || type.includes("tool") || type.includes("player")) return true; + if (type.includes("robot")) return true; + if (/ロボ掃除機|掃除機/.test(text)) return true; + if (/範囲削除|コピーした|貼り付けた|つないだ|設定を変更|看板を書き換え|看板の文字を消した|を消した。|つついて動かした/.test(text)) return true; + return false; }, log(text, kind = null, options = {}) { diff --git a/js/world_temperature_system.js b/js/world_temperature_system.js index 2e13cd5..86960e5 100644 --- a/js/world_temperature_system.js +++ b/js/world_temperature_system.js @@ -127,7 +127,7 @@ const halfWidth = 28 + along * 0.72; if (lateral > halfWidth) continue; const edge = Math.pow(clamp(1 - along / fanRange, 0, 1), 0.62) * Math.pow(clamp(1 - lateral / Math.max(1, halfWidth), 0, 1), 0.30); - temp -= 4 * edge; + temp -= 5 * edge; continue; } const d = distXY(x, y, it.x, it.y); @@ -155,7 +155,7 @@ const base = this.temperatureAt?.(pipe.x, pipe.y, null); const temp = this.temperatureNumber(base, this.currentTemperature ?? CONFIG.standardTemperature ?? 15); const toward = 20 - temp; - return this.temperatureNumber(temp + clamp(toward, -7, 7)); + return this.temperatureNumber(temp + clamp(toward, -5, 5)); }, feltTemperatureFor(actor, x = actor?.x, y = actor?.y) { if (!actor) return this.temperatureAt(x, y); @@ -204,10 +204,12 @@ const rings = [72, 128, 196, 288, 392]; const dirs = 18; const candidates = []; - const pushCandidate = (x, y, extra = 0) => candidates.push({ + const pushCandidate = (x, y, extra = 0, targetItem = null, tempOverride = null) => candidates.push({ x: clamp(x, radius + 8, (this.w || 1000) - radius - 8), y: clamp(y, radius + 8, (this.h || 720) - radius - 8), extra, + targetItem, + tempOverride, }); for (const ring of rings) { for (let i = 0; i < dirs; i++) { @@ -217,7 +219,20 @@ } const itemRange = Math.max(24, CONFIG.temperatureItemRange ?? 190); for (const it of this.items || []) { - if (!it || it.dead || (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan")) continue; + if (!it || it.dead) continue; + if (it.type === "nest_box" || it.type === "pipe") { + const shelterTemp = it.type === "pipe" && this.pipeInteriorTemperatureFor + ? this.pipeInteriorTemperatureFor(it, actor) + : this.temperatureAt?.(it.x, it.y, actor); + if (!Number.isFinite(Number(shelterTemp))) continue; + const shelterStatus = this.temperatureStatusFor(shelterTemp, actor); + if (currentStatus.direction === "cold" && shelterTemp <= current + 0.6 && !shelterStatus.comfortable) continue; + if (currentStatus.direction === "hot" && shelterTemp >= current - 0.6 && !shelterStatus.comfortable) continue; + const entry = this.nestBoxEntryPoint?.(it) || { x: it.x, y: it.y }; + pushCandidate(entry.x, entry.y, shelterStatus.comfortable ? -96 : -44, it, shelterTemp); + continue; + } + if (it.type !== "dry_ice" && it.type !== "stove" && it.type !== "fan") continue; if (currentStatus.direction === "cold" && it.type !== "stove") continue; if (currentStatus.direction === "hot" && it.type !== "dry_ice" && it.type !== "fan") continue; if (it.type === "fan") { @@ -236,7 +251,7 @@ let bestScore = Infinity; for (const c of candidates) { if (this.pointBlockedByObstacle?.(c.x, c.y, Math.max(14, radius * 0.72), { exclude: actor })) continue; - const temp = this.temperatureAt(c.x, c.y, actor); + const temp = Number.isFinite(Number(c.tempOverride)) ? Number(c.tempOverride) : this.temperatureAt(c.x, c.y, actor); const status = this.temperatureStatusFor(temp, actor); if (currentStatus.direction === "cold" && temp <= current + 0.6 && !status.comfortable) continue; if (currentStatus.direction === "hot" && temp >= current - 0.6 && !status.comfortable) continue; @@ -245,7 +260,9 @@ const score = status.discomfort * 110 + Math.abs(temp - targetTemp) * 2.8 + d * 0.22 + blockedPenalty + (c.extra || 0); if (score < bestScore) { bestScore = score; - best = { x: c.x, y: c.y, dead: false, detour: true, temperatureTarget: true, temperature: temp, label: "\u5FEB\u9069\u306A\u6E29\u5EA6\u5E2F" }; + const shelterLabel = c.targetItem?.type === "pipe" ? "土管" : (c.targetItem?.type === "nest_box" ? "巣箱" : "快適な温度帯"); + best = { x: c.x, y: c.y, dead: false, detour: !c.targetItem, temperatureTarget: true, temperature: temp, label: shelterLabel }; + if (c.targetItem) best.shelterItem = c.targetItem; } } if (!best) return null; diff --git a/js/world_tool_actions.js b/js/world_tool_actions.js index 4778ab8..4fe76c4 100644 --- a/js/world_tool_actions.js +++ b/js/world_tool_actions.js @@ -408,7 +408,12 @@ } else if (hit?.kind === "item") { handled = true; const target = hit.target; - if (target && isPinType(target.type)) { + if (target?.type === "balloon") { + globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(target, this, { type: "shoot", x: aim.x, y: aim.y, id: "shoot" }); + this.markItemBucketsDirty?.("shoot-water-balloon-pop"); + this.markSpatialDirty?.("shoot-water-balloon-pop"); + this.drawListDirty = true; + } else if (target && isPinType(target.type)) { const blasted = this.blastLoosePinsFrom?.(aim.x, aim.y, 92, target, 1.25, "shoot-tool") || 0; if (!blasted && target.pinState !== "lodged") { let dx = (target.x || 0) - aim.x; diff --git a/js/world_view.js b/js/world_view.js index d97407a..f367246 100644 --- a/js/world_view.js +++ b/js/world_view.js @@ -176,12 +176,12 @@ renderSelected(); if (typeof document !== "undefined") { const groundTextEl = document.getElementById("statGroundType"); - if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "\u7802"; + if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "\u5730\u9762"; } if (!opts.silent) { - const label = this.groundLabel?.() || "\u7802"; - this.log?.(`\u3058\u3081\u3093: ${label}`); - showToast(`\u3058\u3081\u3093\u3092 ${label} \u306b\u3057\u307e\u3057\u305f\u3002`); + const label = this.groundLabel?.() || "\u5730\u9762"; + this.log?.(`\u5730\u9762: ${label}`); + showToast(`\u5730\u9762\u3092 ${label} \u306b\u3057\u307e\u3057\u305f\u3002`); } this.emit?.("ground:changed", { previous, next, definition: this.groundDefinition?.() }); } diff --git a/service-worker.js b/service-worker.js index d3ac533..4c07267 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "39.15.116"; +const APP_VERSION = "39.15.136"; 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. From 8f9f5b51002e7116509c77be842f52b496d65d95 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sun, 5 Jul 2026 18:01:36 +0900 Subject: [PATCH 2/4] duu --- AI_MAP.md | 24 +- FILES.json | 2 +- GAME_FEATURES.md | 49 +--- README.md | 40 +--- app_manifest.json | 5 +- css/base.css | 29 --- css/components.css | 10 - css/layout.css | 29 +-- css/mobile.css | 18 +- css/panel.css | 14 +- index.html | 347 ++++++++++++++-------------- js/ants.js | 5 - js/audio.js | 3 - js/collision_footprint_system.js | 21 +- js/collision_response_system.js | 24 +- js/command_dispatcher.js | 5 - js/constraint_system.js | 6 +- js/data.js | 1 + js/debug_tools.js | 1 + js/deterministic_helpers.js | 14 +- js/display_helpers.js | 36 +++ js/domain_ids.js | 70 ++++++ js/event_bus.js | 22 +- js/family_graph.js | 38 --- js/fire_runtime_system.js | 164 ++++++++++++- js/geometry_helpers.js | 15 ++ js/ground_types.js | 2 +- js/health.js | 5 +- js/history_system.js | 104 ++++++++- js/item_dynamic_ball_system.js | 39 +++- js/item_dynamic_system.js | 1 + js/item_dynamic_tool_system.js | 17 +- js/item_dynamic_zunchi_system.js | 20 +- js/item_lifecycle_decay_system.js | 2 - js/item_lifecycle_growth_system.js | 7 +- js/item_lifecycle_support.js | 1 - js/item_render_runtime.js | 17 +- js/item_tool_definitions.js | 49 +++- js/item_tool_metadata.js | 5 - js/item_type_catalog.js | 50 ++++ js/item_type_initializers.js | 4 +- js/item_update_policy.js | 12 +- js/item_update_scheduler.js | 5 +- js/items.js | 2 - js/main.js | 2 +- js/mechanical_shape_bridge.js | 4 - js/mechanical_system.js | 11 +- js/physics_helpers.js | 17 +- js/physics_shape_editor_system.js | 11 +- js/placement_preview_system.js | 8 +- js/render.js | 93 +++----- js/robot_cleaner_system.js | 10 +- js/save_codec.js | 303 ++---------------------- js/save_schema.js | 98 +++----- js/simulation_ambient_system.js | 1 - js/simulation_effects_system.js | 16 +- js/simulation_environment_system.js | 9 +- js/simulation_maintenance_system.js | 13 +- js/simulation_runtime_helpers.js | 6 - js/snapshot_system.js | 61 +++-- js/structure_lifecycle.js | 5 +- js/structures.js | 5 +- js/tarinai_action_definitions.js | 6 +- js/tarinai_action_state.js | 2 +- js/tarinai_behavior_text.js | 12 +- js/tarinai_building_behavior.js | 44 ++-- js/tarinai_disease_nest.js | 126 ++++++++-- js/tarinai_food_prototype_mixin.js | 2 - js/tarinai_forced_behavior.js | 1 + js/tarinai_identity_social.js | 2 +- js/tarinai_item_effects.js | 5 - js/tarinai_item_targeting.js | 17 +- js/tarinai_needs_items.js | 69 ++++-- js/tarinai_nest_sleep_system.js | 39 +++- js/tarinai_render.js | 8 +- js/tarinai_social_move_life.js | 18 +- js/tarinai_update_policy.js | 7 +- js/tarinai_update_step_frame.js | 12 +- js/tarinai_update_step_movement.js | 3 +- js/ui.js | 3 + js/ui_bind.js | 2 +- js/ui_charts.js | 30 ++- js/ui_family_async.js | 23 +- js/ui_family_layout.js | 8 - js/ui_family_paths.js | 346 +++++++++++++++++++++++++-- js/ui_family_render.js | 105 ++++----- js/ui_ground.js | 2 +- js/ui_selected.js | 2 +- js/ui_tools.js | 27 ++- js/version.js | 4 +- js/weather_system.js | 30 ++- js/world_ants_system.js | 3 - js/world_combat_effects.js | 5 +- js/world_environment.js | 198 ++++++++-------- js/world_family_social.js | 153 +++++++----- js/world_pathfinding_system.js | 25 +- js/world_placement_log.js | 6 +- js/world_reset_presets.js | 5 - js/world_spatial_budget.js | 21 +- js/world_temperature_system.js | 35 ++- js/world_tool_actions.js | 8 +- js/world_update.js | 104 +++++---- js/world_view.js | 65 +++--- scripts/ai_inventory.ps1 | 19 ++ scripts/ai_inventory.py | 5 + scripts/generate_app_files.py | 15 ++ scripts/regression_check.py | 8 +- service-worker.js | 4 +- 108 files changed, 2140 insertions(+), 1501 deletions(-) create mode 100644 js/display_helpers.js create mode 100644 js/domain_ids.js create mode 100644 js/item_type_catalog.js diff --git a/AI_MAP.md b/AI_MAP.md index f801193..02a0d2f 100644 --- a/AI_MAP.md +++ b/AI_MAP.md @@ -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.103`. +- Version/cache: `app_manifest.json`, `js/version.js`, and generated `service-worker.js` share `39.15.164`. - 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`. @@ -59,4 +59,26 @@ Purpose: static Japanese browser simulation/game. Optimize context by reading th - Keep intentional global exports for compatibility with regression checks and browser runtime; remove public facades only after repo-wide caller checks. - Prefer focused edits in the owning subsystem; split modules are intentional compatibility boundaries. - Avoid reintroducing `simulationQualityTier` branches unless a real simulation-quality setting is restored; current quality controls are visual/detail controls. +- `High` display quality may include quality-preserving optimizations, but should not reduce simulation cadence, object limits, or visible fidelity in a noticeable way. +- Save format compatibility with old text saves is not required when intentionally shortening/replacing save hash text; keep current save/load internally consistent instead. - Run `node --check js/*.js` and `python scripts/regression_check.py` for behavior changes. + +## Current Maintenance Notes + +This section records current boundaries only. Version-by-version cleanup history and completed work logs are intentionally omitted. + +### Live Compatibility Surfaces + +- Ordered global-IIFE loading is still part of the app contract; check repo-wide callers before removing bare globals or facade objects. +- Keep public event names with active listeners: `audio:play`, `selection:changed`, `tool:selected`, `world:phase`, `log:entry`, and `tool:placed`. Telemetry-only emit calls should stay removed. +- Save compatibility with old exported text is not required, but the current save/load round-trip must remain internally consistent. +- Tool icon ownership is `TOOL_DEFINITIONS.icon`; UI code converts asset paths for CSS variables. Avoid restoring CSS-local `url(...)` definitions. +- Shared geometry/display/domain helpers should stay in their owning helper modules unless a dedicated namespace migration is being done. + +### Open Architecture Candidates + +- Full namespace migration for behavior/action/needs/tool helpers. This requires rewriting many bare global call sites and updating regression contracts. +- Effect-specific spatial indexing, fuller delta-history storage, and high-speed collision partitioning. These need new indexes or semantics beyond cleanup. +- UI/text-side meaning consolidation, including labels, wrapping/measurement helpers, and duplicated scalar helpers such as `num`, `finiteOr`, `isObject`, and `clonePlain`. +- Further `World.prototype` reduction is possible, but only for helpers proven not to be runtime-facing canvas/UI/tool APIs. + diff --git a/FILES.json b/FILES.json index 376200a..c7645ba 100644 --- a/FILES.json +++ b/FILES.json @@ -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.103", + "version": "39.15.164", "entrypoints": { "app": "index.html", "runtime": "js/main.js", diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index 272cfa7..a96d255 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -1,26 +1,3 @@ -## 39.15.136 ツールチップ・統計・病気・空腹調整 -- たりないツールチップは4行固定のまま、横幅を内容に合わせて可変化。 -- コロニー統計に毎日0:00の灰色点線と日付ラベルを表示。 -- 持ち主IDが空、または持ち主が生存個体に存在しないぬいぐるみは削除。 -- きずつき病の個体は日光浴を開始せず、日光浴中に発症した場合も中断する。日光浴回復対象からも除外。 -- 病気中に体力切れ/ストレス過多で死亡した場合、病死として病名を優先記録するよう補強。 -- 空腹度合いの自然上昇幅を通常時・睡眠時ともに3/4倍へ調整。 - -## 39.15.136 水風船・扇風機差分 - -- 水風船の飛沫は雨滴と同じ `Item("water")` 生成経路に統一。専用タイプや専用IDは使わず、短時間だけ速度を持つ水滴として扱う。 -- 水風船の破裂飛沫を増量し、飛距離・初速・飛翔時間を拡大。 -- 水風船は射撃命中、爆竹/爆発病の爆風、げんこつ、火、鋲で破裂する。 -- 扇風機の本体角度と風向きを分離。風向き変更・首振りで変化するのは風範囲/矢印のみ。 -- 扇風機の風が火に当たると、火を単に弱めず、風下へ火種を飛ばして拡散する。 -- たりないが水風船で遊ぶ時の楽しさ・押し出し効果をボール寄りに調整。 - -## 39.15.118 土管ツールチップ・雨滴統一 -- 土管ツールチップは、どの土管にカーソルを合わせても土管内のたりないを一覧表示する。 -- 巣箱・土管に入り透明化済みのたりないは、個体ツールチップを出さない。 -- 土管内の体感温度補正は20℃へ最大±5℃まで寄せる。 -- 水風船破裂時に生成される水滴は、雨で生成される水滴と同じ初期化を使う。 - # tarinai_ Game Features 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. @@ -166,6 +143,11 @@ Tarinai can be pushed by physics objects, carried or affected by ants, dragged b - **Grass ecology**: grass grows, feeds tarinai, comforts them, can become bed material, and has a small chance to reduce fire before burning. - **Dirt and corpses**: zunchi, splats, corpses, and ant corpses affect hygiene, disease, and observation results. - **Day/light conditions**: lighting and sun influence rendering and sunbath behavior. +- **Season temperature**: automatic summer climate is roughly 5℃ hotter than the previous baseline; manual temperature control remains exact to the slider value. + +## Crowded Scene Performance + +Crowded fields are an intended sandbox outcome. Performance work should prefer cached, typed, spatial, dirty, or incremental lookups over lowering object limits. High display quality should preserve visible fidelity and simulation rules while avoiding repeated hidden scans in render, tooltip, family tree, collision, effect, fire, and maintenance paths. ## Disease And Recovery @@ -191,25 +173,6 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju ## Save, Reset, And Persistence - The app supports manual save and restore through browser storage. +- Current saves are allowed to use compact/hash-style text. Old save text does not need to remain loadable when the save format is intentionally shortened or replaced, so load failure is acceptable after intentional save-format changes as long as the current save/load path stays internally consistent. - Reset opens field selection and starts a new field state. - Service worker caching allows the static app shell to remain available after load, depending on browser support. - - -## 39.15.117 土管外観・内部温度 - -- 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。 -- 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。 -- 土管ツールチップの内部気温表示にも、土管内補正後の温度を反映。 - -## 39.15.115 水風船の飛散強化・扇風機首振り設定 -- 水風船破裂時に水滴が放射状に飛び、短時間移動してから水たまり状に落ち着く。 -- 配置済み扇風機をクリックすると首振り設定UIを開ける。個別に首振りON/OFF、向き、振り幅、速度を設定可能。 -- 首振り中の扇風機は風の範囲表示と実際の風判定が現在角度に追従する。 - -## 39.15.115 土管 - -- 生活カテゴリに「土管」を追加。丸い口が見える配置アイテムとして表示。 -- たりないが巣箱のように土管の中へ入り、内部の個体は巣箱系ツールチップで確認可能。 -- 土管から出る時は、複数ある場合は別のランダムな土管から出る。 -- 土管内でも眠れるが、寝心地と睡眠優先順位は干草寝床の一段下。 - diff --git a/README.md b/README.md index b85e8c8..b700605 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,6 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; read deeper docs only when needed. -## v39.15.70 robot cleaner tuning -- Tuned ロボ掃除機 targeting: normal speed is 60, high-speed mode is 120, and speed no longer changes based on the selected cleanup targets. -- The cleaner checks the nearest matching target every 2 seconds, then keeps trying to clean that target between scans. Contact cleanup still removes every matching object it touches. -- The 死骸 target also covers blood traces. When たりない is enabled, nearby Tarinai panic while chased and are cleaned on contact. - ## Read Strategy - Start here for architecture, script order constraints, and where to look next. @@ -18,16 +13,17 @@ 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.103`. +- No bundler; `index.html` loads ordered global-IIFE scripts with `?v=39.15.164`. - 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`. -- Generated/static app shell: `app_manifest.json`, `service-worker.js`, and `js/version.js` must stay version-aligned. +- Generated/static app shell: `app_manifest.json`, `service-worker.js`, `FILES.json`, and `js/version.js` must stay version-aligned. ## File Routing - `index.html`: DOM shell, CSS/JS load order, panels/dialogs/canvas. - `css/*.css`: base/layout/panel/components/mobile styling. +- `js/domain_ids.js`, `js/item_type_catalog.js`, `js/data.js`, `js/ground_types.js`: stable domain ids, current item-type save catalog, core config, field/ground definitions. - `js/world*.js`: world state, view, update phases, tools, placement, combat, environment, family/social, ants. - `js/tarinai*.js`: creature state, needs, actions, behavior, social life, item effects, update pipeline, rendering. - `js/item*.js`, `js/structures*.js`: item registry/runtime/lifecycle/dynamic behavior/rendering plus structures. @@ -55,33 +51,3 @@ AI-first entrypoint for a static browser simulation/game. Keep this file small; - Syntax: `node --check js/*.js` - Regression: `python scripts/regression_check.py` - Inventory sanity: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/ai_inventory.ps1 -All` - - -## UI media isolation / scroll repair - -- PC / smartphone UI override rules are separated into `@media (min-width: 981px)` and `@media (max-width: 980px)`. -- PC family tree card is restored below the main game layout, not inside the right menu. -- Smartphone manual scroll no longer uses internal vertical scroll traps for event log / family tree content. -- Auto-scroll does not open collapsed cards. - - -## Event/family/oshibyo UI fixes - -- Event UI hides direct player item placement records while keeping those records in the internal log. -- Collapsed Event UI no longer leaves a large blank area from the log-card min-height. -- Family tree nodes show inherited stats in a 2x2 grid and move the tarinai portrait to the upper-right. -- Oshibyo lodged position is anchored to the left side of the tarinai sprite. - - -## Family polish - -- Family tree generation row spacing is tightened. -- Family tree personality tags also use a 2x2 grid (up to 4 visible tags). -- Sprinkler effective cleaning radius is increased to 3x the previous value and still filters targets by actual distance. - - -## Owned object panic / shoot effect - -- Tarinai panic when their own plushie or simple grass bed is destroyed. -- The selected Tarinai data behavior text explicitly shows the owned-object panic reason. -- Shooting impact effects are larger and remain visible for slightly longer. diff --git a/app_manifest.json b/app_manifest.json index 0d17dce..4d46a30 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.136", + "version": "39.15.164", "css": [ "css/base.css", "css/layout.css", @@ -10,6 +10,7 @@ "js": [ "js/version.js", "js/event_bus.js", + "js/domain_ids.js", "js/registry_base.js", "js/disease_registry.js", "js/sound_pack.js", @@ -38,10 +39,12 @@ "js/assets.js", "js/audio.js", "js/perf_profiler.js", + "js/display_helpers.js", "js/render.js", "js/sim_core.js", "js/tarinai_seed_factory.js", "js/structures.js", + "js/item_type_catalog.js", "js/items.js", "js/item_type_initializers.js", "js/item_lifecycle_support.js", diff --git a/css/base.css b/css/base.css index d90a089..e3d7e2b 100644 --- a/css/base.css +++ b/css/base.css @@ -402,35 +402,6 @@ h1 { .tool[data-tool="dwarf_drug"] .tool-sprite-preview, .tool[data-tool="oshibyo"] .tool-sprite-preview { width: 22px; height: 22px; left: 5px; } .tool[data-tool="poke"], .tool[data-tool="water_hose"] { --tool-icon-zoom: 0.92; } -.tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); } -.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); } -.tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.webp"); } -.tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.webp"); } -.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.webp"); } -.tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.webp"); } -.tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.webp"); } -.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; } -.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.webp"); } -.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.webp"); } -.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.webp"); } -.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.webp"); } -.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); } - -.tool[data-tool="signboard"] { --tool-icon: url("../assets/ui/tool_signboard.webp"); } -.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp"); } -.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp"); } -.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp"); } - -/* Generated parameter item tool icons. */ -.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp"); } -.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp"); } -.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp"); } -.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp"); } -.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp"); } -.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp"); } -.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp"); } -.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp"); } -.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp"); } .signboard-editor { position: fixed; diff --git a/css/components.css b/css/components.css index f78d734..e6649bd 100644 --- a/css/components.css +++ b/css/components.css @@ -790,16 +790,6 @@ top: 50%; transform: translate(-50%, -50%); } -@media (max-width: 980px) { - .tool-category[data-tool-category="operate"]:not(.collapsed) { top: 58px; } -} - -@media (max-width: 980px) { - body.touch-mobile-ui .tool-tip-popover { - max-width: min(280px, calc(100vw - 24px)); - } -} - /* Collapsed event card must shrink to its header. layout.css gives .log-card a min-height, which left a large blank panel when folded. */ .log-card.collapsed { min-height: 0 !important; height: auto !important; flex: 0 0 auto !important; flex-basis: auto !important; } diff --git a/css/layout.css b/css/layout.css index 9e4dbb5..6515c70 100644 --- a/css/layout.css +++ b/css/layout.css @@ -1,11 +1,6 @@ -/* CSS split file; preserve manifest load order. */ -.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.webp"); } -.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.webp"); } -.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.webp"); } -.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.webp"); --tool-icon-zoom: 1.36; } -.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } -.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); } -.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; } +/* CSS split file; preserve manifest load order. Tool image paths come from TOOL_DEFINITIONS. */ +.tool[data-tool="ball"] { --tool-icon-zoom: 1.36; } +.tool[data-tool="shoot"] { --tool-icon-zoom: 1.48; } .tool.tool-emoji-icon { --tool-icon: none; } .tool.tool-emoji-icon::before { @@ -302,24 +297,6 @@ meter { .log-observe { border-left-color: rgba(175, 146, 86, 0.62); } .log-event { border-left-color: rgba(132, 120, 210, 0.62); } -@media (max-width: 980px) { - #app { padding: 10px; } - .topbar { align-items: flex-start; flex-direction: column; } - .top-left { align-items: flex-start; flex-direction: column; } - .brand { align-items: center; flex-direction: row; gap: 10px; } - .layout { grid-template-columns: 1fr; } - .panel { max-height: none; overflow: visible; } - #gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); } - .stage-wrap { min-height: var(--field-mobile-min-height, 460px); } - .field-choice-grid { grid-template-columns: 1fr; } - .reset-preset-choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} - - - - - - /* Clearer tool category grouping. */ .tool-category[data-tool-category="operate"] { --tool-cat-a: rgba(224,239,255,0.82); --tool-cat-b: rgba(203,226,255,0.74); --tool-cat-line: rgba(82,128,190,0.26); } .tool-category[data-tool-category="food"] { --tool-cat-a: rgba(236,252,219,0.82); --tool-cat-b: rgba(214,244,194,0.74); --tool-cat-line: rgba(96,156,72,0.26); } diff --git a/css/mobile.css b/css/mobile.css index 9fa63f2..ca86dcc 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -227,7 +227,16 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; } align-items: stretch; gap: 8px; } - .top-left { gap: 8px; } + .top-left { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .brand { + align-items: center; + flex-direction: row; + gap: 10px; + } .brand h1, h1 { max-width: 100%; @@ -265,9 +274,16 @@ body.touch-mobile-ui.mobile-mode-tool #gameCanvas { cursor: crosshair; } gap: 10px; overflow: visible; } + .field-choice-grid { grid-template-columns: 1fr; } + .reset-preset-choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #gameCanvas { + height: var(--field-mobile-height, 66vh); + min-height: var(--field-mobile-min-height, 460px); + } .stage-wrap { position: relative; width: 100%; + min-height: var(--field-mobile-min-height, 460px); overflow: hidden; } .panel { diff --git a/css/panel.css b/css/panel.css index 0dc1763..a964d1c 100644 --- a/css/panel.css +++ b/css/panel.css @@ -137,22 +137,10 @@ body::before { min-width: 0; white-space: nowrap; } -.family-genetic-stats b, -.family-fight-stat b { +.family-genetic-stats b { font-weight: 800; color: rgba(120, 86, 30, 0.92); } -.family-fight-stat { - display: block; - margin-top: 1px; - max-width: 82px; - font-size: 9.7px; - line-height: 1.13; - color: rgba(88, 70, 48, 0.82); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} .lineage-family-tree .tree-node.champion { border-color: rgba(202, 146, 33, 0.58); box-shadow: inset 0 0 0 1px rgba(247, 200, 64, 0.30), 0 3px 8px rgba(120, 78, 12, 0.09); diff --git a/index.html b/index.html index 648018b..2e2ded9 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,14 @@ たりない観察 - - - - - - - - + + + + + + + +
@@ -71,7 +71,7 @@
-

\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

+

\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

@@ -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 @@
-

\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

+

\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

@@ -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; diff --git a/js/world_reset_presets.js b/js/world_reset_presets.js index c1cdf60..5c751c1 100644 --- a/js/world_reset_presets.js +++ b/js/world_reset_presets.js @@ -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) { diff --git a/js/world_spatial_budget.js b/js/world_spatial_budget.js index c0df21a..37347ef 100644 --- a/js/world_spatial_budget.js +++ b/js/world_spatial_budget.js @@ -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); diff --git a/scripts/regression_check.py b/scripts/regression_check.py index f40ad8f..4540fda 100644 --- a/scripts/regression_check.py +++ b/scripts/regression_check.py @@ -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"(? 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() diff --git a/service-worker.js b/service-worker.js index 6d1d907..657617f 100644 --- a/service-worker.js +++ b/service-worker.js @@ -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. From 2636d580a87e280eae0c594a2964ec50c3c8f191 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 10 Jul 2026 16:40:06 +0900 Subject: [PATCH 4/4] aaa --- .tmp-edge-profile2/Crashpad/settings.dat | Bin 0 -> 288 bytes .../Crashpad/throttle_store.dat | 1 + AI_MAP.md | 3 +- BUILD_NOTE.txt | 169 +-- FILES.json | 2 +- GAME_FEATURES.md | 110 +- README.md | 7 +- app_manifest.json | 3 +- css/base.css | 30 + css/components.css | 24 +- css/panel.css | 10 +- index.html | 350 ++--- js/ants.js | 15 +- js/assets.js | 101 +- js/collision_footprint_system.js | 4 +- js/collision_response_system.js | 3 + js/constraint_system.js | 195 ++- js/debug_tools.js | 37 +- js/fire_runtime_system.js | 13 +- js/geometry_helpers.js | 22 + js/ground_types.js | 2 +- js/impact_core_system.js | 4 +- js/item_dynamic_ball_system.js | 13 +- js/item_dynamic_system.js | 2 +- js/item_dynamic_tool_system.js | 16 +- js/item_render_runtime.js | 52 +- js/item_tool_definitions.js | 5 + js/item_tool_metadata.js | 21 +- js/item_type_catalog.js | 3 +- js/item_type_initializers.js | 15 +- js/item_update_policy.js | 100 +- js/item_update_scheduler.js | 64 +- js/items.js | 3 + js/main.js | 11 +- js/mechanical_system.js | 115 +- js/perf_profiler.js | 207 ++- js/physics_helpers.js | 37 +- js/physics_world_system.js | 145 +- js/placement_preview_system.js | 54 +- js/render.js | 179 ++- js/robot_cleaner_system.js | 11 + js/save_codec.js | 1319 ++++++++++++++--- js/save_schema.js | 6 +- js/save_system.js | 92 +- js/signal_system.js | 251 ++++ js/sim_core.js | 40 +- js/simulation_creature_system.js | 28 +- js/simulation_effects_system.js | 64 +- js/simulation_item_ant_system.js | 22 +- js/simulation_maintenance_system.js | 1 - js/snapshot_system.js | 56 +- js/structure_lifecycle.js | 2 +- js/tarinai_needs_items.js | 4 +- js/tarinai_seed_factory.js | 18 +- js/tarinai_social_action_runtime.js | 2 +- js/tarinai_update_policy.js | 21 +- js/tarinai_update_step_movement.js | 4 + js/ui.js | 5 + js/ui_bind.js | 109 +- js/ui_charts.js | 43 +- js/ui_input_shared.js | 1 - js/ui_layout_dialogs.js | 2 +- js/ui_selected.js | 3 +- js/ui_tools.js | 11 +- js/version.js | 4 +- js/world.js | 1 + js/world_environment.js | 227 ++- js/world_family_social.js | 42 + js/world_pathfinding_system.js | 8 +- js/world_placement_log.js | 91 +- js/world_spatial_budget.js | 28 +- js/world_tool_actions.js | 34 +- js/world_update.js | 3 - scripts/regression_check.py | 268 +++- service-worker.js | 4 +- 75 files changed, 3889 insertions(+), 1083 deletions(-) create mode 100644 .tmp-edge-profile2/Crashpad/settings.dat create mode 100644 .tmp-edge-profile2/Crashpad/throttle_store.dat create mode 100644 js/signal_system.js diff --git a/.tmp-edge-profile2/Crashpad/settings.dat b/.tmp-edge-profile2/Crashpad/settings.dat new file mode 100644 index 0000000000000000000000000000000000000000..3b20fded8081a26d1f1bcfc18d3914abdd4f64f3 GIT binary patch literal 288 ocmXR;32