From 88f618cadf095f168e3596713f2cfc3d10268c95 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 3 Jul 2026 20:57:02 +0900 Subject: [PATCH] 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.