From ee22e1a9298845c4f5bd57c57098de9e87a2d036 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Fri, 3 Jul 2026 00:45:22 +0900 Subject: [PATCH] qqq --- BUILD_NOTE.txt | 7 + GAME_FEATURES.md | 20 ++ app_manifest.json | 2 +- css/base.css | 52 +++- index.html | 328 +++++++++++----------- js/audio.js | 2 +- js/collision_footprint_system.js | 4 +- js/collision_response_system.js | 50 ++-- js/constraint_system.js | 3 +- js/impact_response_system.js | 9 +- js/item_dynamic_ball_system.js | 426 +++++++++++++++++++++++++++-- js/item_dynamic_system.js | 5 + js/item_render_runtime.js | 256 +++++++++++++++++ js/item_tool_definitions.js | 5 + js/item_tool_metadata.js | 12 +- js/item_type_initializers.js | 32 ++- js/item_update_policy.js | 6 +- js/item_visual_definitions.js | 18 ++ js/items.js | 6 +- js/placement_preview_system.js | 2 +- js/render.js | 5 +- js/robot_cleaner_system.js | 1 - js/save_codec.js | 5 +- js/save_schema.js | 8 +- js/sim_core.js | 2 +- js/snapshot_system.js | 20 +- js/structure_lifecycle.js | 2 +- js/tarinai_action_definitions.js | 5 +- js/tarinai_action_state.js | 5 +- js/tarinai_contact_item_system.js | 5 +- js/tarinai_disease_nest.js | 49 ++-- js/tarinai_identity_social.js | 3 +- js/tarinai_item_targeting.js | 2 +- js/tarinai_needs_items.js | 4 +- js/tarinai_nest_sleep_system.js | 28 +- js/tarinai_social_move_life.js | 22 +- js/tarinai_update_policy.js | 2 +- js/tarinai_update_step_movement.js | 2 +- js/text_catalog.js | 2 +- js/ui_input_shared.js | 6 +- js/version.js | 4 +- js/world_combat_effects.js | 25 +- js/world_environment.js | 71 ++++- js/world_family_social.js | 19 +- js/world_placement_log.js | 121 +++++++- js/world_reset_presets.js | 4 +- js/world_temperature_system.js | 47 +++- js/world_tool_actions.js | 2 +- service-worker.js | 2 +- 49 files changed, 1380 insertions(+), 338 deletions(-) diff --git a/BUILD_NOTE.txt b/BUILD_NOTE.txt index 106d468..2809c20 100644 --- a/BUILD_NOTE.txt +++ b/BUILD_NOTE.txt @@ -1,3 +1,10 @@ +tarinai_water_balloon_fan_ui_v1 +- Fan drawing now shows the actual forward wind cone/range. +- Mobile rotator/reciprocator editor dialogs are scrollable; reciprocator canvas shares editor sizing. +- Balloon tool is replaced by water balloon visuals/text. +- Water balloons splash water when popped; low-openness tarinai panic and flee with behavior text: 水風船が割れておどろいている。 +- Version: 39.15.116 + tarinai_king_fix_v10 - Fire tool button hidden; flames spawn only from flame firecracker. - Burning tarinai and ants draw lightweight flame-shaped overlays. diff --git a/GAME_FEATURES.md b/GAME_FEATURES.md index cdfd96c..0907f3a 100644 --- a/GAME_FEATURES.md +++ b/GAME_FEATURES.md @@ -170,3 +170,23 @@ Recovery depends on the disease or effect. Water, rest, food, medicine, zunda ju - The app supports manual save and restore through browser storage. - Reset opens field selection and starts a new field state. - Service worker caching allows the static app shell to remain available after load, depending on browser support. + + +## 39.15.116 土管外観・内部温度 + +- 土管の外観を、丸い口と側面が見える斜め上からの視点に変更。 +- 土管内の体感温度を20℃へ近づける内部温度補正を追加。補正幅は最大7℃。 +- 土管ツールチップの内部気温表示にも、土管内補正後の温度を反映。 + +## 39.15.115 水風船の飛散強化・扇風機首振り設定 +- 水風船破裂時に水滴が放射状に飛び、短時間移動してから水たまり状に落ち着く。 +- 配置済み扇風機をクリックすると首振り設定UIを開ける。個別に首振りON/OFF、向き、振り幅、速度を設定可能。 +- 首振り中の扇風機は風の範囲表示と実際の風判定が現在角度に追従する。 + +## 39.15.115 土管 + +- 生活カテゴリに「土管」を追加。丸い口が見える配置アイテムとして表示。 +- たりないが巣箱のように土管の中へ入り、内部の個体は巣箱系ツールチップで確認可能。 +- 土管から出る時は、複数ある場合は別のランダムな土管から出る。 +- 土管内でも眠れるが、寝心地と睡眠優先順位は干草寝床の一段下。 + diff --git a/app_manifest.json b/app_manifest.json index ef67904..a7f37e2 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.110", + "version": "39.15.116", "css": [ "css/base.css", "css/layout.css", diff --git a/css/base.css b/css/base.css index 58f4e9a..f87ca66 100644 --- a/css/base.css +++ b/css/base.css @@ -450,6 +450,10 @@ h1 { background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96)); border: 1px solid rgba(94, 70, 42, 0.20); box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72); + max-height: calc(100dvh - 36px); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; } .signboard-editor-titlebar { display: flex; @@ -595,6 +599,10 @@ h1 { background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.97)); border: 1px solid rgba(94, 70, 42, 0.20); box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72); + max-height: calc(100dvh - 36px); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; } .rotator-editor-titlebar { display: flex; @@ -660,7 +668,8 @@ h1 { border-color: rgba(112, 80, 170, 0.38); font-weight: 700; } -#rotatorCanvas { +#rotatorCanvas, +#reciprocatorCanvas { width: 100%; height: min(58vh, 360px); display: block; @@ -682,7 +691,44 @@ h1 { margin-top: 12px; } @media (max-width: 560px) { - .rotator-editor-panel { padding: 10px; } + .rotator-editor { + place-items: start center; + padding: 8px; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + .rotator-editor-panel { + width: calc(100vw - 16px); + max-height: calc(100dvh - 16px); + padding: 10px; + } .rotator-editor-controls label { grid-template-columns: 1fr; align-items: stretch; } - #rotatorCanvas { height: 320px; } + .rotator-editor-tools { max-height: 22vh; overflow-y: auto; padding-right: 2px; } + #rotatorCanvas, #reciprocatorCanvas { height: min(44vh, 270px); } +} + +.fan-editor-panel { width: min(430px, calc(100vw - 28px)); } +.fan-editor-help { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin: 0 2px 10px; +} +.fan-editor-controls label { + grid-template-columns: 56px 1fr 72px auto; +} +.fan-editor-controls .fan-editor-check { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255,255,255,0.58); + border: 1px solid rgba(84,68,48,0.11); +} +.fan-editor-controls input[type="checkbox"] { accent-color: #70aee8; } +@media (max-width: 560px) { + .fan-editor-controls label { grid-template-columns: 1fr; align-items: stretch; } + .fan-editor-panel { width: calc(100vw - 16px); } } diff --git a/index.html b/index.html index edad51f..f017603 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -257,164 +257,164 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/audio.js b/js/audio.js index 9136fa0..ca6aaa3 100644 --- a/js/audio.js +++ b/js/audio.js @@ -307,7 +307,7 @@ const audio = { if (type === "firecracker") return this.play("sfx_firecracker_fuse", { category: "ops", minGap: 0.18 }); if (type === "grass") return this.play("sfx_grass", { category: "ops", minGap: 0.08 }); if (type === "zunchi") return this.play("sfx_zunchi", { category: "ops", minGap: 0.12 }); - if (type === "ant_nest" || type === "nest_box") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 }); + if (type === "ant_nest" || type === "nest_box" || type === "pipe") return this.play("sfx_ant_nest", { category: "ops", minGap: 0.12 }); return this.play("sfx_place", { category: "ops", minGap: 0.08 }); }, delete() { this.play("sfx_delete", { category: "ops", minGap: 0.08 }); }, diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index 9555399..47186b7 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -85,7 +85,7 @@ if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true; } else if (!rects.length) { const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12); - const defaultMul = item.type === "ball" ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2); + const defaultMul = (item.type === "ball" || item.type === "balloon") ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2); const mul = num(opts.radiusMultiplier, defaultMul); const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul); hit = bestDistance <= r + padding; @@ -95,7 +95,7 @@ function itemPlacementOverlapBlocked(worldRef, item, opts = {}) { if (!worldRef || !item || item.dead) return true; - const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box"]); + const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]); const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || item.type === "rope" || item.type === "rod" || isFenceItemType(item.type)); if (!important.has(item.type) && !isPhysicsItem) return false; const itemRects = placementRectsFor(worldRef, item); diff --git a/js/collision_response_system.js b/js/collision_response_system.js index 8795998..a7ba81f 100644 --- a/js/collision_response_system.js +++ b/js/collision_response_system.js @@ -6,22 +6,35 @@ const physicalDamageFromImpactSpeed = ImpactCore.physicalDamageFromImpactSpeed; const itemHasAttachedLink = ImpactCore.itemHasAttachedLink; + function isBallLikeType(type = "") { return type === "ball" || type === "balloon"; } + function ballLikeCount(worldRef) { return (worldRef?.itemCounts?.ball || 0) + (worldRef?.itemCounts?.balloon || 0); } + function ballLikeItems(worldRef) { + if (!worldRef) return []; + if (worldRef.itemsOfType) { + const balls = worldRef.itemsOfType("ball") || []; + const balloons = worldRef.itemsOfType("balloon") || []; + return balls.concat(balloons); + } + return (worldRef.items || []).filter(it => it && !it.dead && isBallLikeType(it.type)); + } + function ballLikeLabel(item) { return item?.type === "balloon" ? "水風船" : "ボール"; } + function resolveBallBallCollisions(worldRef, dt) { if (!worldRef) return undefined; - if ((worldRef.itemCounts?.ball || 0) < 2) return; + if (ballLikeCount(worldRef) < 2) return; const now = worldRef.time || 0; if (!worldRef.ballCollisionMemo) worldRef.ballCollisionMemo = new Map(); - const balls = worldRef.itemsOfType?.("ball") || (worldRef.items || []).filter(it => it && !it.dead && it.type === "ball"); + const balls = ballLikeItems(worldRef); for (const a of balls) { - if (!a || a.dead || a.type !== "ball") continue; + if (!a || a.dead || !isBallLikeType(a.type)) continue; const ar = a.r || 18; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; for (const b of worldRef.nearbyItems(a.x, a.y, range) || []) { - if (!b || b === a || b.dead || b.type !== "ball") continue; + if (!b || b === a || b.dead || !isBallLikeType(b.type)) continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; if (now - (worldRef.ballCollisionMemo.get(key) || -999) < 0.055) continue; const br = b.r || 18; @@ -75,7 +88,7 @@ const relNormal = rvx * nx + rvy * ny; if (relNormal > 0 && d >= hitRadius - 0.5) continue; worldRef.ballCollisionMemo.set(key, now); - const restitution = 0.86; + const restitution = (a.type === "balloon" || b.type === "balloon") ? 0.58 : 0.86; const impulse = -(1 + restitution) * relNormal / 2; if (Number.isFinite(impulse)) { a.vx = (a.vx || 0) - impulse * nx; @@ -102,9 +115,9 @@ function resolveBallInteractions(worldRef, dt) { if (!worldRef) return undefined; - if (!worldRef.itemCounts?.ball) return; - for (const ball of (worldRef.itemsOfType ? worldRef.itemsOfType("ball") : worldRef.items)) { - if (!ball || ball.dead || ball.type !== "ball") continue; + if (ballLikeCount(worldRef) < 1) return; + for (const ball of ballLikeItems(worldRef)) { + if (!ball || ball.dead || !isBallLikeType(ball.type)) continue; const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84; const range = (ball.r || 18) + 72 + sweep; @@ -127,7 +140,7 @@ if (d > hitDistance) continue; const now = worldRef.time || 0; const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18; - if (repeat && ballSpeed < 300) continue; + if (repeat && ballSpeed < (ball.type === "balloon" ? 180 : 300)) continue; let nx = (hitX - t.x) / d; let ny = (hitY - t.y) / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { @@ -140,7 +153,7 @@ } } - const transferredPins = globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 0; + const transferredPins = ball.type === "ball" ? (globalThis.TarinaiItemDynamicPinSystem.transferBallPinsToTarinai(ball, t, worldRef) || 0) : 0; if (transferredPins > 0) { ball.vx = (ball.vx || 0) * 0.72 - nx * Math.min(78, Math.max(18, ballSpeed * 0.06)); ball.vy = (ball.vy || 0) * 0.72 - ny * Math.min(78, Math.max(18, ballSpeed * 0.06)); @@ -185,7 +198,9 @@ continue; } - const isDangerous = ballSpeed >= 285; + const isBalloon = ball.type === "balloon"; + const objectLabel = ballLikeLabel(ball); + const isDangerous = !isBalloon && ballSpeed >= 285; if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) { const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx; const damage = physicalDamageFromImpactSpeed(ballSpeed, { min: 3.5, max: 38, scale: 18 }); @@ -223,9 +238,10 @@ const relativeSpeed = Math.hypot(relVx, relVy); const playIntent = t.state === "play_ball" || t.target === ball; const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); - const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); - ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; - ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; + const impulseBase = isBalloon ? 34 : 48; + const impulse = clamp(impulseBase + relativeSpeed * (isBalloon ? 0.48 : 0.72) + (playIntent ? (isBalloon ? 34 : 52) : 0) + (playful ? (isBalloon ? 18 : 24) : 0), impulseBase, isDangerous ? 360 : (isBalloon ? 165 : 260)); + ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + nx * impulse + (t.vx || 0) * (isBalloon ? 0.32 : 0.52); + ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : (isBalloon ? 0.72 : 0.92)) + ny * impulse + (t.vy || 0) * (isBalloon ? 0.32 : 0.52); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38); ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding); ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding); @@ -235,7 +251,7 @@ t.vy -= ny * Math.min(22, impulse * 0.10); t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; - const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; + const sensitiveBump = !isBalloon && t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); if (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); @@ -246,8 +262,8 @@ t.nextPlayBubbleAt = now + deterministicRange(worldRef, "ball-play-bubble-delay", 2.0, 3.8, ball, t); worldRef.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "\u306f\u3046", "?" ]), "rgba(70,96,50,0.76)"); } - if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}\u306f\u30dc\u30fc\u30eb\u3092\u8ffd\u3044\u304b\u3051\u3066\u5f3e\u3044\u305f\u3002`, "observe", { participants: [t] }); - worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" })); + if (playful && worldRef.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) worldRef.log(`${t.name}は${objectLabel}を追いかけて弾いた。`, "observe", { participants: [t] }); + worldRef.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: isBalloon ? "rgba(248,122,166,0.42)" : "rgba(170,210,95,0.50)" })); } } diff --git a/js/constraint_system.js b/js/constraint_system.js index 2856ca1..1a4aca7 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -323,6 +323,7 @@ if (!obj || obj.dead) return Infinity; if (obj._heldByPlayer || obj.playerHeld) return Infinity; if (obj.radius && obj.name) return 1.0; + if (obj.type === "balloon") return 0.22; if (obj.type === "ball") return 0.7; if (obj.type === "zunchi") return 0.55; if (obj.type === "rope" || obj.type === "rod") return 1.6; @@ -332,7 +333,7 @@ if (obj.type === "poison_block") return Math.max(0.35, ps(obj, "mass", 0.45)); return 8.0; } - if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5; + if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box" || obj.type === "pipe")) return obj.type.includes("fence") ? Infinity : 5.5; return 2.2; } diff --git a/js/impact_response_system.js b/js/impact_response_system.js index dd7ea91..21321b5 100644 --- a/js/impact_response_system.js +++ b/js/impact_response_system.js @@ -50,14 +50,19 @@ hit += 1; } let ballHit = 0; - if (isGenkotsu && worldRef.itemCounts?.ball) { + if (isGenkotsu && (worldRef.itemCounts?.ball || worldRef.itemCounts?.balloon)) { const ballRange = radius + Math.max(72, (it.r || 88) * 0.78); const balls = worldRef.nearbyItems?.(it.x, it.y, ballRange + 42, true) || worldRef.items || []; for (const ball of balls) { - if (!ball || ball.dead || ball.type !== "ball") continue; + if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue; const d = Math.max(1, distXY(ball.x, ball.y, it.x, it.y)); const p = clamp(1 - d / (ballRange + (ball.r || 18)), 0, 1); if (p <= 0) continue; + if (ball.type === "balloon") { + globalThis.TarinaiItemDynamicBallSystem?.popBalloon?.(ball, worldRef, it); + ballHit += 1; + continue; + } let nx = (ball.x - it.x) / d; let ny = (ball.y - it.y) / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { diff --git a/js/item_dynamic_ball_system.js b/js/item_dynamic_ball_system.js index fd9838a..fd1df43 100644 --- a/js/item_dynamic_ball_system.js +++ b/js/item_dynamic_ball_system.js @@ -1,10 +1,20 @@ "use strict"; // Layer: entity-runtime/items/dynamic-system -// Owns ball item motion and obstacle bounce behavior. Item.prototype ball -// methods delegate to this system. +// Owns ball-like item motion, balloon drift/pop behavior, and fan wind. +// Item.prototype ball/balloon/fan methods delegate to this system. (function (global) { + function isBallLike(item) { + return !!item && !item.dead && (item.type === "ball" || item.type === "balloon"); + } + function update(item, dt, worldRef) { + if (!isBallLike(item)) return false; + if (item.type === "balloon") return updateBalloon(item, dt, worldRef); + return updateBall(item, dt, worldRef); + } + + function updateBall(item, dt, worldRef) { if (!item || item.dead || item.type !== "ball") return false; item.prevX = item.x; item.prevY = item.y; @@ -32,14 +42,58 @@ return true; } + function updateBalloon(item, dt, worldRef) { + if (!item || item.dead || item.type !== "balloon") return false; + const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016); + item.prevX = item.x; + item.prevY = item.y; + + // A balloon should never be completely inert: it slowly wobbles, then fans + // and collisions can turn that small drift into visible motion. + const phase = (worldRef?.time || 0) * 1.35 + (item.seed || 0) * 0.017 + (item.balloonWobble || 0); + item.vx = (item.vx || 0) + Math.cos(phase * 0.83) * 6.5 * frameDt + Math.sin(phase * 0.31) * 2.4 * frameDt; + item.vy = (item.vy || 0) + Math.sin(phase * 0.71) * 5.4 * frameDt; + + const maxSpeed = 560; + const speedBefore = Math.hypot(item.vx || 0, item.vy || 0); + if (speedBefore > maxSpeed) { + const s = maxSpeed / speedBefore; + item.vx *= s; + item.vy *= s; + } + + const moving = Math.hypot(item.vx || 0, item.vy || 0); + if (moving > 0.04) { + item.x += (item.vx || 0) * dt; + item.y += (item.vy || 0) * dt; + const p = Math.max(28, CONFIG.worldPadding || 30); + if (item.x < p) { item.x = p; item.vx = Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; } + if (item.x > worldRef.w - p) { item.x = worldRef.w - p; item.vx = -Math.abs(item.vx || 0) * 0.58; item.spinVelocity *= -0.44; } + if (item.y < p) { item.y = p; item.vy = Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; } + if (item.y > worldRef.h - p) { item.y = worldRef.h - p; item.vy = -Math.abs(item.vy || 0) * 0.58; item.spinVelocity *= -0.44; } + resolveObstacleCollisions(item, dt, worldRef); + item.x = clamp(item.x, p, worldRef.w - p); + item.y = clamp(item.y, p, worldRef.h - p); + const airFriction = Math.pow(0.47, frameDt); + item.vx *= airFriction; + item.vy *= airFriction; + } + + checkBalloonHazards(item, worldRef); + item.spin = (item.spin || 0) + (item.spinVelocity || 0) * frameDt + (item.vx || 0) * frameDt / Math.max(12, item.r || 20) * 0.42; + item.spinVelocity *= Math.pow(0.58, frameDt); + if (Math.hypot(item.vx || 0, item.vy || 0) < 0.08) { item.vx = 0; item.vy = 0; } + return true; + } + function resolveObstacleCollisions(item, dt, worldRef) { if (!worldRef?.nearbyItems) return; const speed = Math.hypot(item.vx || 0, item.vy || 0); const searchRadius = Math.max(150, (item.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120); const items = worldRef.nearbyItems(item.x, item.y, searchRadius) || []; const seen = new Set(items); - // \u5927\u304d\u306a\u6a5f\u69cb\u306f\u4e2d\u5fc3\u304c\u8fd1\u508d\u30bb\u30eb\u5916\u3067\u3082\u5f53\u305f\u308b\u53ef\u80fd\u6027\u304c\u3042\u308b\u3002 - // \u4e2d\u5fc3\u3067\u306f\u306a\u304f\u5916\u63a5\u534a\u5f84\u3067\u8ffd\u52a0\u8d70\u67fb\u3057\u3066\u3001\u9577\u3044\u68d2\u30fb\u5f80\u5fa9\u5e45\u306b\u3082\u885d\u7a81\u3055\u305b\u308b\u3002 + // 大きな機構は中心が近傍セル外でも当たる可能性がある。 + // 中心ではなく外接半径で追加走査して、長い棒・往復幅にも衝突させる。 if (Array.isArray(worldRef.items)) { for (const it of worldRef.items) { if (!it || it.dead || it === item || !global.TarinaiMechanicalSystem.isMechanicalType(it.type) || seen.has(it)) continue; @@ -55,7 +109,14 @@ if (it.type === "bed") { applyHayDrag(item, it, dt, worldRef); } else if (it.type === "stone") { - resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), 0.82, worldRef); + resolveCircleBounce(item, it, (it.r || 20) * 1.08 + (item.r || 18), item.type === "balloon" ? 0.48 : 0.82, worldRef); + } else if (it.type === "fan" && item.type === "balloon") { + const wind = fanWindAt(it, item.x, item.y); + if (wind) { + const a = itemAngleFor(it); + item.vx += Math.cos(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016); + item.vy += Math.sin(a) * 38 * wind.falloff * Math.max(0.016, dt || 0.016); + } } const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : []; if (!rects.length) continue; @@ -70,7 +131,7 @@ const dy = item.y - bed.y; const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1; if (!inside) return; - const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016)); + const slow = Math.pow(item.type === "balloon" ? 0.28 : 0.16, Math.max(0.016, dt || 0.016)); item.vx *= slow; item.vy *= slow; item.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016)); @@ -119,12 +180,14 @@ item.vx = (item.vx || 0) - (1 + restitution) * toward * nx; item.vy = (item.vy || 0) - (1 + restitution) * toward * ny; } else { - item.vx = (item.vx || 0) + nx * 24; - item.vy = (item.vy || 0) + ny * 24; + const push = item.type === "balloon" ? 12 : 24; + item.vx = (item.vx || 0) + nx * push; + item.vy = (item.vy || 0) + ny * push; } - item.vx *= 0.96; - item.vy *= 0.96; - item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38); + const damp = item.type === "balloon" ? 0.84 : 0.96; + item.vx *= damp; + item.vy *= damp; + item.spinVelocity = clamp((item.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (item.type === "balloon" ? 2.2 : 5.5), -38, 38); emitBounce(item, worldRef, obstacle); } @@ -133,13 +196,13 @@ const bounceCircleOffRect = global.TarinaiPhysics?.bounceCircleOffRect; if (typeof bounceCircleOffRect !== "function") return; const hitRadius = (item.r || 18) + 2; - const restitution = Number.isFinite(rect.restitution) ? rect.restitution : 0.78; - const velocityDamp = rect.bounce ? 1.0 : 0.94; + const restitution = Number.isFinite(rect.restitution) ? rect.restitution : (item.type === "balloon" ? 0.54 : 0.78); + const velocityDamp = rect.bounce ? 1.0 : (item.type === "balloon" ? 0.82 : 0.94); const minBounceSpeed = Math.max(0, Number(rect.minBounceSpeed || 0) || 0); const bounced = bounceCircleOffRect(item, rect, hitRadius, restitution, worldRef, { minBounceSpeed: rect.bounce ? minBounceSpeed : 0, - fallbackImpulse: 18, - spinKick: 6.2, + fallbackImpulse: item.type === "balloon" ? 10 : 18, + spinKick: item.type === "balloon" ? 2.4 : 6.2, spinLimit: 38, }); if (!bounced) return; @@ -152,12 +215,343 @@ const now = worldRef?.time || 0; if ((item.lastObstacleBounceAt || -999) + 0.08 > now) return; item.lastObstacleBounceAt = now; - const color = (obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)"); + const color = item.type === "balloon" ? "rgba(248,122,166,0.36)" : ((obstacle?.type === "bounce_fence" || obstacle?.type === "bounce_fence_v") ? "rgba(82,153,230,0.46)" : (obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)")); worldRef?.effects?.push(new Effect("ring", item.x, item.y, { size: Math.max(13, (item.r || 18) * 0.82), life: 0.18, color })); } + function normalizeAngleLocal(angle = 0, fallback = 0) { + if (typeof normalizedItemAngle === "function") return normalizedItemAngle(angle, fallback); + const twoPi = Math.PI * 2; + const n = Number(angle); + const base = Number.isFinite(n) ? n : (Number.isFinite(Number(fallback)) ? Number(fallback) : 0); + return ((base % twoPi) + twoPi) % twoPi; + } + + function updateFanSwing(item, dt) { + if (!item || item.dead || item.type !== "fan") return false; + if (!item.fanSwingOn) { + item.fanSwingCenter = Number.isFinite(Number(item.fanSwingCenter)) ? Number(item.fanSwingCenter) : itemAngleFor(item); + return false; + } + const center = normalizeAngleLocal(item.fanSwingCenter, itemAngleFor(item)); + const range = clamp(Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0, 0, 150 * Math.PI / 180); + const speed = clamp(Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0, 0, 360 * Math.PI / 180); + item.fanSwingCenter = center; + item.fanSwingRange = range; + item.fanSwingSpeed = speed; + item.fanSwingPhase = Number(item.fanSwingPhase || 0) + speed * Math.max(0.016, Number(dt || 0.016) || 0.016); + item.angle = normalizeAngleLocal(center + Math.sin(item.fanSwingPhase || 0) * range, center); + return true; + } + + function fanWindAt(fan, x, y, opts = {}) { + if (!fan || fan.dead || fan.type !== "fan") return null; + const range = Math.max(80, Number(opts.range || 300) || 300); + const spread = Math.max(0.18, Number(opts.spread || 0.62) || 0.62); + const dx = x - fan.x; + const dy = y - fan.y; + const angle = itemAngleFor(fan); + const ax = Math.cos(angle); + const ay = Math.sin(angle); + const along = dx * ax + dy * ay; + if (along < 8 || along > range) return null; + const lateral = Math.abs(-dx * ay + dy * ax); + const halfWidth = 26 + along * Math.tan(spread); + if (lateral > halfWidth) return null; + const distFalloff = clamp(1 - along / range, 0, 1); + const edgeFalloff = clamp(1 - lateral / Math.max(1, halfWidth), 0, 1); + const falloff = Math.pow(distFalloff, 0.64) * Math.pow(edgeFalloff, 0.34); + if (falloff <= 0.02) return null; + return { angle, along, lateral, halfWidth, range, falloff }; + } + + function updateFan(item, dt, worldRef) { + if (!item || item.dead || item.type !== "fan") return false; + const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016); + const now = worldRef?.time || 0; + item.prevX = item.x; + item.prevY = item.y; + item.fanSpin = (item.fanSpin || 0) + 11.5 * frameDt; + item.fanPulse = (item.fanPulse || 0) + 2.2 * frameDt; + updateFanSwing(item, frameDt); + + const angle = itemAngleFor(item); + const ax = Math.cos(angle); + const ay = Math.sin(angle); + const range = 320; + const searchRadius = range + 40; + const nearbyItems = worldRef?.nearbyItems?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || []; + for (const target of nearbyItems) { + if (!target || target.dead || target === item) continue; + const wind = fanWindAt(item, target.x, target.y, { range }); + if (!wind) continue; + applyFanToItem(item, target, wind, frameDt, worldRef); + } + + const nearbyTarinai = worldRef?.nearbyTarinai?.(item.x + ax * range * 0.45, item.y + ay * range * 0.45, searchRadius) || []; + for (const t of nearbyTarinai) { + if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; + const wind = fanWindAt(item, t.x, t.y, { range }); + if (!wind) continue; + applyFanToTarinai(item, t, wind, frameDt, worldRef); + } + + if ((item.lastFanEffectAt || -999) + 0.18 < now) { + item.lastFanEffectAt = now; + const phase = Math.sin(item.fanPulse || 0); + const px = item.x + ax * (42 + 24 * phase); + const py = item.y + ay * (42 + 24 * phase); + worldRef?.effects?.push(new Effect("ring", px, py, { size: 12 + 7 * Math.abs(phase), life: 0.18, color: "rgba(174,214,236,0.26)" })); + } + return true; + } + + function applyFanToItem(fan, target, wind, dt, worldRef) { + const type = target.type || ""; + let massFactor = 0; + if (type === "balloon") massFactor = 1.85; + else if (type === "ball") massFactor = 0.72; + else if (type === "pushpin" || type === "oshibyo") massFactor = 0.90; + else if (type === "water" || type === "zunda_juice" || type === "mercury") massFactor = 0.42; + else if (type === "zunchi") massFactor = 0.30; + else if (type === "fire") massFactor = 0.18; + else return; + + const force = 236 * wind.falloff * massFactor; + const ax = Math.cos(wind.angle); + const ay = Math.sin(wind.angle); + target.prevX = Number.isFinite(target.prevX) ? target.prevX : target.x; + target.prevY = Number.isFinite(target.prevY) ? target.prevY : target.y; + target.vx = clamp((target.vx || 0) + ax * force * dt, -820, 820); + target.vy = clamp((target.vy || 0) + ay * force * dt, -820, 820); + if (type === "balloon") target.spinVelocity = clamp((target.spinVelocity || 0) + 3.2 * wind.falloff * dt, -12, 12); + else if (type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + 5.4 * wind.falloff * dt, -38, 38); + if (type === "fire") target.amount = Math.max(0, (target.amount ?? 1) - wind.falloff * dt * 1.0); + worldRef?.markSpatialDirty?.("fan-wind-item"); + } + + function applyFanToTarinai(fan, t, wind, dt, worldRef) { + const ax = Math.cos(wind.angle); + const ay = Math.sin(wind.angle); + const radius = t.radius || 18; + const speed = 30 * wind.falloff; + t.vx = clamp((t.vx || 0) + ax * speed * dt, -360, 360); + t.vy = clamp((t.vy || 0) + ay * speed * dt, -360, 360); + t.fanBreezeTimer = Math.max(t.fanBreezeTimer || 0, 0.35); + if (wind.falloff > 0.58 && (worldRef?.time || 0) - (t.lastFanSurpriseAt || -999) > 2.2) { + t.lastFanSurpriseAt = worldRef?.time || 0; + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.12); + if (typeof t.spawnBubble === "function") t.spawnBubble("~", { life: 0.55 }); + worldRef?.effects?.push(new Effect("ring", t.x + ax * radius * 0.4, t.y + ay * radius * 0.2, { size: Math.max(12, radius * 0.72), life: 0.16, color: "rgba(174,214,236,0.28)" })); + } + } + + function checkBalloonHazards(balloon, worldRef) { + if (!balloon || balloon.dead || balloon.type !== "balloon" || !worldRef?.nearbyItems) return; + const now = worldRef.time || 0; + if ((balloon.balloonPoppedAt || -999) + 0.5 > now) return; + const radius = Math.max(26, (balloon.r || 20) + 22); + for (const it of worldRef.nearbyItems(balloon.x, balloon.y, radius) || []) { + if (!it || it.dead || it === balloon) continue; + if ((typeof isPinType === "function" && isPinType(it.type) && it.pinState !== "lodged") || it.type === "fire" || it.type === "flame_firecracker") { + const hit = distXY(balloon.x, balloon.y, it.x, it.y) <= (balloon.r || 20) + (it.r || 10) + (it.type === "fire" ? 16 : 4); + if (hit) { + popBalloon(balloon, worldRef, it); + return; + } + } + } + } + + const WATER_BALLOON_SURPRISE_TEXT = "水風船が割れておどろいている。"; + + function tarinaiOpennessValue(t) { + const v = (typeof global.effectivePersonalityValue === "function") + ? global.effectivePersonalityValue(t, "openness") + : (t?.currentPersonality?.openness ?? t?.birthPersonality?.openness ?? t?.personality?.openness ?? 0); + return Number(v) || 0; + } + + function shouldPanicFromWaterBalloon(t, strength, worldRef, balloon) { + const openness = tarinaiOpennessValue(t); + if (openness >= -0.08) return false; + if (openness <= -0.48) return true; + const chance = clamp(0.32 + Math.max(0, -openness) * 0.70 + Math.max(0, strength) * 0.26, 0.34, 0.92); + return deterministicChance(worldRef || t?.world || null, "water-balloon-low-openness-panic", chance, t, balloon, Math.round(strength * 1000)); + } + + function setWaterBalloonSurpriseBehavior(t, target, panic = false) { + if (!t || t.dead) return false; + t.lastPanicDetail = WATER_BALLOON_SURPRISE_TEXT; + t.thought = WATER_BALLOON_SURPRISE_TEXT; + if (typeof global.setBehavior === "function") { + global.setBehavior(t, { + actionId: panic ? "panic_escape" : "water_balloon_surprise", + need: "safety", + label: WATER_BALLOON_SURPRISE_TEXT, + text: WATER_BALLOON_SURPRISE_TEXT, + reason: WATER_BALLOON_SURPRISE_TEXT, + target: target || null, + phase: "perform", + source: "behavior", + tiedNeeds: ["safety"], + lockSeconds: panic ? 2.6 : 1.1, + minDuration: panic ? 1.2 : 0.55, + }); + return true; + } + if (typeof global.setBehaviorText === "function") { + global.setBehaviorText(t, { need: "safety", actionId: panic ? "panic_escape" : "water_balloon_surprise", actionLabel: WATER_BALLOON_SURPRISE_TEXT, reasonText: WATER_BALLOON_SURPRISE_TEXT, target: target || null, phase: "perform", source: "behavior" }); + return true; + } + return false; + } + + function spawnWaterFromBalloon(balloon, worldRef) { + if (!balloon || !worldRef) return 0; + const baseR = Math.max(14, Number(balloon.r || 20) || 20); + const count = Math.max(7, Math.min(13, Math.round(baseR * 0.42))); + let spawned = 0; + for (let i = 0; i < count; i++) { + if (typeof global.Item !== "function") break; + const a = deterministicAngle(worldRef || balloon, "water-balloon-splash-angle", balloon, i); + const d = deterministicRange(worldRef || balloon, "water-balloon-splash-dist", baseR * 0.10, baseR * 0.58, balloon, i); + const speed = deterministicRange(worldRef || balloon, "water-balloon-drop-speed", 150, 420, balloon, i); + const drop = new global.Item("water", balloon.x + Math.cos(a) * d, balloon.y + Math.sin(a) * d); + drop.r = deterministicRange(worldRef || balloon, "water-balloon-drop-radius", 5.5, 10.5, balloon, i); + drop.amount = deterministicRange(worldRef || balloon, "water-balloon-drop-amount", 10, 28, balloon, i); + drop.prevX = drop.x; + drop.prevY = drop.y; + drop.vx = Math.cos(a) * speed + (Number(balloon.vx || 0) || 0) * 0.18; + drop.vy = Math.sin(a) * speed + (Number(balloon.vy || 0) || 0) * 0.18; + drop.splashFlightTimer = deterministicRange(worldRef || balloon, "water-balloon-drop-flight", 0.30, 0.62, balloon, i); + drop.splashSourceId = balloon.id || ""; + if (worldRef.addItem?.(drop, "water-balloon-splash", { terrain: false })) spawned += 1; + else { worldRef.items?.push?.(drop); spawned += 1; } + if (i < 8) { + worldRef.effects?.push(new Effect("fight", drop.x, drop.y, { + size: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-size", 3.5, 7.0, balloon, i), + life: deterministicRange(worldRef || balloon, "water-balloon-drop-spark-life", 0.24, 0.42, balloon, i), + color: "rgba(96,196,244,0.66)", + vx: drop.vx * 0.42, + vy: drop.vy * 0.42, + })); + } + } + if (spawned) { + worldRef.markItemBucketsDirty?.("water-balloon-splash"); + worldRef.markSpatialDirty?.("water-balloon-splash"); + worldRef.markTerrainDirtyAt?.(balloon.x, balloon.y, Math.max(70, baseR * 5.2), "water-balloon-splash"); + } + return spawned; + } + + function updateWaterDropMotion(item, dt, worldRef) { + if (!item || item.dead || item.type !== "water") return false; + const frameDt = Math.max(0.016, Number(dt || 0.016) || 0.016); + const speed = Math.hypot(item.vx || 0, item.vy || 0); + if (speed <= 0.05 && !(item.splashFlightTimer > 0)) return false; + item.prevX = Number.isFinite(Number(item.x)) ? item.x : 0; + item.prevY = Number.isFinite(Number(item.y)) ? item.y : 0; + item.x += (Number(item.vx || 0) || 0) * frameDt; + item.y += (Number(item.vy || 0) || 0) * frameDt; + const pad = Math.max(28, CONFIG.worldPadding || 30); + const r = Math.max(5, Number(item.r || 8) || 8); + if (worldRef) { + if (item.x < pad) { item.x = pad; item.vx = Math.abs(item.vx || 0) * 0.30; } + if (item.x > worldRef.w - pad) { item.x = worldRef.w - pad; item.vx = -Math.abs(item.vx || 0) * 0.30; } + if (item.y < pad) { item.y = pad; item.vy = Math.abs(item.vy || 0) * 0.30; } + if (item.y > worldRef.h - pad) { item.y = worldRef.h - pad; item.vy = -Math.abs(item.vy || 0) * 0.30; } + } + item.splashFlightTimer = Math.max(0, Number(item.splashFlightTimer || 0) - frameDt); + const damp = item.splashFlightTimer > 0 ? Math.pow(0.78, frameDt * 60) : Math.pow(0.58, frameDt * 60); + item.vx *= damp; + item.vy *= damp; + if (Math.hypot(item.vx || 0, item.vy || 0) < 8 && item.splashFlightTimer <= 0) { + item.vx = 0; + item.vy = 0; + } + if (worldRef && ((item.lastSplashTrailAt || -999) + 0.12 < (worldRef.time || 0)) && Math.hypot(item.vx || 0, item.vy || 0) > 72) { + item.lastSplashTrailAt = worldRef.time || 0; + worldRef.effects?.push(new Effect("fight", item.x, item.y, { size: Math.max(2.8, r * 0.55), life: 0.18, color: "rgba(112,206,248,0.36)", vx: -(item.vx || 0) * 0.05, vy: -(item.vy || 0) * 0.05 })); + } + worldRef?.markSpatialDirty?.("water-drop-motion"); + worldRef?.markTerrainDirtyAt?.(item.x, item.y, Math.max(28, r * 3.2), "water-drop-motion"); + if (worldRef) worldRef.drawListDirty = true; + return true; + } + + function applyWaterBalloonSurprise(balloon, worldRef) { + for (const t of worldRef?.nearbyTarinai?.(balloon.x, balloon.y, 165) || []) { + if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue; + const d = Math.max(1, distXY(balloon.x, balloon.y, t.x, t.y)); + const strength = clamp(1 - d / 165, 0, 1); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34 + strength * 0.44); + if (typeof t.addStress === "function") t.addStress(0.18 + strength * 0.42, "水風船が割れて驚いた"); + if (typeof t.spawnBubble === "function" && strength > 0.35) t.spawnBubble("!", { life: 0.72 }); + const awayX = (t.x - balloon.x) / d; + const awayY = (t.y - balloon.y) / d; + t.vx = clamp((t.vx || 0) + awayX * 68 * strength, -360, 360); + t.vy = clamp((t.vy || 0) + awayY * 68 * strength, -360, 360); + + const panic = shouldPanicFromWaterBalloon(t, strength, worldRef, balloon); + if (panic) { + const threat = { x: balloon.x, y: balloon.y, dead: true, type: "water_balloon_pop" }; + const destination = t.panicDestination?.(threat, true) || { x: t.x + awayX * 190, y: t.y + awayY * 190, dead: false, panic: true }; + t.enterPanic?.({ + threat, + destination, + target: destination, + reason: WATER_BALLOON_SURPRISE_TEXT, + cause: "water_balloon_pop", + fearTimer: 1.35 + strength * 1.30, + stress: 14 + strength * 24, + surpriseTimer: 0.70 + strength * 0.45, + wake: true, + forceDestination: true, + bubble: "!!", + bubbleCooldown: 0.7, + }) || t.setActionState?.("panic", { target: destination, reason: WATER_BALLOON_SURPRISE_TEXT, wake: true, sleeping: false }); + t.fearTimer = Math.max(t.fearTimer || 0, 1.85 + strength * 1.40); + t.panicStartedAt = worldRef?.time || t.panicStartedAt || 0; + t.panicHardStopAt = Math.max(t.panicHardStopAt || 0, (worldRef?.time || 0) + 5.5); + t.lastPanicCause = "water_balloon_pop"; + setWaterBalloonSurpriseBehavior(t, destination, true); + } else { + setWaterBalloonSurpriseBehavior(t, null, false); + } + } + } + + function popBalloon(balloon, worldRef, cause = null) { + if (!balloon || balloon.dead) return; + const now = worldRef?.time || 0; + balloon.balloonPoppedAt = now; + // Item.dead is a getter based on amount, so mark the water balloon gone + // through amount/hp instead of assigning to .dead directly. + balloon.amount = 0; + balloon.hp = 0; + worldRef?.effects?.push(new Effect("ring", balloon.x, balloon.y, { size: Math.max(32, (balloon.r || 20) * 1.9), life: 0.28, color: "rgba(76,178,236,0.62)" })); + for (let i = 0; i < 9; i++) { + const a = deterministicAngle(worldRef || balloon, "water-balloon-pop-piece", balloon, i); + const s = deterministicRange(worldRef || balloon, "water-balloon-pop-speed", 24, 96, balloon, i); + worldRef?.effects?.push(new Effect("fight", balloon.x, balloon.y, { size: deterministicRange(worldRef || balloon, "water-balloon-pop-size", 4, 10, balloon, i), life: 0.28, color: "rgba(95,204,246,0.72)", vx: Math.cos(a) * s, vy: Math.sin(a) * s })); + } + spawnWaterFromBalloon(balloon, worldRef); + const sourceLabel = cause?.type === "fire" || cause?.type === "flame_firecracker" ? "火" : (cause?.type === "genkotsu" ? "げんこつ" : (cause && typeof isPinType === "function" && isPinType(cause.type) ? "鋲" : "何か")); + if (worldRef?.relationNotice?.(balloon.id || "balloon", cause?.id || sourceLabel, "water-balloon-pop", 1.4)) { + worldRef.log?.(`水風船が${sourceLabel}に触れて割れた。`, "accident", { participants: [] }); + } + applyWaterBalloonSurprise(balloon, worldRef); + worldRef?.markSpatialDirty?.("water-balloon-pop"); + } + global.TarinaiItemDynamicBallSystem = Object.freeze({ update, + updateFan, + updateWaterDropMotion, + fanWindAt, resolveObstacleCollisions, + popBalloon, }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/item_dynamic_system.js b/js/item_dynamic_system.js index 4c20c86..ddf31de 100644 --- a/js/item_dynamic_system.js +++ b/js/item_dynamic_system.js @@ -20,7 +20,12 @@ case "rod": return global.TarinaiItemDynamicToolSystem.update(item, dt, worldRef) !== false; case "ball": + case "balloon": return global.TarinaiItemDynamicBallSystem.update(item, dt, worldRef) !== false; + case "fan": + return global.TarinaiItemDynamicBallSystem.updateFan(item, dt, worldRef) !== false; + case "water": + return global.TarinaiItemDynamicBallSystem.updateWaterDropMotion(item, dt, worldRef) !== false; case "duplicator": return global.TarinaiItemDynamicDuplicatorSystem.update(item, dt, worldRef) !== false; case "zunchi": diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index 6736c07..6dfb943 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -279,6 +279,86 @@ draw(ctx, t, lighting = null) { ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); + } else if (this.type === "pipe") { + const r = this.r || 36; + ctx.save(); + const body = styleNight ? "#506d5f" : (styleWarm ? "#7f9a72" : "#6f8d75"); + const side = styleNight ? "#3e574d" : (styleWarm ? "#637f5d" : "#587260"); + const lip = styleNight ? "#779083" : (styleWarm ? "#adc49b" : "#94b199"); + const lipDark = styleNight ? "#2f4640" : "#486858"; + const inner = styleNight ? "#0d1514" : "#152420"; + const outline = styleNight ? "rgba(9,15,15,0.86)" : "rgba(30,50,42,0.78)"; + const topY = -r * 0.42; + const bottomY = r * 0.54; + const topRx = r * 1.04; + const topRy = r * 0.43; + const bottomRx = r * 0.94; + const bottomRy = r * 0.34; + + // Shadow under the visible front edge. + ctx.fillStyle = "rgba(18,24,22,0.20)"; + ctx.beginPath(); + ctx.ellipse(0, r * 0.73, r * 1.12, r * 0.30, 0, 0, Math.PI * 2); + ctx.fill(); + + // Cylindrical side wall: the lower edge is visible because the pipe is viewed from above-front. + ctx.fillStyle = side; + ctx.strokeStyle = outline; + ctx.lineWidth = Math.max(2.2, r * 0.07); + ctx.beginPath(); + ctx.moveTo(-topRx, topY); + ctx.bezierCurveTo(-topRx * 0.98, topY + r * 0.30, -bottomRx * 1.02, bottomY - r * 0.16, -bottomRx, bottomY); + ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, Math.PI, 0, true); + ctx.bezierCurveTo(topRx * 1.02, bottomY - r * 0.16, topRx * 0.98, topY + r * 0.30, topRx, topY); + ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI, true); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Front lower rim shade. + ctx.fillStyle = body; + ctx.beginPath(); + ctx.ellipse(0, bottomY, bottomRx, bottomRy, 0, 0, Math.PI); + ctx.lineTo(bottomRx * 0.88, bottomY + r * 0.08); + ctx.ellipse(0, bottomY + r * 0.08, bottomRx * 0.88, bottomRy * 0.58, 0, 0, Math.PI, true); + ctx.closePath(); + ctx.fill(); + + // Top lip and visible round opening. + ctx.fillStyle = lip; + ctx.strokeStyle = outline; + ctx.lineWidth = Math.max(2.6, r * 0.08); + ctx.beginPath(); + ctx.ellipse(0, topY, topRx, topRy, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = inner; + ctx.strokeStyle = styleNight ? "rgba(2,7,7,0.88)" : "rgba(14,26,23,0.90)"; + ctx.lineWidth = Math.max(2.0, r * 0.065); + ctx.beginPath(); + ctx.ellipse(0, topY + r * 0.02, r * 0.64, r * 0.24, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = lipDark; + ctx.lineWidth = Math.max(1.4, r * 0.045); + ctx.beginPath(); + ctx.ellipse(0, topY + r * 0.02, r * 0.78, r * 0.32, 0, Math.PI * 0.04, Math.PI * 0.96); + ctx.stroke(); + + // Upper-left highlight for the rim and side. + ctx.globalAlpha = 0.34; + ctx.fillStyle = "rgba(255,255,255,0.86)"; + ctx.beginPath(); + ctx.ellipse(-r * 0.42, topY - r * 0.17, r * 0.24, r * 0.055, -0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 0.22; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineWidth = Math.max(1.1, r * 0.026); + ctx.beginPath(); + ctx.moveTo(-r * 0.72, -r * 0.10); + ctx.bezierCurveTo(-r * 0.66, r * 0.18, -r * 0.62, r * 0.32, -r * 0.48, r * 0.50); + ctx.stroke(); + ctx.restore(); } else if (this.type === "robot_cleaner") { ctx.save(); const flash = clamp(Number(this.robotCleanerCleanFlash || 0) || 0, 0, 1); @@ -605,6 +685,182 @@ draw(ctx, t, lighting = null) { ctx.fill(); } ctx.restore(); + } else if (this.type === "balloon") { + const speed = Math.hypot(this.vx || 0, this.vy || 0); + const moving = clamp(speed / 260, 0, 1); + const r = this.r || 20; + const wobble = Math.sin(t * 3.0 + (this.seed || 0) * 0.037 + (this.balloonWobble || 0)) * 0.10 + (this.spin || 0) * 0.05; + ctx.save(); + ctx.rotate(wobble); + if (itemShadowsEnabled()) { + ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(68,34,45,0.16)"; + ctx.shadowBlur = 2 + moving * 4; + ctx.shadowOffsetY = 2; + } + const grad = ctx.createRadialGradient(-r * 0.34, -r * 0.54, r * 0.16, 0, -r * 0.18, r * 1.18); + grad.addColorStop(0, "rgba(232,250,255,0.98)"); + grad.addColorStop(0.44, "rgba(96,196,238,0.94)"); + grad.addColorStop(1, "rgba(37,126,198,0.90)"); + ctx.fillStyle = grad; + ctx.strokeStyle = "rgba(34,82,125,0.70)"; + ctx.lineWidth = 1.8; + ctx.beginPath(); + ctx.ellipse(0, -r * 0.18, r * 0.82, r * 1.06, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.shadowColor = "transparent"; + ctx.fillStyle = "rgba(255,255,255,0.50)"; + ctx.beginPath(); + ctx.ellipse(-r * 0.28, -r * 0.62, r * 0.18, r * 0.28, -0.42, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(35,106,168,0.88)"; + ctx.beginPath(); + ctx.moveTo(-r * 0.18, r * 0.78); + ctx.lineTo(r * 0.18, r * 0.78); + ctx.lineTo(0, r * 1.12); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = "rgba(61,113,150,0.58)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(0, r * 1.06); + ctx.bezierCurveTo(-r * 0.18, r * 1.34, r * 0.20, r * 1.52, -r * 0.04, r * 1.76); + ctx.stroke(); + if (moving > 0.42) { + ctx.globalAlpha *= 0.10 + moving * 0.12; + ctx.strokeStyle = "rgba(231,249,255,0.90)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(-r * 0.10, -r * 0.18, r * 0.92, -1.92, -0.95); + ctx.stroke(); + } + ctx.restore(); + } else if (this.type === "fan") { + const r = this.r || 26; + const angle = itemAngleFor(this); + const spin = (this.fanSpin || 0) + t * 10.5; + ctx.save(); + ctx.rotate(angle); + const windRange = 320; + const windStart = r * 1.02; + const windSpread = 0.62; + const windHalfStart = 26 + windStart * Math.tan(windSpread); + const windHalfEnd = 26 + windRange * Math.tan(windSpread); + const fanAlpha = ctx.globalAlpha; + ctx.globalAlpha = fanAlpha * 0.16; + ctx.fillStyle = "rgba(132,190,220,0.34)"; + ctx.strokeStyle = "rgba(80,155,205,0.46)"; + ctx.lineWidth = 1.4; + ctx.setLineDash([10, 8]); + ctx.beginPath(); + ctx.moveTo(windStart, -windHalfStart); + ctx.lineTo(windRange, -windHalfEnd); + ctx.quadraticCurveTo(windRange + r * 0.36, 0, windRange, windHalfEnd); + ctx.lineTo(windStart, windHalfStart); + ctx.quadraticCurveTo(r * 1.28, 0, windStart, -windHalfStart); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.setLineDash([]); + + // Wind direction indicator: centerline + arrowheads. The cone shows the + // affected range; the arrows make the actual airflow direction explicit. + ctx.globalAlpha = fanAlpha * 0.52; + ctx.strokeStyle = "rgba(46,132,200,0.70)"; + ctx.fillStyle = "rgba(46,132,200,0.62)"; + ctx.lineWidth = 2.1; + ctx.setLineDash([16, 10]); + ctx.beginPath(); + ctx.moveTo(windStart + r * 0.55, 0); + ctx.lineTo(windRange - r * 0.50, 0); + ctx.stroke(); + ctx.setLineDash([]); + for (let i = 0; i < 3; i++) { + const x = windStart + r * 2.0 + i * ((windRange - windStart - r * 3.2) / 2); + const wobbleY = Math.sin(t * 2.4 + i * 1.7) * r * 0.035; + const arrow = Math.max(9, r * 0.42); + ctx.beginPath(); + ctx.moveTo(x + arrow * 0.55, wobbleY); + ctx.lineTo(x - arrow * 0.55, wobbleY - arrow * 0.48); + ctx.lineTo(x - arrow * 0.30, wobbleY); + ctx.lineTo(x - arrow * 0.55, wobbleY + arrow * 0.48); + ctx.closePath(); + ctx.fill(); + } + + ctx.globalAlpha = fanAlpha * 0.34; + ctx.strokeStyle = "rgba(132,190,220,0.42)"; + ctx.lineWidth = 2.0; + for (let i = -2; i <= 2; i++) { + const y = i * r * 0.28 + Math.sin(t * 3 + i) * r * 0.08; + ctx.beginPath(); + ctx.moveTo(r * 0.95, y); + ctx.bezierCurveTo(r * 1.55, y - r * 0.22, r * 2.30, y + r * 0.20, r * 3.05, y - r * 0.08); + ctx.stroke(); + } + ctx.globalAlpha = fanAlpha; + ctx.restore(); + ctx.save(); + ctx.rotate(angle); + if (itemShadowsEnabled()) { + ctx.shadowColor = styleNight ? "rgba(0,0,0,0.28)" : "rgba(52,40,26,0.16)"; + ctx.shadowBlur = 3; + ctx.shadowOffsetY = 2; + } + ctx.strokeStyle = "rgba(75,92,105,0.88)"; + ctx.fillStyle = "rgba(221,231,235,0.96)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.82, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.shadowColor = "transparent"; + ctx.strokeStyle = "rgba(96,117,130,0.42)"; + ctx.lineWidth = 1.0; + for (let i = 0; i < 8; i++) { + const a = i * Math.PI / 4; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(a) * r * 0.76, Math.sin(a) * r * 0.76); + ctx.stroke(); + } + ctx.save(); + ctx.rotate(spin); + ctx.fillStyle = "rgba(126,170,196,0.88)"; + ctx.strokeStyle = "rgba(62,82,96,0.44)"; + ctx.lineWidth = 1.1; + for (let i = 0; i < 3; i++) { + ctx.rotate(Math.PI * 2 / 3); + ctx.beginPath(); + ctx.ellipse(r * 0.30, 0, r * 0.38, r * 0.13, 0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + ctx.fillStyle = "rgba(65,78,86,0.94)"; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.16, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(62,82,96,0.74)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(-r * 0.22, r * 0.84); + ctx.lineTo(-r * 0.22, r * 1.35); + ctx.stroke(); + ctx.fillStyle = "rgba(91,104,112,0.88)"; + ctx.beginPath(); + ctx.ellipse(-r * 0.22, r * 1.42, r * 0.62, r * 0.16, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(65,78,86,0.74)"; + ctx.stroke(); + ctx.fillStyle = "rgba(92,116,132,0.96)"; + ctx.beginPath(); + ctx.moveTo(r * 0.82, 0); + ctx.lineTo(r * 1.08, -r * 0.14); + ctx.lineTo(r * 1.08, r * 0.14); + ctx.closePath(); + ctx.fill(); + ctx.restore(); } else if (this.type === "ball") { const speed = Math.hypot(this.vx || 0, this.vy || 0); const moving = clamp(speed / 260, 0, 1); diff --git a/js/item_tool_definitions.js b/js/item_tool_definitions.js index b22a327..4bd77fa 100644 --- a/js/item_tool_definitions.js +++ b/js/item_tool_definitions.js @@ -40,10 +40,13 @@ const TOOL_DEFINITIONS = Object.freeze({ genkotsu: { id: "genkotsu", itemType: "genkotsu", label: "\u3052\u3093\u3053\u3064", placeable: true, scalable: false, radius: 88, amount: 100, icon: "assets/ui/tool_genkotsu.webp", tooltip: "\u62f3\u3092\u5730\u9762\u306b\u305f\u305f\u304d\u3064\u3051\u308b\u3002" }, bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, + pipe: { id: "pipe", itemType: "pipe", label: "\u571f\u7ba1", placeable: true, scalable: false, radius: 36, amount: 999, iconText: "\u25ce", collisionShape: "circle", tooltip: "\u4e2d\u306b\u5165\u3063\u3066\u7720\u308c\u308b\u4e38\u3044\u571f\u7ba1\u3002\u51fa\u308b\u6642\u306f\u30e9\u30f3\u30c0\u30e0\u306a\u571f\u7ba1\u304b\u3089\u51fa\u308b\u3002\u5bdd\u5fc3\u5730\u306f\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u6bb5\u4e0b\u3002" }, dry_ice: { id: "dry_ice", itemType: "dry_ice", label: "\u30C9\u30E9\u30A4\u30A2\u30A4\u30B9", placeable: true, scalable: false, radius: 21, amount: 999, iconText: "\uD83E\uDDCA", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0B\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, stove: { id: "stove", itemType: "stove", label: "\u30B9\u30C8\u30FC\u30D6", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\uD83D\uDD25", collisionShape: "circle", tooltip: "\u5468\u56F2\u306E\u6C17\u6E29\u309210\u2103\u4E0A\u3052\u308B\u3002\u52B9\u679C\u306F\u91CD\u306D\u3089\u308C\u308B\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, + balloon: { id: "balloon", itemType: "balloon", label: "水風船", placeable: true, scalable: false, radius: 20, amount: 999, iconText: "💧", collisionShape: "circle", tooltip: "水の入った風船。軽くて風に流される。画鋲・火・げんこつで割れ、水が飛び散って近くのたりないが驚く。" }, + fan: { id: "fan", itemType: "fan", label: "扇風機", placeable: true, scalable: false, radius: 26, amount: 999, iconText: "🌀", collisionShape: "circle", tooltip: "前方に風を出す。水風船やボールを押し、少し涼しくする。Q/Eで置く向きを変える。配置後にクリックすると首振り設定を開く。" }, signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" }, duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 24, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3001\u85ac\u3001\u92f2\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u7121\u9650\u306b\u4f9b\u7d66\u3067\u304d\u308b\u3002" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, @@ -107,6 +110,7 @@ function itemHasTrait(type = "", trait = "") { function isPinType(type = "") { return itemHasTrait(type, "pin"); } function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); } +function isNestContainerType(type = "") { const id = String(type || ""); return id === "nest_box" || id === "pipe"; } function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); } function lodgedPinFor(tarinai = null, worldRef = null) { if (!tarinai?.stuckPushpinId) return null; @@ -180,6 +184,7 @@ function toolTipsFromDefinitions() { itemHasTrait, isPinType, isSleepFurnitureType, + isNestContainerType, lodgedPinFor, hasLodgedPinEffect, pinBehaviorFor, diff --git a/js/item_tool_metadata.js b/js/item_tool_metadata.js index 5cb8488..b7f08c1 100644 --- a/js/item_tool_metadata.js +++ b/js/item_tool_metadata.js @@ -3,20 +3,20 @@ // Static item/tool metadata shared by tool definitions and UI helpers. (function (global) { const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), + obstacle: new Set(["stone", "ball", "balloon", "fan", "nest_box", "bed", "duplicator", "dry_ice", "stove", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "zunchi", "water", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "slave_chain", "golden_crown", "sleep_drug"]), hazard: new Set(["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), - kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), - sleepFurniture: new Set(["bed", "nest_box", "grass_bed"]), - draggable: new Set(["ball", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), + kinematic: new Set(["ball", "balloon", "pushpin", "oshibyo", "zunchi"]), + sleepFurniture: new Set(["bed", "nest_box", "pipe", "grass_bed"]), + draggable: new Set(["ball", "balloon", "fan", "stone", "firecracker", "flame_firecracker", "bed", "nest_box", "pipe", "dry_ice", "stove", "signboard", "duplicator", "robot_cleaner", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]), }); const FENCE_ITEM_TYPES = Object.freeze(new Set(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence"])); const MECHANICAL_ITEM_TYPES = Object.freeze(new Set(["rotator", "poison_block", "reciprocator"])); const PHYSICS_TOOL_IDS = Object.freeze(["rope", "rod", "fence_h", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator"]); - const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES])); + const ROTATABLE_ITEM_TYPES = Object.freeze(new Set([...FENCE_ITEM_TYPES, ...MECHANICAL_ITEM_TYPES, "fan"])); const PIN_BEHAVIORS = Object.freeze({ pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false }, @@ -27,7 +27,7 @@ { id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "new", "poke", "pinch", "copy", "undo", "redo", "delete", "area_delete", "water_hose", "clear_tool"] }, { id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice", "duplicator"] }, { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown"] }, - { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "dry_ice", "stove", "ball", "signboard"] }, + { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "robot_cleaner", "bed", "nest_box", "pipe", "dry_ice", "stove", "ball", "balloon", "fan", "signboard"] }, { id: "physics", label: "\u7269\u7406", themeClass: "tool-category-physics", order: 45, toolIds: PHYSICS_TOOL_IDS }, { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "flame_firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 262a467..742f264 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -7,7 +7,7 @@ function initializeItemTypeState(item, type, x, y) { if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true; if (type === "water" || type === "zunda_juice") item.roles.drink = true; if (["sweet", "water", "zunda_juice", "slave_chain", "golden_crown"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; - if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true; + if (type === "bed" || type === "nest_box" || type === "pipe") item.roles.sleepPlace = true; if (["firecracker", "flame_firecracker", "fire", "genkotsu", "pushpin", "poison_block", "splat"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; if (type === "grass") { @@ -26,6 +26,10 @@ function initializeItemTypeState(item, type, x, y) { item.comfort = rand(0.86, 1.14); item.wear = 0; } + if (type === "pipe") { + item.comfort = rand(0.66, 0.82); + item.wear = 0; + } if (type === "signboard") { item.text = ""; } @@ -51,18 +55,36 @@ function initializeItemTypeState(item, type, x, y) { item.robotCleanerHighSpeed = false; item.robotCleanerSpeed = globalThis.TarinaiItemDynamicToolSystem?.robotCleanerMoveSpeed ?? 60; } - if (type === "ball") { - item.vx = rand(-5, 5); - item.vy = rand(-5, 5); + if (type === "ball" || type === "balloon") { + const light = type === "balloon"; + item.vx = rand(light ? -18 : -5, light ? 18 : 5); + item.vy = rand(light ? -14 : -5, light ? 14 : 5); item.prevX = x; item.prevY = y; item.spin = rand(0, Math.PI * 2); - item.spinVelocity = rand(-1.6, 1.6); + item.spinVelocity = rand(light ? -0.8 : -1.6, light ? 0.8 : 1.6); item.lastKickedAt = -999; item.lastKickerId = ""; item.lastPokedAt = -999; item.pokeCombo = 0; item.lastPokeAngle = rand(0, Math.PI * 2); + if (light) { + item.balloonWobble = rand(0, Math.PI * 2); + item.balloonPoppedAt = -999; + } + } + if (type === "fan") { + item.amount = 999; + item.angle = defaultItemAngle(type); + item.fanSpin = rand(0, Math.PI * 2); + item.fanPulse = rand(0, Math.PI * 2); + item.lastFanEffectAt = -999; + item.lastFanLogAt = -999; + item.fanSwingOn = false; + item.fanSwingCenter = item.angle; + item.fanSwingRange = 35 * Math.PI / 180; + item.fanSwingSpeed = 48 * Math.PI / 180; + item.fanSwingPhase = 0; } if (type === "zunchi") { item.amount = Math.max(Number(item.amount || 0) || 0, 240); diff --git a/js/item_update_policy.js b/js/item_update_policy.js index 9340c4c..58bc630 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -5,13 +5,13 @@ // helpers and the item scheduler both read this single policy instead of // duplicating item-type timing rules. (function (global) { - const REALTIME_ITEM_TYPES = new Set(["ball", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]); + const REALTIME_ITEM_TYPES = new Set(["ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "robot_cleaner"]); const PIN_ITEM_TYPES = new Set(["pushpin", "oshibyo"]); const SCHEDULED_ITEM_TYPES = Object.freeze([ - "ball", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo", + "ball", "balloon", "fan", "genkotsu", "firecracker", "flame_firecracker", "fire", "pushpin", "oshibyo", "plushie", "grass_bed", "zunchi", "robot_cleaner", "duplicator", "water", "trace", "splat", "ant_corpse", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", - "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", + "stone", "bed", "nest_box", "pipe", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", ]); function mobilePerfHint() { diff --git a/js/item_visual_definitions.js b/js/item_visual_definitions.js index 75175b9..a508f9d 100644 --- a/js/item_visual_definitions.js +++ b/js/item_visual_definitions.js @@ -111,6 +111,24 @@ "fieldScale": 1, "accessibleShapeTraits": ["round", "soccer ball"], "emojiFallback": "\u26bd" + }, + "balloon": { + "renderer": "vector_balloon", + "shape": "round_water_balloon_with_string", + "palette": { "fill": "#58bde8", "stroke": "rgba(38,83,123,0.76)", "highlight": "rgba(255,255,255,0.72)" }, + "scale": 1.05, + "uiPreviewScale": 1.06, + "fieldScale": 1.05, + "accessibleShapeTraits": ["round", "water balloon", "string", "blue"] + }, + "fan": { + "renderer": "vector_fan", + "shape": "directional_fan", + "palette": { "fill": "#d7e2ea", "stroke": "rgba(58,74,84,0.74)", "accent": "rgba(92,142,174,0.88)" }, + "scale": 1.08, + "uiPreviewScale": 1.08, + "fieldScale": 1.08, + "accessibleShapeTraits": ["fan", "directional", "wind"] } }`; diff --git a/js/items.js b/js/items.js index 59d5b85..ad4b7d8 100644 --- a/js/items.js +++ b/js/items.js @@ -455,6 +455,7 @@ function previewItemRadiusFor(type = "") { if (type === "rotator") return 25; if (type === "reciprocator") return 26; if (type === "nest_box") return 26; + if (type === "pipe") return 24; if (type === "duplicator") return 24; if (type === "signboard") return 22; if (type === "bed") return 22; @@ -580,6 +581,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { genkotsu: 0.52, bed: 0.80, nest_box: 0.78, + pipe: 0.84, signboard: 0.76, duplicator: 0.82, fence_v: 0.70, @@ -591,6 +593,8 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { reciprocator: 0.76, ant_nest: 0.92, ball: 0.92, + balloon: 0.86, + fan: 0.82, pushpin: 0.92, oshibyo: 0.62, firecracker: 0.90, @@ -603,7 +607,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { if (isConsumableSpriteType(type)) scale *= 1.95; else if (["grass", "water", "zunda_juice", "mercury", "fire"].includes(type)) scale *= 1.85; else if (type === "duplicator") scale *= 1.55; - else if (["signboard", "bed", "nest_box", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08; + else if (["signboard", "bed", "nest_box", "pipe", "ant_nest", "pushpin", "oshibyo", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "flame_firecracker", "fire", "genkotsu"].includes(type)) scale *= 1.08; else if (isParamEffectItemType(type)) scale *= 1.35; if (watermark) scale *= 1.42; ctx.scale(scale, scale); diff --git a/js/placement_preview_system.js b/js/placement_preview_system.js index 08050b7..ad75e3a 100644 --- a/js/placement_preview_system.js +++ b/js/placement_preview_system.js @@ -128,7 +128,7 @@ if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence") && worldRef.fenceRect) { return { rect: worldRef.fenceRect(item), rects: [] }; } - if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] }; + if ((item.type === "nest_box" || item.type === "pipe") && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] }; const rects = rectsFor(worldRef, item); return { rect: null, rects }; } diff --git a/js/render.js b/js/render.js index 1d023b5..b4301d5 100644 --- a/js/render.js +++ b/js/render.js @@ -277,7 +277,7 @@ function drawToolTargetHoverHighlight(ctx, world) { ctx.setLineDash([]); drawLabel(cx, top, 14); } else { - const r = Math.max(14, target.r || 16) * (target.type === "ball" ? 1.30 : 1.18); + const r = Math.max(14, target.r || 16) * ((target.type === "ball" || target.type === "balloon") ? 1.30 : 1.18); strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76); } } @@ -532,7 +532,7 @@ function drawPlacementPreview(ctx, world) { ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); } } - } else if (type === "stone" || type === "ball" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { + } else if (type === "stone" || type === "ball" || type === "balloon" || type === "fan" || type === "pipe" || type === "firecracker" || isPinType(type) || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") { ctx.beginPath(); ctx.arc(x, y, Math.max(8, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2); ctx.fill(); @@ -887,6 +887,7 @@ function renderRadiusForEntity(entity) { if (entity.type === "fence_v" || entity.type === "fence_h" || entity.type === "glass_wall" || entity.type === "bounce_fence" || entity.type === "bounce_fence_v" || entity.type === "gate_fence") return Math.max(96, (entity.r || 24) * 4.2); if (entity.type === "reciprocator") return Math.max(140, TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem.reach(entity) || ((entity.r || 64) * 2.8 + (TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem.scalar(entity, "railTravel", 150) || 150))); if (entity.type === "nest_box") return Math.max(86, (entity.r || 34) * 3.2); + if (entity.type === "pipe") return Math.max(78, (entity.r || 36) * 2.8); if (entity.type === "ant_nest") return Math.max(72, (entity.r || 28) * 3.0); if (entity.kind === "queen") return 42; if (entity.kind === "worker") return 26; diff --git a/js/robot_cleaner_system.js b/js/robot_cleaner_system.js index 4389451..203407a 100644 --- a/js/robot_cleaner_system.js +++ b/js/robot_cleaner_system.js @@ -222,7 +222,6 @@ const ROBOT_DEFAULT_MASK = 0b000011; // zunchi + corpses and stains if (!removed) { target.amount = 0; target.hp = 0; - target.dead = true; removed = true; } } diff --git a/js/save_codec.js b/js/save_codec.js index 428898a..1121602 100644 --- a/js/save_codec.js +++ b/js/save_codec.js @@ -25,6 +25,7 @@ const FIRE_TYPE_ID = ITEM_TYPE_IDS.indexOf("fire"); const FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("firecracker"); const FLAME_FIRECRACKER_TYPE_ID = ITEM_TYPE_IDS.indexOf("flame_firecracker"); + const BALLOON_TYPE_ID = ITEM_TYPE_IDS.indexOf("balloon"); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -627,7 +628,7 @@ writeBitFields(writer, [Math.max(0, Math.min(0x3f, Number(extra[0] ?? 3) | 0)), extra[1] ? 1 : 0], [6, 1]); } else if (JSON_EXTRA_ITEM_TYPE_IDS.has(typeId)) { writeSaveString(writer, JSON.stringify(extra || [])); - } else if (typeId === 21) { // ball + } else if (typeId === 21 || typeId === BALLOON_TYPE_ID) { // ball/balloon writer.s(extra[0] || 0); writer.s(extra[1] || 0); } else if (typeId === 23) { // ant_nest writer.s(extra[0] || 0); writer.s(extra[1] || 0); @@ -696,7 +697,7 @@ return []; } } - if (typeId === 21) return [reader.s(), reader.s()]; + if (typeId === 21 || typeId === BALLOON_TYPE_ID) return [reader.s(), reader.s()]; if (typeId === 23) return [reader.s(), reader.s()]; if (typeId === 24) return [reader.s()]; if (typeId === FIRECRACKER_TYPE_ID || typeId === FLAME_FIRECRACKER_TYPE_ID || typeId === FIRE_TYPE_ID) return [reader.s()]; diff --git a/js/save_schema.js b/js/save_schema.js index cf9d7bf..330eaa1 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 = 18; - const BINARY_SCHEMA_VERSION = 23; + const SNAPSHOT_VERSION = 19; + const BINARY_SCHEMA_VERSION = 24; const FIELD_IDS = Object.freeze(["garden", "cage", "park"]); const GROUND_IDS = Object.freeze(["soil", "dirt", "concrete", "blanket", "foot_massage", "ice", "laboratory"]); @@ -21,7 +21,7 @@ "grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h", - "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker" + "splat", "food", "bed", "genkotsu", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "reciprocator", "rope", "rod", "poison_block", "robot_cleaner", "dry_ice", "stove", "glass_wall", "slave_chain", "golden_crown", "fire", "flame_firecracker", "balloon", "fan", "pipe" ]); function typeIds(names) { @@ -32,7 +32,7 @@ const SERVING_FOOD_TYPE_IDS = typeIds(["sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "food"]); const PIN_TYPE_IDS = typeIds(["pushpin", "oshibyo"]); const JSON_EXTRA_ITEM_TYPE_IDS = typeIds(["rotator", "poison_block", "reciprocator", "rope", "rod"]); - const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator"]); + const ROTATABLE_ITEM_TYPE_IDS = typeIds(["fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "fan"]); function enumIndex(list, value, fallback = 0) { const i = list.indexOf(String(value ?? "")); diff --git a/js/sim_core.js b/js/sim_core.js index c510b4b..4a7315d 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -745,7 +745,7 @@ class SpatialGrid { isDynamicItem(it) { if (!it || it.dead) return false; const type = it.type || ""; - if (type === "ball" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; + if (type === "ball" || type === "balloon" || type === "fan" || type === "genkotsu" || type === "firecracker" || type === "pushpin" || type === "oshibyo") return true; if (type === "rope" || type === "rod") return true; const globalRef = (typeof window !== "undefined" ? window : globalThis); const physics = globalRef.TarinaiPhysicsBodySystem; diff --git a/js/snapshot_system.js b/js/snapshot_system.js index b95f93a..20d971f 100644 --- a/js/snapshot_system.js +++ b/js/snapshot_system.js @@ -291,12 +291,20 @@ } if (global.TarinaiPhysicsBodySystem.isPhysicsType(type)) return { pf: 2, missing: true }; if (type === "duplicator") return [enumIndex(ITEM_TYPE_IDS, item.storedFoodType || "", -1)]; - if (type === "ball") return [q(item.vx, 10), q(item.vy, 10)]; + if (type === "ball" || type === "balloon") return [q(item.vx, 10), q(item.vy, 10)]; if (type === "ant_nest") return [q(item.antCount, 1), q(item.queenSpawnAt, 10)]; if (type === "ant_corpse") return [q(item.decayTimer, 10)]; if (type === "firecracker" || type === "flame_firecracker") return [q(item.fuseTimer, 10)]; if (type === "fire") return [q(item.flameLife, 10)]; if (typeof global.isPinType === "function" && global.isPinType(type)) return [enumIndex(PIN_STATE_IDS, item.pinState || "loose"), tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)]; + if (type === "fan") return [ + q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), + item.fanSwingOn ? 1 : 0, + q(item.fanSwingCenter ?? item.angle ?? 0, 1000), + q(item.fanSwingRange ?? (35 * Math.PI / 180), 1000), + q(item.fanSwingSpeed ?? (48 * Math.PI / 180), 1000), + q(item.fanSwingPhase || 0, 1000), + ]; if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) return [q(item.angle, 1000, q(typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0, 1000)), type === "gate_fence" && item.gateOpen ? 1 : 0]; if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) return [q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)]; return []; @@ -352,7 +360,7 @@ return; } else if (type === "duplicator") { item.storedFoodType = enumValue(ITEM_TYPE_IDS, extra[0], ""); item.storedFoodLabel = global.toolLabel ? global.toolLabel(item.storedFoodType) : item.storedFoodType; if (item.roles) item.roles.food = Boolean(item.storedFoodType); - } else if (type === "ball") { + } else if (type === "ball" || type === "balloon") { item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); } else if (type === "ant_nest") { item.antCount = u(extra[0], 1); item.antWorkers = []; item.queenSpawnAt = u(extra[1], 10); @@ -364,6 +372,14 @@ item.flameLife = u(extra[0], 10, item.flameLife || 6); } else if (typeof global.isPinType === "function" && global.isPinType(type)) { item.pinState = enumValue(PIN_STATE_IDS, extra[0], "loose"); item.pinTargetId = tarinaiList[extra[1]]?.id || ""; item.pinAttachAngle = u(extra[2], 1000); item.pinAttachDistance = u(extra[3], 10); item.pinOffsetY = u(extra[4], 10); + } else if (type === "fan") { + const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; + item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback); + item.fanSwingOn = Boolean(extra[1]); + item.fanSwingCenter = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[2], 1000, item.angle), item.angle) : u(extra[2], 1000, item.angle); + item.fanSwingRange = Math.max(0, Math.min(150 * Math.PI / 180, u(extra[3], 1000, 35 * Math.PI / 180))); + item.fanSwingSpeed = Math.max(0, Math.min(360 * Math.PI / 180, u(extra[4], 1000, 48 * Math.PI / 180))); + item.fanSwingPhase = u(extra[5], 1000, item.fanSwingPhase || 0); } else if (typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type)) { const fallback = typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0; item.angle = typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(u(extra[0], 1000, fallback), fallback) : u(extra[0], 1000, fallback); diff --git a/js/structure_lifecycle.js b/js/structure_lifecycle.js index 1d8a5f5..9727d89 100644 --- a/js/structure_lifecycle.js +++ b/js/structure_lifecycle.js @@ -112,7 +112,7 @@ if (!t || t.dead) continue; const targetMatch = t.target === structure; const sleepTargetMatch = t.sleepSession?.targetId && t.sleepSession.targetId === structure.id; - const nestMatch = structure.type === "nest_box" && t.insideNestBoxId === structure.id; + const nestMatch = (structure.type === "nest_box" || structure.type === "pipe") && t.insideNestBoxId === structure.id; if (!targetMatch && !sleepTargetMatch && !nestMatch) continue; if (nestMatch) { diff --git a/js/tarinai_action_definitions.js b/js/tarinai_action_definitions.js index 92db262..d6b8810 100644 --- a/js/tarinai_action_definitions.js +++ b/js/tarinai_action_definitions.js @@ -71,7 +71,8 @@ function createSleepInBedActionSpec() { if (dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); const isEasyBed = bed.type === "grass_bed"; bed.use?.(t, world, isEasyBed ? { purpose: "sleep" } : undefined); - t.startSleeping?.(bed, bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b")); + const sleepText = bed.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u5bdd\u3066\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b")); + t.startSleeping?.(bed, sleepText); return true; }, tick(t) { @@ -527,7 +528,7 @@ const TARINAI_ACTION_DEFINITIONS = [ label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 24, - selectTarget(t, world) { return world.nearest?.(t, ["ball"], 520) || null; }, + selectTarget(t, world) { return world.nearest?.(t, ["ball", "balloon"], 520) || null; }, canStart(t, world, ctx = {}) { return !!(ctx.target ?? this.selectTarget(t, world, ctx)); }, start: startMoveToTarget("play_ball", "\u904a\u3093\u3067\u3044\u308b"), }, diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index fcb37ad..9617195 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -115,7 +115,8 @@ target.singleUsePending = true; target.singleUseConsumedById = this.id || target.singleUseConsumedById || ""; } - if (target && target.type !== "nest_box") { + const targetIsNestContainer = Boolean(target && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe"))); + if (target && !targetIsNestContainer) { this._sleepExternalMotionUntil = 0; this._sleepExternalWakeSuppressUntil = Math.max(Number(this._sleepExternalWakeSuppressUntil || 0) || 0, now + 2.4); this.vx = 0; @@ -123,7 +124,7 @@ this.impulseVx = 0; this.impulseVy = 0; } - const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"); + const text = reason || (target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b")); setBehaviorText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" }); return this.setActionState("sleep", { target, reason: text, sleeping: true }); }, diff --git a/js/tarinai_contact_item_system.js b/js/tarinai_contact_item_system.js index 386f8c6..e8893f4 100644 --- a/js/tarinai_contact_item_system.js +++ b/js/tarinai_contact_item_system.js @@ -60,7 +60,8 @@ const d = contact.distance; const occ = tarinai.world.bedOccupancy(it); const comfort = tarinai.world.bedComfort(it); it.use?.(tarinai, tarinai.world, isEasyBed ? { purpose: "sleep" } : undefined); - tarinai.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); + const sleepText = it.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); + tarinai.startSleeping?.(it, sleepText); if (tarinai.world.time > tarinai.nextSleepBubbleAt) { tarinai.nextSleepBubbleAt = tarinai.world.time + 4.5; tarinai.world.spawnBubble(tarinai.x, tarinai.y - tarinai.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); @@ -71,7 +72,7 @@ const d = contact.distance; } } - if (it.type === "ball") { + if (it.type === "ball" || it.type === "balloon") { const touch = clamp(1 - d / (tarinai.radius + it.r + 12), 0, 1); if (tarinai.state === "play_ball" || tarinai.target === it || tarinai.shouldApplyPersonalityBehavior("openness", 1)) { tarinai.goodMode = "smile"; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 2c1277a..6324dd5 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -4,6 +4,13 @@ (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_disease_nest.js"); + function isNestContainerItem(item) { + return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe"))); + } + function containerLabel(item) { return item?.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; } + function containerInsideSleepText(item, verb = "\u7720\u3063\u3066\u3044\u308b") { return `${containerLabel(item)}\u306e\u4e2d\u3067${verb}`; } + function containerFullText(item) { return `${containerLabel(item)}\u304c\u3044\u3063\u3071\u3044\u3067\u5165\u308c\u306a\u3044`; } + Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ infectZunchiDisease(source = null, force = false) { if (this.dead || this.zunchiDisease) return false; @@ -193,7 +200,7 @@ nestBoxById(id) { if (!id) return null; - return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; + return this.world?.items?.find?.(it => it && !it.dead && it.id === id && isNestContainerItem(it)) || null; }, nestBoxEntryActive() { @@ -203,7 +210,7 @@ return now < (this._nestBoxEntrySmoothUntil || -Infinity) || ((this.nestFade || 0) > 0.01 && (this.nestFade || 0) < 0.985); }, - abortNestBoxEntry(box = null, reason = "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") { + abortNestBoxEntry(box = null, reason = containerFullText(box)) { const now = this.world?.time || 0; if (this.insideNestBoxId && (!box || this.insideNestBoxId === box.id)) this.leaveNestBox?.(box); if (!this.insideNestBoxId) this.nestFade = 0; @@ -233,14 +240,15 @@ this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 0.7); this._nestBoxSeekStartedAt = NaN; this._nestBoxSeekTargetId = ""; - this.goIdle?.(reason); - this.thought = reason; + const finalReason = reason || containerFullText(box); + this.goIdle?.(finalReason); + this.thought = finalReason; this.pinchThoughtTimer = Math.max(this.pinchThoughtTimer || 0, 1.0); return false; }, wantsNestBox(box = null) { - if (!box || box.dead || box.type !== "nest_box") return false; + if (!isNestContainerItem(box)) return false; const now = this.world?.time || 0; if (this._nestBoxAvoidId === box.id && now < (this._nestBoxAvoidUntil || -Infinity)) return false; if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true; @@ -249,7 +257,7 @@ }, enterNestBox(box, dt = 0.016, options = {}) { - if (!box || box.dead || box.type !== "nest_box" || this.dead) return false; + if (!isNestContainerItem(box) || this.dead) return false; const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; let occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; const alreadyInside = this.insideNestBoxId === box.id; @@ -262,13 +270,13 @@ victim.leaveNestBox?.(box); victim.awakeLockTimer = Math.max(victim.awakeLockTimer || 0, 0.8); victim.fearTimer = Math.max(victim.fearTimer || 0, 0.22); - victim.thought = "\u5DE3\u7BB1\u304B\u3089\u62BC\u3057\u51FA\u3055\u308C\u305F"; + victim.thought = `${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`; victim.pinchThoughtTimer = Math.max(victim.pinchThoughtTimer || 0, 0.65); - victim.goIdle?.("\u5DE3\u7BB1\u304B\u3089\u62BC\u3057\u51FA\u3055\u308C\u305F"); + victim.goIdle?.(`${containerLabel(box)}\u304b\u3089\u62bc\u3057\u51fa\u3055\u308c\u305f`); occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : occupants.filter(t => t !== victim); } } - if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") || false; + if (occupants.length >= capacity) return this.abortNestBoxEntry?.(box, containerFullText(box)) || false; } if (!alreadyInside) { this._nestBoxSeekStartedAt = NaN; @@ -286,7 +294,7 @@ } this.insideNestBoxId = box.id; this.target = box; - if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b"); + if (this.state === "seek_bed") this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b")); if (this.state === "sleep") this.sleeping = true; const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; const smoothDt = clamp(Number(dt || 0.016) || 0.016, 0.008, 0.045); @@ -330,17 +338,18 @@ this._nestBoxExitDuration = 0; return false; } - const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(b, this) : { x: b.x, y: b.y }; - const door = this.world.nestBoxEntryPoint(b); - const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : door; + const exitContainer = b.type === "pipe" && this.world?.randomPipeExitFor ? (this.world.randomPipeExitFor(b, this) || b) : b; + const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(exitContainer, this) : { x: exitContainer.x, y: exitContainer.y }; + const door = this.world.nestBoxEntryPoint(exitContainer); + const exit = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(exitContainer, this) : door; this._nestBoxExitStartX = Number.isFinite(this.x) ? this.x : inner.x; this._nestBoxExitStartY = Number.isFinite(this.y) ? this.y : inner.y; - if ((this.nestFade || 0) > 0.82 || distXY(this._nestBoxExitStartX, this._nestBoxExitStartY, inner.x, inner.y) > (b.r || 42) * 0.7) { + if ((this.nestFade || 0) > 0.82 || distXY(this._nestBoxExitStartX, this._nestBoxExitStartY, inner.x, inner.y) > (exitContainer.r || b.r || 42) * 0.7) { this._nestBoxExitStartX = inner.x; this._nestBoxExitStartY = inner.y; } - this._nestBoxExitDoorX = Number.isFinite(door.x) ? door.x : b.x; - this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : b.y; + this._nestBoxExitDoorX = Number.isFinite(door.x) ? door.x : exitContainer.x; + this._nestBoxExitDoorY = Number.isFinite(door.y) ? door.y : exitContainer.y; this._nestBoxExitEndX = Number.isFinite(exit.x) ? exit.x : this._nestBoxExitDoorX; this._nestBoxExitEndY = Number.isFinite(exit.y) ? exit.y : this._nestBoxExitDoorY; this._nestBoxExitDuration = 0.58; @@ -404,7 +413,7 @@ } this.sleeping = false; this.sleepSession = null; - if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.("\u5DE3\u7BB1\u304B\u3089\u51FA\u305F"); + if (this.state === "sleep" || this.state === "seek_bed") this.goIdle?.(`${containerLabel(b)}\u304b\u3089\u51fa\u305f`); this.nestBoxStayUntil = -Infinity; this.nestBoxSlotIndex = -1; this._nestBoxEntrySmoothUntil = -Infinity; @@ -421,7 +430,7 @@ updateNestBoxPresence(dt = 0) { if (this.updateNestBoxExitAnimation?.(dt || 0.016)) return null; const currentBox = this.nestBoxById(this.insideNestBoxId); - const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; + const targetBox = isNestContainerItem(this.target) ? this.target : null; const box = currentBox || targetBox; if (!box) { this.insideNestBoxId = null; @@ -436,7 +445,7 @@ if (currentBox) { if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) { - this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b"); + this.startSleeping(box, containerInsideSleepText(box, "\u7720\u3063\u3066\u3044\u308b")); this.sleeping = true; this.target = currentBox; } @@ -462,7 +471,7 @@ this._nestBoxSeekTargetId = targetBox.id; } if (!this.insideNestBoxId && global.TarinaiNestSleepSystem.sleepPlaceAvailableFor && !global.TarinaiNestSleepSystem.sleepPlaceAvailableFor(this.world, this, targetBox)) { - return this.abortNestBoxEntry?.(targetBox, "\u5DE3\u7BB1\u304C\u3044\u3063\u3071\u3044\u3067\u5165\u308C\u306A\u3044") || null; + return this.abortNestBoxEntry?.(targetBox, containerFullText(targetBox || box)) || null; } const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index f866447..bf01cec 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -382,7 +382,8 @@ }, targetIgnoresFence(target = this.target) { - return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); + const targetIsContainer = Boolean(target && (typeof isNestContainerType === "function" ? isNestContainerType(target.type) : (target.type === "nest_box" || target.type === "pipe"))); + return Boolean(target?.type === "sweet" || targetIsContainer || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); }, shouldAvoidTarget(target) { diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index 8e27e30..debb8f3 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -35,7 +35,7 @@ function roleMatches(item, role) { if (item.type === "duplicator") return effectiveFoodRoleMatches(item, role); if (item.roles?.[role]) return true; if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role); - if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed"; + if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed"; if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(item.type); if (role === "grassMaterial") return item.type === "grass"; return false; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 0c0f944..c0b0bf4 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1067,11 +1067,11 @@ function resolveNeedsCore(dt) { }, isSleepFurniture(target) { - return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box")))); + return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box" || target.type === "pipe")))); }, sleepSpotFor(bed) { if (!bed) return { x: this.x, y: this.y }; - if (bed.type === "nest_box") { + if (bed.type === "nest_box" || bed.type === "pipe") { if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this); if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; diff --git a/js/tarinai_nest_sleep_system.js b/js/tarinai_nest_sleep_system.js index c8b5623..1f32293 100644 --- a/js/tarinai_nest_sleep_system.js +++ b/js/tarinai_nest_sleep_system.js @@ -1,27 +1,34 @@ "use strict"; // Layer: tarinai/nest-sleep -// Centralizes sleep furniture, nest occupancy/reservation, and sleep-target -// selection policy so nest-box entrance logic and bed targeting do not drift. +// Centralizes sleep furniture, nest/pipe occupancy/reservation, and sleep-target +// selection policy so entrance logic and bed targeting do not drift. (function (global) { const distFn = typeof dist === "function" ? dist : ((a, b) => Math.hypot((a?.x || 0) - (b?.x || 0), (a?.y || 0) - (b?.y || 0))); const clampFn = typeof clamp === "function" ? clamp : ((v, a, b) => Math.max(a, Math.min(b, v))); + function isNestContainer(item) { + return Boolean(item && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe"))); + } + function isSleepFurniture(item) { - return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "grass_bed")))); + return Boolean(item && (item.roles?.sleepPlace || (typeof global.isSleepFurnitureType === "function" ? global.isSleepFurnitureType(item.type) : (item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed")))); } function sleepTargetBlocked(worldRef, tarinai, item) { if (!worldRef || !tarinai || !item) return false; + // Pipe mouths are floor-level entry points. Treat them as reachable even if + // their circular sprite overlaps the tarinai body. + if (item.type === "pipe") return false; return Boolean(worldRef.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65))); } function nestBoxCapacity(worldRef, box = null) { - return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : 5; + return typeof worldRef?.nestBoxCapacity === "function" ? worldRef.nestBoxCapacity(box) : (box?.type === "pipe" ? 3 : 5); } function nestBoxOccupants(worldRef, box, limit = 5) { - if (!worldRef || !box || box.dead || box.type !== "nest_box") return []; + if (!worldRef || !box || box.dead || !isNestContainer(box)) return []; const occupants = (worldRef.tarinai || []).filter(t => t && !t.dead && t.insideNestBoxId === box.id); return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants; } @@ -44,7 +51,7 @@ } function nestBoxReservationCount(worldRef, box) { - if (!worldRef || !box || box.dead || box.type !== "nest_box") return 0; + if (!worldRef || !box || box.dead || !isNestContainer(box)) return 0; const now = Number(worldRef.time || 0) || 0; let n = 0; for (const t of worldRef.tarinai || []) if (tarinaiIsReservingNest(t, box, now)) n += 1; @@ -53,7 +60,7 @@ function bedOccupancy(worldRef, bed) { if (!worldRef || !isSleepFurniture(bed)) return 0; - if (bed.type === "nest_box") { + if (isNestContainer(bed)) { return nestBoxOccupants(worldRef, bed, Infinity).length + nestBoxReservationCount(worldRef, bed); } let n = 0; @@ -69,7 +76,8 @@ function bedComfort(worldRef, bed) { if (!isSleepFurniture(bed)) return 0.7; const occ = bedOccupancy(worldRef, bed); - return clampFn((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18); + const base = bed.type === "pipe" ? Math.min(Number(bed.comfort ?? 0.74) || 0.74, 0.82) : (bed.comfort ?? 1); + return clampFn(base - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, bed.type === "pipe" ? 0.46 : 0.42, bed.type === "pipe" ? 0.88 : 1.18); } function sleepPlaceAvailableFor(worldRef, tarinai, item) { @@ -79,7 +87,7 @@ if (tarinai.shouldAvoidTarget?.(item)) return false; if (worldRef?.sleepTargetTemperatureBlocked?.(tarinai, item)) return false; if (sleepTargetBlocked(worldRef, tarinai, item)) return false; - const capacity = item.type === "nest_box" ? nestBoxCapacity(worldRef, item) : 1; + const capacity = isNestContainer(item) ? nestBoxCapacity(worldRef, item) : 1; const occupancy = bedOccupancy(worldRef, item); return occupancy < capacity || tarinai.target === item || (tarinai.insideNestBoxId && tarinai.insideNestBoxId === item.id); } @@ -135,6 +143,7 @@ return findOwnedStructure(worldRef, tarinai, "grass_bed", maxDist) || nearestSleepPlaceOfType(worldRef, tarinai, "nest_box", maxDist) || nearestSleepPlaceOfType(worldRef, tarinai, "bed", maxDist) + || nearestSleepPlaceOfType(worldRef, tarinai, "pipe", maxDist) || nearestSleepPlaceOfType(worldRef, tarinai, "grass_bed", maxDist) || null; } @@ -145,6 +154,7 @@ } global.TarinaiNestSleepSystem = Object.freeze({ + isNestContainer, isSleepFurniture, isTarinaiHiddenInNestBox, nestBoxOccupants, diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index b400973..8a6df91 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -22,6 +22,14 @@ const TARINAI_KING_FLEE_REASON = "\u305F\u308A\u306A\u3044\u738B\u304C\u6016\u304F\u3066\u9003\u3052\u3066\u3044\u308B\u3002"; + function isNestContainerItem(item) { + return Boolean(item && !item.dead && (typeof global.isNestContainerType === "function" ? global.isNestContainerType(item.type) : (item.type === "nest_box" || item.type === "pipe"))); + } + + function sleepContainerText(item, verb = "\u4f11\u3093\u3067\u3044\u308b") { + return item?.type === "pipe" ? `\u571f\u7ba1\u306e\u4e2d\u3067${verb}` : `\u5de3\u7bb1\u306e\u4e2d\u3067${verb}`; + } + function tarinaiIsChampionBreedingPartner(champion, candidate) { if (!champion || !candidate || champion === candidate) return false; if (champion.birthPartnerId && champion.birthPartnerId === candidate.id) return true; @@ -316,7 +324,7 @@ if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); const sleepHungerScale = this.metabolicHungerMultiplier ? this.metabolicHungerMultiplier() : (typeof tarinaiMetabolicHungerScale === "function" ? tarinaiMetabolicHungerScale(this) : 1); this.hunger = clamp(this.hunger + dt * 0.018 * sleepHungerScale, 0, 115); - if (this.target && !this.target.dead && this.target.type === "nest_box") { + if (this.target && !this.target.dead && isNestContainerItem(this.target)) { this.updateNestBoxPresence(dt); this.vx = 0; this.vy = 0; @@ -504,19 +512,20 @@ const bedDx = this.target.x - this.x; const bedDy = this.target.y - this.y; const bedDist = Math.hypot(bedDx, bedDy); - const reachedSleepFurniture = this.target.type === "nest_box" + const reachedSleepFurniture = isNestContainerItem(this.target) ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); if (reachedSleepFurniture) { const sleepTarget = this.target; const isEasyBed = sleepTarget?.type === "grass_bed"; sleepTarget?.use?.(this, this.world, isEasyBed ? { purpose: "sleep" } : undefined); - this.startSleeping?.(sleepTarget, sleepTarget?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b")); - if (sleepTarget?.type === "nest_box") this.enterNestBox(sleepTarget, dt); + const sleepText = isNestContainerItem(sleepTarget) ? sleepContainerText(sleepTarget, "\u4f11\u3093\u3067\u3044\u308b") : (isEasyBed ? "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3067\u5bdd\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u3067\u4f11\u3093\u3067\u3044\u308b"); + this.startSleeping?.(sleepTarget, sleepText); + if (isNestContainerItem(sleepTarget)) this.enterNestBox(sleepTarget, dt); return; } } - if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && this.target?.type === "nest_box") { + if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) { const nestDist = distXY(this.x, this.y, this.target.x, this.target.y); if (nestDist <= Math.max(50, (this.radius || 20) + (this.target.r || 24) + 18)) { this.enterNestBox?.(this.target, dt, { forceDisplace: true, reason: TARINAI_KING_FLEE_REASON }); @@ -524,7 +533,7 @@ } } let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1; - if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && this.target?.type === "nest_box") sign = 1; + if (this.state === "panic" && this.lastPanicCause === "tarinai_champion" && isNestContainerItem(this.target)) sign = 1; if (this.target?.detour) sign = 1; let strength = 1; if (this.state === "follow_parent") { @@ -685,7 +694,6 @@ if (this.world.recentDeathTimes.length > 24) this.world.recentDeathTimes = this.world.recentDeathTimes.slice(-24); for (const item of this.world.items || []) { if (!item || item.dead || !item.isStructure || item.type !== "plushie" || item.ownerId !== this.id) continue; - item.dead = true; item.hp = 0; item.amount = 0; item.carriedById = ""; diff --git a/js/tarinai_update_policy.js b/js/tarinai_update_policy.js index 6b3b861..260504e 100644 --- a/js/tarinai_update_policy.js +++ b/js/tarinai_update_policy.js @@ -59,7 +59,7 @@ const lockedPassive = sleeping || state === "eat" || /eat|sunbath|birth_ritual|breed|repro/i.test(id) || /sunbath|birth_ritual|breed|repro/.test(state) || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.sunbathTimer || 0) > 0.04; if (sleeping && isVisible && !worldRef?.isTarinaiHiddenInNestBox?.(tarinai)) return true; if (lockedPassive && isVisible && activeMechanicalObstacleNear(tarinai, worldRef)) return true; - if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && tarinai.target.type !== "nest_box") return true; + if (sleeping && tarinai.target && !tarinai.target.dead && tarinai.isSleepFurniture?.(tarinai.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(tarinai.target.type) : (tarinai.target.type === "nest_box" || tarinai.target.type === "pipe"))) return true; return false; } diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index d501cc9..e7c560b 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -120,7 +120,7 @@ const state = String(t.state || ""); const sleepExternal = markSleepExternalMotion(t, dt, "passive-physical"); - if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && t.target.type !== "nest_box") { + if ((state === "sleep" || t.sleeping) && t.target && !t.target.dead && t.isSleepFurniture?.(t.target) && !(typeof isNestContainerType === "function" ? isNestContainerType(t.target.type) : (t.target.type === "nest_box" || t.target.type === "pipe"))) { const comfort = world?.bedComfort ? world.bedComfort(t.target) : 0.72; const spot = t.sleepSpotFor ? t.sleepSpotFor(t.target) : t.target; const dx = numeric(spot?.x, beforeX) - beforeX; diff --git a/js/text_catalog.js b/js/text_catalog.js index 621a2ee..382b3bb 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -153,7 +153,7 @@ TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { if (cleanedBehaviorLabel) return cleanedBehaviorLabel; const target = TEXT_CATALOG.targetLabel(t.target); if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b"; - if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b"; + if (t.state === "sleep") return t.target?.type === "pipe" ? "\u571f\u7ba1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : (t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b"); if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b"; if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b"; diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index d6e458b..7fdd528 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -252,7 +252,7 @@ if (uiCache.grabKind === "tarinai") { target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b"); if (options.touch) target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8); - } else if (target.type === "ball") { + } else if ((target.type === "ball" || target.type === "balloon")) { target.prevX = target.x - dx; target.prevY = target.y - dy; target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38); @@ -293,8 +293,8 @@ const frameDt = Math.max(1 / 90, Math.min(1 / 30, Number(world.dt || 1 / 60) || 1 / 60)); target.prevX = target.x - releaseVx * frameDt; target.prevY = target.y - releaseVy * frameDt; - if (target.type === "ball") target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42); - } else if (target.type === "ball") { + if ((target.type === "ball" || target.type === "balloon")) target.spinVelocity = clamp((target.spinVelocity || 0) + releaseVx * 0.018, -42, 42); + } else if ((target.type === "ball" || target.type === "balloon")) { target.prevX = target.x; target.prevY = target.y; } diff --git a/js/version.js b/js/version.js index 87d0752..8dff869 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.110"; - const APP_BUILD = "tarinai-fps-cadence-v39-15-110"; + const APP_VERSION = "39.15.116"; + const APP_BUILD = "tarinai-pipe-v39-15-116"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 387829b..1612380 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -154,8 +154,8 @@ push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), }); let blastedBalls = 0; - for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { - if (!ball || ball.dead || ball.type !== "ball") continue; + for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) { + if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue; let dx = ball.x - x; let dy = ball.y - y; let d = Math.hypot(dx, dy) || 1; @@ -243,8 +243,8 @@ this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale, }); - for (const ball of (this.itemsOfType ? this.itemsOfType("ball") : this.items)) { - if (!ball || ball.dead || ball.type !== "ball") continue; + for (const ball of (this.itemsOfType ? (this.itemsOfType("ball") || []).concat(this.itemsOfType("balloon") || []) : this.items)) { + if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) continue; let dx = ball.x - x; let dy = ball.y - y; let d = Math.hypot(dx, dy) || 1; @@ -633,10 +633,10 @@ return global.TarinaiImpactResponseSystem.itemDropImpact(this, it); }, limitBallChasers() { - if (!this.itemCounts?.ball) return; + if (!this.itemCounts?.ball && !this.itemCounts?.balloon) return; const groups = new Map(); for (const t of this.tarinai || []) { - if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue; + if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || (t.target.type !== "ball" && t.target.type !== "balloon")) continue; const arr = groups.get(t.target) || []; arr.push(t); groups.set(t.target, arr); @@ -649,14 +649,14 @@ return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { - t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b"); + t.goIdle((ball.type === "balloon" ? "水風船" : "ボール") + "が混み合っているので眺めている"); t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + deterministicRange(this, "ball-chaser-release-angle", -0.45, 0.45, t, ball); } } }, pokeBall(ball, x, y) { - if (!ball || ball.dead || ball.type !== "ball") return false; + if (!ball || ball.dead || (ball.type !== "ball" && ball.type !== "balloon")) return false; const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0); let dx = ball.x - x; let dy = ball.y - y; @@ -674,14 +674,15 @@ ball.lastPokedAt = now; ball.lastPokeAngle = Math.atan2(ny, nx); const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24); + const isBalloon = ball.type === "balloon"; + const impulse = (isBalloon ? 88 : 145) + ball.pokeCombo * (isBalloon ? 38 : 64) + Math.min(isBalloon ? 160 : 260, currentSpeed * (isBalloon ? 0.18 : 0.24)); ball.vx = (ball.vx || 0) + nx * impulse; ball.vy = (ball.vy || 0) + ny * impulse; - ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38); + ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * ((isBalloon ? 2.4 : 5.6) + ball.pokeCombo * (isBalloon ? 0.55 : 1.15)), -38, 38); ball.amount = Math.max(ball.amount || 999, 999); audio.ballHit?.(); - this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" })); - this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke" }); + this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: isBalloon ? "rgba(94,188,236,0.56)" : "rgba(255,255,255,0.58)" })); + this.log(isBalloon ? "水風船をつついて飛ばした。" : "ボールをつついて転がした。", "observe", { eventType: "ball_poke" }); return true; }, diff --git a/js/world_environment.js b/js/world_environment.js index 056cdd5..4041704 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -18,7 +18,7 @@ const type = String(it.type || ""); let reach = Math.max(32, Number(it.r || it.radius || 32) || 32); if (type === "fence" || type === "fence_v" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") reach = Math.max(120, reach * 3.9); - else if (type === "nest_box") reach = Math.max(92, reach * 1.9); + else if (type === "nest_box" || type === "pipe") reach = Math.max(92, reach * 1.9); else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6); return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); } @@ -232,13 +232,27 @@ }, - nestBoxCapacity() { - return 5; + nestBoxCapacity(box = null) { + return box?.type === "pipe" ? 3 : 5; + }, + + isNestContainerItem(it) { + return Boolean(it && !it.dead && (typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(it.type) : (it.type === "nest_box" || it.type === "pipe"))); }, nestBoxBaseRect(it) { - if (!it || it.dead || it.type !== "nest_box") return null; - const r = it.r || 42; + if (!this.isNestContainerItem?.(it)) return null; + const r = it.r || (it.type === "pipe" ? 36 : 42); + if (it.type === "pipe") { + return { + left: it.x - r * 1.12, + right: it.x + r * 1.12, + top: it.y - r * 0.88, + bottom: it.y + r * 0.82, + type: "pipe", + item: it, + }; + } return { left: it.x - r * 1.28, right: it.x + r * 1.28, @@ -252,6 +266,7 @@ nestBoxSolidRects(it) { const base = this.nestBoxBaseRect(it); if (!base) return []; + if (it?.type === "pipe") return []; const w = base.right - base.left; const h = base.bottom - base.top; const colW = w / 3; @@ -266,7 +281,13 @@ nestBoxEntryPoint(box) { const base = this.nestBoxBaseRect(box); if (!base) return { x: box?.x || 0, y: box?.y || 0 }; - const r = box.r || 42; + const r = box.r || (box.type === "pipe" ? 36 : 42); + if (box.type === "pipe") { + return { + x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(box.y - r * 0.02, CONFIG.worldPadding, this.h - CONFIG.worldPadding), + }; + } // Move the approach point to the center of the open middle cell. The // collision resolver also ignores this nest box for active sleepers near // the door, so tarinai can cross the threshold instead of sliding off posts. @@ -279,7 +300,14 @@ nestBoxExitPoint(box, occupant = null) { const base = this.nestBoxBaseRect(box); if (!base) return this.nestBoxEntryPoint(box); - const r = box.r || 42; + const r = box.r || (box.type === "pipe" ? 36 : 42); + if (box.type === "pipe") { + const side = occupant ? (stableUnit(occupant.id || "pipe", `pipe-exit-${box.id || "pipe"}`) - 0.5) * r * 0.46 : 0; + return { + x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(box.y + r * 0.74, CONFIG.worldPadding, this.h - CONFIG.worldPadding), + }; + } const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; return { x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), @@ -294,17 +322,34 @@ let index = Number.isInteger(occupant?.nestBoxSlotIndex) && occupant.nestBoxSlotIndex >= 0 ? occupant.nestBoxSlotIndex : occupants.indexOf(occupant); if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); index = clamp(index, 0, Math.max(0, this.nestBoxCapacity(box) - 1)); - const r = box.r || 42; - const slots = [ - [0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16], - ]; + const r = box.r || (box.type === "pipe" ? 36 : 42); + const pipeSlots = [[0.00, -0.02], [-0.15, 0.06], [0.15, 0.06]]; + const nestSlots = [[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16]]; + const slots = box.type === "pipe" ? pipeSlots : nestSlots; const slot = slots[index % slots.length]; + if (box.type === "pipe") { + return { + x: clamp(box.x + slot[0] * r, base.left + r * 0.40, base.right - r * 0.40), + y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.28), + }; + } return { x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), }; }, + randomPipeExitFor(sourcePipe = null, occupant = null) { + const allPipes = (this.items || []).filter(it => it && !it.dead && it.type === "pipe"); + if (!allPipes.length) return sourcePipe; + const pipes = allPipes.length > 1 && sourcePipe ? allPipes.filter(it => it.id !== sourcePipe.id) : allPipes; + if (!pipes.length) return sourcePipe || allPipes[0] || null; + const now = Number(this.time || 0) || 0; + const salt = `${occupant?.id || "tarinai"}:${sourcePipe?.id || "pipe"}:${Math.floor(now * 1.7)}`; + const idx = Math.floor(stableUnit(salt, "random-pipe-exit") * pipes.length) % pipes.length; + return pipes[idx] || sourcePipe || null; + }, + @@ -323,12 +368,12 @@ return rect ? [{ ...rect, type: it.type, item: it }] : []; } if (global.TarinaiMechanicalShapeSystem.isMechanicalType(it.type)) return global.TarinaiMechanicalShapeSystem.obstacleRects(it) || []; - if (it.type === "nest_box") return this.nestBoxSolidRects(it); + if (it.type === "nest_box" || it.type === "pipe") return this.nestBoxSolidRects(it); return []; }, shouldIgnoreNestBoxCollisionFor(t, box) { - if (!t || !box || box.dead || box.type !== "nest_box") return false; + if (!t || !box || box.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(box.type) : (box.type === "nest_box" || box.type === "pipe"))) return false; if (t.insideNestBoxId) return true; if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false; const entry = this.nestBoxEntryPoint(box); diff --git a/js/world_family_social.js b/js/world_family_social.js index 8e8b1e6..5faa1f3 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -451,7 +451,7 @@ if (!p?.inside) return null; let best = null, bestD = Infinity; for (const it of this.nearbyItems(p.x, p.y, 140)) { - if (!it || it.dead || it.type !== "nest_box") continue; + if (!it || it.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(it.type) : (it.type === "nest_box" || it.type === "pipe"))) continue; const d = distXY(p.x, p.y, it.x, it.y); if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; } } @@ -462,21 +462,24 @@ }, nestBoxTooltipLines(box) { - if (!box || box.dead || box.type !== "nest_box") return []; + if (!box || box.dead || !(typeof globalThis.isNestContainerType === "function" ? globalThis.isNestContainerType(box.type) : (box.type === "nest_box" || box.type === "pipe"))) return []; const occupants = this.nestBoxOccupants(box, Infinity); const names = occupants.map(t => t?.name).filter(Boolean).slice(0, 5); const more = occupants.length > names.length ? `\u3001\u307b\u304b${occupants.length - names.length}\u5339` : ""; - return ["\u5de3\u7bb1", names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "\u5185\u90E8\u6C17\u6E29")].filter(Boolean); + const label = box.type === "pipe" ? "\u571f\u7ba1" : "\u5de3\u7bb1"; + return [label, names.length ? `\u5165\u3063\u3066\u308b\u5b50: ${names.join("\u3001")}${more}` : "\u5165\u3063\u3066\u308b\u5b50: \u306a\u3057", this.temperatureTooltipLineFor?.(box, "\u5185\u90E8\u6C17\u6E29")].filter(Boolean); }, - temperatureTooltipLineFor(target, label = "\u4F53\u611F\u6C17\u6E29") { + temperatureTooltipLineFor(target, label = "体感気温") { if (!target || !this.temperatureAt) return ""; - const temp = this.temperatureAt(target.x, target.y); + const temp = target.type === "pipe" && this.pipeInteriorTemperatureFor + ? this.pipeInteriorTemperatureFor(target) + : this.temperatureAt(target.x, target.y); if (!Number.isFinite(Number(temp))) return ""; const status = this.temperatureStatusFor?.(temp) || null; - const suffix = status?.label ? `\uFF08${status.label}\uFF09` : ""; - return `${label}: ${Number(temp).toFixed(1)}\u2103${suffix}`; + const suffix = status?.label ? `(${status.label})` : ""; + return `${label}: ${Number(temp).toFixed(1)}℃${suffix}`; }, pointerTarinaiTooltipInfo() { @@ -521,7 +524,7 @@ const tarinaiInfo = this.pointerTarinaiTooltipInfo?.(); if (tarinaiInfo?.target) return tarinaiInfo; const nest = this.pointerNestBoxInfo?.(); - if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: "nest_box" }; + if (nest?.box) return { target: nest.box, lines: this.nestBoxTooltipLines(nest.box), kind: nest.box?.type === "pipe" ? "pipe" : "nest_box" }; const bedInfo = this.pointerOwnedBedInfo?.(); if (bedInfo?.bed) return { target: bedInfo.bed, lines: this.ownedBedTooltipLines(bedInfo.bed), kind: "grass_bed" }; return null; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 4042b06..13ddc58 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -218,6 +218,100 @@ return true; } + function ensureFanEditor() { + return ensureEditorDialog("fanEditor", "rotator-editor fan-editor hidden", ` + `); + } + + function degForAngle(angle = 0) { + const rad = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angle, 0) : (((Number(angle) || 0) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + return Math.round((rad * 180 / Math.PI) % 360 + 360) % 360; + } + + function syncEditorInputPair(rangeInput, numberInput, onChange = null) { + const sync = (source, target) => { + if (!source || !target) return; + target.value = source.value; + if (typeof onChange === "function") onChange(); + }; + if (rangeInput) rangeInput.oninput = () => sync(rangeInput, numberInput); + if (numberInput) numberInput.oninput = () => sync(numberInput, rangeInput); + } + + function openFanEditor(item, worldRef) { + if (!item || item.type !== "fan") return false; + const dialog = ensureFanEditor(); + const center = dialog.querySelector("#fanCenterAngle"); + const centerNum = dialog.querySelector("#fanCenterAngleNum"); + const swingRange = dialog.querySelector("#fanSwingRange"); + const swingRangeNum = dialog.querySelector("#fanSwingRangeNum"); + const swingSpeed = dialog.querySelector("#fanSwingSpeed"); + const swingSpeedNum = dialog.querySelector("#fanSwingSpeedNum"); + const enabled = dialog.querySelector("#fanSwingEnabled"); + const stop = dialog.querySelector("#fanEditorStop"); + const apply = dialog.querySelector("#fanEditorApply"); + const cancel = dialog.querySelector("#fanEditorCancel"); + const closeBtn = dialog.querySelector("#fanEditorClose"); + const currentAngle = item.fanSwingOn ? (Number(item.fanSwingCenter) || itemAngleFor(item)) : itemAngleFor(item); + const centerDeg = degForAngle(currentAngle); + const rangeDeg = Math.round(Math.max(0, Math.min(150, Number(item.fanSwingRange ?? (35 * Math.PI / 180)) * 180 / Math.PI || 35))); + const speedDeg = Math.round(Math.max(0, Math.min(360, Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) * 180 / Math.PI || 48))); + if (center) center.value = String(Math.max(0, Math.min(355, centerDeg))); + if (centerNum) centerNum.value = String(centerDeg); + if (swingRange) swingRange.value = String(Math.max(0, Math.min(120, rangeDeg))); + if (swingRangeNum) swingRangeNum.value = String(rangeDeg); + if (swingSpeed) swingSpeed.value = String(Math.max(0, Math.min(180, speedDeg))); + if (swingSpeedNum) swingSpeedNum.value = String(speedDeg); + if (enabled) enabled.checked = Boolean(item.fanSwingOn); + syncEditorInputPair(center, centerNum); + syncEditorInputPair(swingRange, swingRangeNum); + syncEditorInputPair(swingSpeed, swingSpeedNum); + const close = () => dialog.classList.add("hidden"); + const commit = (forceStop = false) => { + global.TarinaiHistory.capture(worldRef, "fan-swing-edit"); + const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0)); + const rDeg = Math.max(0, Math.min(150, Number(swingRangeNum?.value || swingRange?.value || 0) || 0)); + const sDeg = Math.max(0, Math.min(360, Number(swingSpeedNum?.value || swingSpeed?.value || 0) || 0)); + item.fanSwingCenter = (typeof normalizedItemAngle === "function" ? normalizedItemAngle(cDeg * Math.PI / 180, 0) : cDeg * Math.PI / 180); + item.fanSwingRange = rDeg * Math.PI / 180; + item.fanSwingSpeed = sDeg * Math.PI / 180; + item.fanSwingOn = forceStop ? false : Boolean(enabled?.checked && rDeg > 0 && sDeg > 0); + item.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0; + item.angle = item.fanSwingCenter; + worldRef?.markSpatialDirty?.("fan-swing-settings"); + if (worldRef) worldRef.drawListDirty = true; + worldRef?.log?.(item.fanSwingOn ? "扇風機の首振り設定を変更した。" : "扇風機を固定向きにした。", "observe"); + if (typeof showToast === "function") showToast(item.fanSwingOn ? "扇風機: 首振りON" : "扇風機: 固定向き"); + global.render?.(); + close(); + }; + if (stop) stop.onclick = () => commit(true); + if (apply) apply.onclick = () => commit(false); + if (cancel) cancel.onclick = close; + if (closeBtn) closeBtn.onclick = close; + dialog.onclick = (e) => { if (e.target === dialog) close(); }; + dialog.classList.remove("hidden"); + return true; + } + function ensureRotatorEditor() { const dialog = ensureEditorDialog("rotatorEditor", "rotator-editor hidden", `