diff --git a/app_manifest.json b/app_manifest.json index caa7202..d6bf065 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "39.15.18", + "version": "39.15.27", "css": [ "css/base.css", "css/layout.css", diff --git a/assets/sounds/shoot_bolt_action.mp3 b/assets/sounds/shoot_bolt_action.mp3 new file mode 100644 index 0000000..d611e74 Binary files /dev/null and b/assets/sounds/shoot_bolt_action.mp3 differ diff --git a/assets/sounds/shoot_pistol.mp3 b/assets/sounds/shoot_pistol.mp3 new file mode 100644 index 0000000..77db291 Binary files /dev/null and b/assets/sounds/shoot_pistol.mp3 differ diff --git a/assets/ui/tool_shoot.png b/assets/ui/tool_shoot.png new file mode 100644 index 0000000..50b5f7f Binary files /dev/null and b/assets/ui/tool_shoot.png differ diff --git a/css/base.css b/css/base.css index 5a72a27..66a0007 100644 --- a/css/base.css +++ b/css/base.css @@ -441,6 +441,7 @@ h1 { .tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.webp"); } .tool[data-tool="genkotsu"] { --tool-icon: url("../assets/ui/tool_genkotsu.webp"); } .tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.webp"); } +.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; } .tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.webp"); } .tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.webp"); } .tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.webp"); } diff --git a/css/components.css b/css/components.css index d5fdc33..554b5c2 100644 --- a/css/components.css +++ b/css/components.css @@ -745,8 +745,8 @@ z-index: 11; overflow: hidden; background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(221, 237, 255, 0.98)); - border-color: rgba(82,128,190,0.36); - box-shadow: 0 8px 18px rgba(58, 82, 112, 0.13), inset 0 1px 0 rgba(255,255,255,0.82); + border: 3px solid rgba(52,112,188,0.72); + box-shadow: 0 9px 22px rgba(58, 82, 112, 0.18), inset 0 0 0 1px rgba(255,255,255,0.86); } .tool-category[data-tool-category="operate"] .tool-category-toggle { background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(231, 243, 255, 0.98)); diff --git a/css/layout.css b/css/layout.css index c7b4fec..d69ca60 100644 --- a/css/layout.css +++ b/css/layout.css @@ -6,12 +6,13 @@ .tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } .tool[data-tool="area_delete"] { --tool-icon: url("../assets/ui/tool_delete.webp"); } .tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.webp"); } +.tool[data-tool="shoot"] { --tool-icon: url("../assets/ui/tool_shoot.png"); --tool-icon-zoom: 1.48; } .tool[data-tool="poke"] { --tool-emoji: "\1F448"; } .tool[data-tool="pinch"] { --tool-emoji: "\1F90F"; } .tool[data-tool="water_hose"] { --tool-emoji: "\1F6BF"; } .tool[data-tool="copy"] { --tool-emoji: "⧉"; } -.tool[data-tool="area_delete"] { --tool-emoji: "✕"; } +.tool[data-tool="area_delete"] { --tool-emoji: "×"; } .tool[data-tool="rope"] { --tool-emoji: "╰"; } .tool[data-tool="rod"] { --tool-emoji: "━"; } .tool[data-tool="reciprocator"] { --tool-emoji: "⇄"; } @@ -41,10 +42,15 @@ .tool[data-tool="area_delete"] { --tool-icon-zoom: 1.42; } .tool[data-tool="pinch"].tool-emoji-icon::before { font-size: 22px; } .tool[data-tool="area_delete"].tool-emoji-icon::before { - font-size: 30px; + font-size: 31px; + line-height: 1; color: #f3a7aa; text-shadow: 0 1px 0 rgba(255,255,255,0.65), 0 2px 5px rgba(146,54,62,0.16); filter: none; + transform: translateY(-54%) scale(var(--tool-icon-zoom, 1)); +} +.tool-category[data-tool-category="operate"] .tool[data-tool="area_delete"].tool-emoji-icon::before { + transform: translate(-50%, -54%) scale(var(--tool-icon-zoom, 1)); } .tool[data-tool="ball"] { --tool-icon-zoom: 1.36; } diff --git a/index.html b/index.html index 11c5b1f..9685d88 100644 --- a/index.html +++ b/index.html @@ -7,11 +7,11 @@ - - - - - + + + + +
@@ -66,17 +66,17 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/audio.js b/js/audio.js index 666f224..0fd084b 100644 --- a/js/audio.js +++ b/js/audio.js @@ -303,6 +303,8 @@ const audio = { grab() { this.play("sfx_grab", { category: "ops", minGap: 0.08 }); }, drop() { this.play("sfx_drop", { category: "ops", minGap: 0.08 }); }, waterHose() { this.play("sfx_water_hose", { category: "ops", minGap: 0.20 }); }, + shoot() { this.play("sfx_shoot", { category: "ops", minGap: 0.045 }); }, + boltAction() { this.play("sfx_bolt_action", { category: "ops", minGap: 0.10 }); }, ballHit() { this.play("sfx_ball_hit", { category: "ops", minGap: 0.08 }); }, stoneImpact() { this.play("sfx_stone_impact", { category: "ops", minGap: 0.18 }); }, genkotsuImpact() { this.play("sfx_genkotsu_impact", { category: "ops", minGap: 0.20 }); setTimeout(() => this.play("sfx_ground_rumble", { category: "ops", minGap: 0.20 }), 80); }, diff --git a/js/collision_footprint_system.js b/js/collision_footprint_system.js index bc7881a..7991cdc 100644 --- a/js/collision_footprint_system.js +++ b/js/collision_footprint_system.js @@ -208,7 +208,9 @@ const otherRects = worldRef.solidObstacleRects?.(other) || []; const otherRadius = itemFootprintRadius(worldRef, other, otherRects); const hasMechanism = global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type) || global.TarinaiMechanicalSystem?.isMechanicalType?.(other.type); - const margin = hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); + const itemIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(item.type) : /fence/.test(String(item.type || "")); + const otherIsFence = typeof global.isFenceItemType === "function" ? global.isFenceItemType(other.type) : /fence/.test(String(other.type || "")); + const margin = (itemIsFence || otherIsFence) ? (item.type === "gate_fence" || other.type === "gate_fence" ? 1.5 : 2.5) : (hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18)); if (itemRects.length && otherRects.length) { if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; continue; diff --git a/js/command_dispatcher.js b/js/command_dispatcher.js index 0c1e509..5d8561a 100644 --- a/js/command_dispatcher.js +++ b/js/command_dispatcher.js @@ -25,6 +25,12 @@ const id = String(toolId || "observe"); if (worldRef.tool === id) return ok({ toolId: id, changed: false }); worldRef.tool = id; + if (id !== "shoot") { + worldRef.hoverShootTarget = null; + worldRef.shootCursorActive = false; + worldRef.shootCursorOffsetX = 0; + worldRef.shootCursorOffsetY = 0; + } emit(worldRef, "tool:selected", { toolId: id }); return ok({ toolId: id, changed: true }); } diff --git a/js/constraint_system.js b/js/constraint_system.js index 8e0b7cd..8554365 100644 --- a/js/constraint_system.js +++ b/js/constraint_system.js @@ -363,8 +363,9 @@ if (obj.radius && obj.name) { obj.x += moveX; obj.y += moveY; - const kickScale = mode === "rod" ? 0.038 : 0.052; - const kickLimit = mode === "rod" ? 88 : 105; + const kickScale = mode === "rod" ? 0.020 : 0.052; + const kickLimit = mode === "rod" ? 54 : 105; + if (mode === "rod") { obj.vx = (obj.vx || 0) * 0.90; obj.vy = (obj.vy || 0) * 0.90; } obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, kickScale, kickLimit), -150, 150); obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, kickScale, kickLimit), -150, 150); if (obj.state === "sleep" || obj.sleeping) { @@ -384,8 +385,8 @@ // Project the rail body along its single legal axis. Powered motors are // intentionally stiff, so a rope/rod can slow them but not yank them far // off their drive path in one frame. - const railScale = mode === "rod" ? (powered ? 0.58 : 0.94) : (powered ? 0.22 : 0.78); - const railCap = mode === "rod" ? (powered ? 34 : 42) : 18; + const railScale = mode === "rod" ? (powered ? 0.46 : 0.76) : (powered ? 0.22 : 0.78); + const railCap = mode === "rod" ? (powered ? 22 : 30) : 18; const along = clamp(alongRaw * railScale, -railCap, railCap); if (Math.abs(along) < 0.001) return false; const beforePhase = ps(obj, "railPhase", 0); @@ -399,16 +400,16 @@ } const changed = Math.abs(ps(obj, "railPhase", 0) - beforePhase) > 0.0001; const current = ps(obj, "slideSpeed", 0); - const kick = constraintKick(along, dt, mode === "rod" ? (powered ? 0.0035 : 0.009) : (powered ? 0.0045 : 0.012), mode === "rod" ? (powered ? 18 : 44) : (powered ? 22 : 58)); - pset(obj, "slideSpeed", clamp(current * (powered ? 0.74 : 0.88) + kick, -120, 120), "constraint-slide-project"); + const kick = constraintKick(along, dt, mode === "rod" ? (powered ? 0.0025 : 0.0055) : (powered ? 0.0045 : 0.012), mode === "rod" ? (powered ? 12 : 26) : (powered ? 22 : 58)); + pset(obj, "slideSpeed", clamp(current * (powered ? 0.70 : (mode === "rod" ? 0.72 : 0.88)) + kick, -120, 120), "constraint-slide-project"); if (changed) commitConstraintMovedMechanical(obj, "constraint-reciprocator-project"); return changed; } if (obj.type === "poison_block") { const beforeX = Number(obj.x || 0) || 0; const beforeY = Number(obj.y || 0) || 0; - const blockScale = mode === "rod" ? 0.92 : 0.72; - const blockCap = mode === "rod" ? 34 : 18; + const blockScale = mode === "rod" ? 0.74 : 0.72; + const blockCap = mode === "rod" ? 24 : 18; const px = clamp(moveX * blockScale, -blockCap, blockCap); const py = clamp(moveY * blockScale, -blockCap, blockCap); if (Math.hypot(px, py) < 0.001) return false; @@ -428,8 +429,9 @@ nextVx -= nx * intoCorrection * 0.50; nextVy -= ny * intoCorrection * 0.50; } - nextVx += constraintKick(px, dt, 0.012, 36); - nextVy += constraintKick(py, dt, 0.012, 36); + if (mode === "rod") { nextVx *= 0.86; nextVy *= 0.86; } + nextVx += constraintKick(px, dt, mode === "rod" ? 0.006 : 0.012, mode === "rod" ? 20 : 36); + nextVy += constraintKick(py, dt, mode === "rod" ? 0.006 : 0.012, mode === "rod" ? 20 : 36); pset(obj, "xv", clamp(nextVx, -165, 165), "constraint-poison-project"); pset(obj, "yv", clamp(nextVy, -165, 165), "constraint-poison-project"); obj.vx = ps(obj, "xv", obj.vx || 0); @@ -456,8 +458,9 @@ obj.prevY = obj.y; obj.x += moveX; obj.y += moveY; - obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, mode === "rod" ? 0.032 : 0.042, mode === "rod" ? 62 : 78), -125, 125); - obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, mode === "rod" ? 0.032 : 0.042, mode === "rod" ? 62 : 78), -125, 125); + if (mode === "rod") { obj.vx = (obj.vx || 0) * 0.90; obj.vy = (obj.vy || 0) * 0.90; } + obj.vx = clamp((obj.vx || 0) + constraintKick(moveX, dt, mode === "rod" ? 0.016 : 0.042, mode === "rod" ? 42 : 78), -125, 125); + obj.vy = clamp((obj.vy || 0) + constraintKick(moveY, dt, mode === "rod" ? 0.016 : 0.042, mode === "rod" ? 42 : 78), -125, 125); return true; } @@ -514,7 +517,7 @@ const d = Math.max(0.001, Math.hypot(dx, dy)); const delta = d - length; if (mode === "rope" && delta <= 0) return false; - if (mode === "rod" && Math.abs(delta) < 0.35) return false; + if (mode === "rod" && Math.abs(delta) < 0.85) return false; const nx = dx / d; const ny = dy / d; const ma = endpointMass(pa.obj); @@ -523,7 +526,7 @@ const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0; const sum = ia + ib || 1; const step = Math.max(0.012, Math.min(0.05, Number(dt || 0.016) || 0.016)); - const compliance = mode === "rod" ? 0.035 / (step * 60) : 0.46 / (step * 60); + const compliance = mode === "rod" ? 0.085 / (step * 60) : 0.46 / (step * 60); const target = delta / (1 + compliance); const cap = mode === "rod" ? Math.max(6.0, Math.min(46, length * maxFraction)) @@ -538,11 +541,11 @@ if (mode === "rope") { changed = solveOnce(a, b, 0.58, 0.044) || changed; } else { - const iterations = 7; + const iterations = 5; for (let i = 0; i < iterations; i += 1) { const aa = endpointWorld(ep(item, 0), item.world || null) || a; const bb = endpointWorld(ep(item, 1), item.world || null) || b; - changed = solveOnce(aa, bb, 0.96, 0.18) || changed; + changed = solveOnce(aa, bb, 0.82, 0.10) || changed; } } return changed; @@ -906,7 +909,7 @@ const ca = endpointWorld(ep(item, 0), worldRef) || aa; const cb = endpointWorld(ep(item, 1), worldRef) || bb; const stretch = ca && cb ? Math.abs(distXY(ca.x, ca.y, cb.x, cb.y) - length) : 0; - if (stretch > Math.max(0.75, length * 0.006)) { + if (stretch > Math.max(1.6, length * 0.012)) { item._rodStretchCorrectionCount = (item._rodStretchCorrectionCount || 0) + 1; item._rodLastStretch = stretch; changed = applyDistanceConstraint(item, ca, cb, Math.min(0.033, dt || 0.016), length, "rod") || changed; diff --git a/js/debug_tools.js b/js/debug_tools.js index 5bd1f95..ea60846 100644 --- a/js/debug_tools.js +++ b/js/debug_tools.js @@ -131,6 +131,7 @@ return hints.join(" ") || "-"; } + const DEBUG_REFRESH_MS = 750; window.setInterval(() => { const w = window.world; const input = window.TarinaiInputMode?.snapshot?.() || {}; @@ -154,7 +155,7 @@ `spatial dirtyReasons ${topMap(diag.spatial?.dirtyReasons, 4)} rebuildReasons ${topMap(diag.spatial?.rebuildReasons, 4)}`, `nearby obstacleRects hit:${diag.nearby?.obstacleRectHits ?? 0} miss:${diag.nearby?.obstacleRectMisses ?? 0} built:${diag.nearby?.obstacleRectBuilt ?? 0} bypass:${diag.nearby?.obstacleRectBypass ?? 0} constraintDirty mark:${diag.constraintDirty?.marked ?? 0} coal:${diag.constraintDirty?.coalesced ?? 0}`, `scheduler heap:${scheduler.heap ?? 0} realtime:${scheduler.activeRealtime ?? scheduler.realtime ?? 0} ran:${scheduler.lastRan ?? 0} itemRt:${scheduler.realtimeRan ?? 0} itemDue:${scheduler.dueRan ?? 0} constraints:${scheduler.constraints ?? 0} postPairs:${scheduler.postConstraintPairs ?? 0} rodStretch:${fmtMs(scheduler.constraintStats?.rodStretchMax || 0)} corr:${scheduler.constraintStats?.rodCorrections ?? 0}`, - `items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"}`, + `items active:${scheduler.activeRealtime ?? 0} asleep:${scheduler.sleeping ?? scheduler.heap ?? 0} budgetPairs:${scheduler.physicsBudget?.maxPairs ?? "?"} substeps:${scheduler.physics?.mechanical?.substeps ?? scheduler.mechanicalStats?.substeps ?? "?"} fenceCCD:${Boolean((scheduler.physics?.mechanical || scheduler.mechanicalStats)?.fenceSensitive)}`, `terrain dirty raw:${terrain.raw ?? 0} global:${terrain.global ?? 0} chunk:${terrain.chunk ?? 0} coalesced:${terrain.coalesced ?? 0} reasons:${topMap(terrain.reasons, 4)}`, `work ai ${diag.work?.stats?.aiRuns ?? 0}/${diag.work?.stats?.aiSkips ?? 0} env ${diag.work?.stats?.envRuns ?? 0}/${diag.work?.stats?.envSkips ?? 0} coll ${diag.work?.stats?.collisionRuns ?? 0}/${diag.work?.stats?.collisionSkips ?? 0}`, `hints ${diagnosticHints(diag, perf)}`, @@ -173,5 +174,5 @@ : `tarinai debug fps ${window.__tarinaiFps ?? "?"} frame ${fmtMs(perf.frameAvg || 0)}ms`; if (Date.now() >= copiedMessageUntil && copyBtn.textContent !== "Copy") copyBtn.textContent = "Copy"; if (!minimized) body.textContent = lastText; - }, 500); + }, DEBUG_REFRESH_MS); })(); diff --git a/js/health.js b/js/health.js index 24bb899..ba03579 100644 --- a/js/health.js +++ b/js/health.js @@ -11,6 +11,9 @@ const HEALTH = (() => { poke: "\u3064\u3064\u304b\u308c\u3059\u304e", firecracker: "\u7206\u7af9", genkotsu: "\u3052\u3093\u3053\u3064", + shooting: "射撃", + stoneCrush: "石に潰された", + weakness: "衰弱", poisonBlock: "毒ブロック", accident: "\u4e8b\u6545", collision: "\u885d\u7a81", @@ -30,6 +33,10 @@ const HEALTH = (() => { const text = String(reason || ""); if (!text) return ""; if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); + if (/射撃|shoot/.test(text)) return CAUSES.shooting; + if (/石に潰された|石.*潰|stone.*crush|crushed.*stone/.test(text)) return CAUSES.stoneCrush; + const collisionWith = text.match(/([^、。\s]+)に衝突/); + if (collisionWith) return `${collisionWith[1]}に衝突`; if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; if (/\u3052\u3093\u3053\u3064|genkotsu/.test(text)) return CAUSES.genkotsu; if (/毒ブロック|poison.?block/.test(text)) return CAUSES.poisonBlock; @@ -37,7 +44,7 @@ const HEALTH = (() => { if (/\u77ee\u5c0f\u85ac|dwarf/.test(text)) return CAUSES.dwarfDrug; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; - if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision; + if (/\u885d\u7a81|collision|急激な速度変化/.test(text)) return CAUSES.collision; if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke; if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease; if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease; @@ -50,7 +57,8 @@ const HEALTH = (() => { } if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger; if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress; - if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return CAUSES.accident; + if (/\u77f3|stone/.test(text)) return CAUSES.stoneCrush; + if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|accident/.test(text)) return CAUSES.accident; if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return CAUSES.lifespan; return ""; } @@ -96,24 +104,33 @@ const HEALTH = (() => { const recentMajor = hasRecentMajorDamage(t); const recent = recentDamageCause(t, 20); const fighting = isFightingContext(t, recent); - if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision; - if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) { + + // Direct damage death: use the immediate/direct source, not a weakened suffix. + if (opts.fromDamage) { + if (direct) return direct; + if (recentMajor && t.lastMajorDamageCause) return t.lastMajorDamageCause; + if (recent) return recent; + return CAUSES.accident; + } + + if (recentMajor && t.lastMajorDamageCause === CAUSES.collision && !(t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) return CAUSES.collision; + if (recentMajor && (!direct || direct === t.lastMajorDamageCause || t.energy <= 18 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 112)) { return weakenedDeathReasonFor(t.lastMajorDamageCause); } const weakenedTrace = recentDamageCause(t, WEAKENED_TRACE_WINDOW); const traceCanWeaken = weakenedTrace && weakenedTrace !== CAUSES.stress && weakenedTrace !== CAUSES.hunger && weakenedTrace !== CAUSES.lifespan && weakenedTrace !== CAUSES.outOfBounds; - if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace || opts.fromDamage) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) { + if (traceCanWeaken && (!direct || direct === CAUSES.stress || direct === CAUSES.hunger || direct === weakenedTrace) && (t.energy <= 20 || t.mood <= 16 || t.stress >= 124 || t.hunger >= 114)) { return weakenedDeathReasonFor(weakenedTrace); } if (direct && direct !== CAUSES.stress) return direct; if (t.zunchiDisease && ((t.zunchiDiseaseSeverity || 0) >= 0.30 || /\u75c5|zunchi/.test(text))) return CAUSES.zunchiDisease; if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger; - if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight; - if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126 || opts.fromDamage)) return weakenedDeathReasonFor(recent); + if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22)) return CAUSES.fight; + if (recent && recent !== CAUSES.stress && (t.energy <= 22 || t.mood <= 14 || t.stress >= 126)) return weakenedDeathReasonFor(recent); if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan; if (direct) return direct; if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress; - if (t.energy <= 0.5) return CAUSES.accident; + if (opts.lowHealth || t.energy <= 0.5) return CAUSES.weakness; return ""; } @@ -122,11 +139,42 @@ const HEALTH = (() => { if (text.includes(WEAKENED_SUFFIX)) return text; const cause = dominantDeathCause(t, text, opts) || CAUSES.accident; if (cause.includes(WEAKENED_SUFFIX)) return cause; - const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu; + if (opts.fromDamage) return cause; + const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision && cause !== CAUSES.genkotsu && cause !== CAUSES.weakness; if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause); return cause; } + + function maybeReleaseZunchiFromDamage(t, actualLoss = 0, cause = "") { + if (!t || !t.world || t.dead) return false; + const loss = Math.max(0, Number(actualLoss) || 0); + if (loss <= 0.1) return false; + const now = t.world.time || 0; + if (now < Number(t.nextDamageZunchiAt || -999)) return false; + const probability = Math.max(0.06, Math.min(0.82, 0.06 + loss / 48)); + if (Math.random() >= probability) return false; + t.nextDamageZunchiAt = now + Math.max(0.55, 1.55 - Math.min(0.9, loss / 38)); + if (t.hasLodgedPinEffect?.("blocksZunchi")) { + t.oshiriByoZunchiStock = Math.min(24, Math.max(0, t.oshiriByoZunchiStock || 0) + 1); + t.thought = "ダメージでずんちを我慢している"; + return false; + } + const side = t.facingDir ? t.facingDir() : (t.facing || 1); + const jitter = (Math.random() - 0.5) * Math.max(4, (t.radius || 22) * 0.25); + const x = t.x - side * (t.radius || 22) * (0.72 + Math.random() * 0.24); + const y = t.y + (t.radius || 22) * (0.24 + Math.random() * 0.25) + jitter; + t.world.spawnZunchi?.(x, y, t); + t.poopCount = (t.poopCount || 0) + 1; + t.digest = Math.max(0, Number(t.digest || 0) - 1.0); + t.nextPoopBubbleAt = now + 2.0; + t.bubble?.("ぶりゅっ", 1.1, "rgba(74,91,50,0.78)", { force: true }); + if (now > Number(t.nextDamageZunchiLogAt || -999)) { + t.nextDamageZunchiLogAt = now + 10; + t.world.log?.(`${t.name}はダメージでずんちを放出した。`, "accident", { participants: [t] }); + } + return true; + } function applyDamage(t, amount, reason = "") { const maxEnergy = typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100); t.maxEnergy = maxEnergy; @@ -142,14 +190,14 @@ const HEALTH = (() => { if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); audio.damage?.(actualLoss); + maybeReleaseZunchiFromDamage(t, actualLoss, cause || reason); if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); if (t.wakeFromDamage) t.wakeFromDamage(cause || reason); if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason); t.showHpBar(); } if (t.energy <= 0.5) { - const majorNow = actualLoss >= Math.max(10, before * 0.42); - t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true, weakened: majorNow })); + t.die(normalizeDeathReason(t, reason || cause, { fromDamage: true })); } } @@ -172,6 +220,7 @@ const HEALTH = (() => { normalizeDeathReason, applyDamage, briefDeathReason, + maybeReleaseZunchiFromDamage, }); })(); diff --git a/js/item_dynamic_duplicator_system.js b/js/item_dynamic_duplicator_system.js index 000c0cd..8f54a68 100644 --- a/js/item_dynamic_duplicator_system.js +++ b/js/item_dynamic_duplicator_system.js @@ -14,89 +14,18 @@ const s = support(); const syncRoles = s.syncDuplicatorRoles || global.TarinaiDuplicatorRuntime?.syncRoles; const applyStoredPin = s.duplicatorApplyStoredPin; - const canLoadNow = s.duplicatorCanLoadNow; - const loadPointFor = s.duplicatorLoadPoint || global.TarinaiDuplicatorRuntime?.loadPoint; - const loadTypeForItem = s.duplicatorLoadTypeForItem || global.TarinaiDuplicatorRuntime?.loadTypeForItem; - const candidateReach = s.duplicatorCandidateReach; - const setStoredType = s.duplicatorSetStoredType || global.TarinaiDuplicatorRuntime?.setStoredType; - if (typeof syncRoles !== "function" || typeof loadPointFor !== "function" || typeof loadTypeForItem !== "function" || typeof setStoredType !== "function") return false; + if (typeof syncRoles !== "function") return false; item.amount = 999; syncRoles(item); if (typeof applyStoredPin === "function") applyStoredPin(item, worldRef); item.duplicatorPinFlash = Math.max(0, (item.duplicatorPinFlash || 0) - dt); - if (!worldRef?.nearbyItems) return true; - if (typeof canLoadNow === "function" && !canLoadNow(item, worldRef)) { - item._duplicatorCandidateKey = ""; - item._duplicatorCandidateSince = 0; - return true; - } - - const loadPoint = loadPointFor(item); - const pickupRadius = Math.max(68, (item.r || 34) + 44); - const candidates = Array.from(worldRef.nearbyItems(item.x, item.y, pickupRadius, true) || []); - // SpatialGrid の分類漏れを補う。ただし、複製機からかなり近いものだけに限定し、 - // 移動中の複製機が周辺アイテムを吸い込む挙動を避ける。 - if (worldRef.items) { - for (const candidate of worldRef.items) { - if (!candidate || candidates.includes(candidate) || candidate === item || candidate.dead) continue; - const approxReach = pickupRadius + Math.max(16, candidate.r || 12); - if (distXY(item.x, item.y, candidate.x, candidate.y) <= approxReach) candidates.push(candidate); - } - } - - let best = null, bestType = "", bestSlotD = Infinity, bestScore = Infinity; - for (const it of candidates) { - if (!it || it === item || it.dead || it.playerHeld || it._heldByPlayer) continue; - const loadType = loadTypeForItem(it); - if (!loadType) continue; - if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; - const slotD = distXY(loadPoint.x, loadPoint.y, it.x, it.y); - const centerD = distXY(item.x, item.y, it.x, it.y); - const slotReach = typeof candidateReach === "function" ? candidateReach(it) : Math.max(20, Math.min(36, (it?.r || 12) * 0.82 + 13)); - const bodyReach = Math.max(22, (item.r || 34) * 0.46 + (it.r || 12) * 0.36); - if (slotD > slotReach && centerD > bodyReach) continue; - // 基本は投入口に近いものを優先。同距離なら置かれたばかりのものを優先し、 - // 複数候補があるときに意図しない古いアイテムへ寄りにくくする。 - const score = slotD + Math.max(0, Number(it.age || 0)) * 0.012; - if (score < bestScore) { - best = it; - bestType = loadType; - bestSlotD = slotD; - bestScore = score; - } - } - - if (!best || !bestType) { - item._duplicatorCandidateKey = ""; - item._duplicatorCandidateSince = 0; - return true; - } - - const candidateKey = `${best.id || ""}:${bestType}`; - const now = Number(worldRef.time || 0); - if (item._duplicatorCandidateKey !== candidateKey) { - item._duplicatorCandidateKey = candidateKey; - item._duplicatorCandidateSince = now; - return true; - } - const dwell = item.storedFoodType ? (s.DUPLICATOR_REPLACE_DWELL_SECONDS ?? 0.70) : (s.DUPLICATOR_LOAD_DWELL_SECONDS ?? 0.38); - if (now - Number(item._duplicatorCandidateSince || 0) < dwell) return true; - - const changed = item.storedFoodType !== bestType; - if (!changed && item.storedFoodType) { - item._duplicatorCandidateKey = ""; - item._duplicatorCandidateSince = 0; - return true; - } - - setStoredType(item, bestType, worldRef, { consumeItem: best, direct: false, force: false, log: false, emit: false }); - // Item.dead is a getter based on amount. Do not assign to it. - worldRef.markItemBucketsDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); - worldRef.emit?.("duplicator:loaded", { duplicator: item, foodType: item.storedFoodType, replaced: changed, slotDistance: bestSlotD }); - worldRef.log?.(`複製機に${item.storedFoodLabel}をセットした。`, typeof isPinType === "function" && isPinType(item.storedFoodType) ? "observe" : "food"); + // 39.15.27: auto-loading by contact is intentionally disabled. + // A duplicator accepts a stored item only through the explicit player + // click path (World.directSetDuplicatorAt / direct:true). This prevents + // dropped or pushed items from accidentally changing its setup. + item._duplicatorCandidateKey = ""; + item._duplicatorCandidateSince = 0; return true; } diff --git a/js/item_lifecycle_support.js b/js/item_lifecycle_support.js index 4acad1b..0480e5d 100644 --- a/js/item_lifecycle_support.js +++ b/js/item_lifecycle_support.js @@ -14,7 +14,7 @@ const DUPLICATOR_STORABLE_TYPES = new Set([ function duplicatorLoadTypeForItem(item) { if (!item || item.dead || item.type === "duplicator") return ""; const type = String(item.type || ""); - if (!type || type === "water_bowl" || type === "grass") return ""; + if (!type || type === "grass") return ""; if ((typeof isPinType === "function" && isPinType(type)) && item.pinState === "lodged") return ""; // Explicit list first. これで「ずんだ餅」「へこ餅」「けんか餅」が // serving-food 判定や medicine role の揺れに左右されず複製機へ入る。 @@ -23,7 +23,7 @@ function duplicatorLoadTypeForItem(item) { if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return type; const foodDef = global.TarinaiItemRegistry?.food?.get?.(type); if (foodDef && (Number(foodDef.nutrition || 0) > 0 || foodDef.effectId)) return type; - if (typeof roleMatches === "function" && roleMatches(item, "medicine") && type !== "water_bowl") return type; + if (typeof roleMatches === "function" && roleMatches(item, "medicine")) return type; return ""; } @@ -66,6 +66,7 @@ function syncDuplicatorRoles(item) { function duplicatorSetStoredType(duplicator, storedType = "", worldRef = null, options = {}) { if (!duplicator || duplicator.type !== "duplicator") return false; + if (options.direct !== true && options.force !== true) return false; const type = String(storedType || ""); if (!type) return false; const probe = { type, amount: 1, foodServingsRemaining: 1, dead: false }; diff --git a/js/item_registry.js b/js/item_registry.js index 5b1f399..f5ded3c 100644 --- a/js/item_registry.js +++ b/js/item_registry.js @@ -43,8 +43,9 @@ const TOOL_DEFINITIONS = Object.freeze({ pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや一部の置きものを移動させる。" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, - rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックして、伸び縮みしないヒモで接続する。機械体・柵・ヒモ・棒はクリック地点、それ以外は中心につなぐ。" }, + rope: { id: "rope", label: "ヒモ", placeable: false, scalable: false, iconText: "╰", tooltip: "2つの対象を順にクリックしてふにゃふにゃしたヒモで繋ぐ。" }, rod: { id: "rod", label: "棒", placeable: false, scalable: false, iconText: "━", tooltip: "2つの対象を順にクリックして、曲がらない棒で接続する。" }, + shoot: { id: "shoot", label: "射撃", placeable: false, scalable: false, icon: "assets/ui/tool_shoot.png", tooltip: "銃で攻撃する。" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, @@ -78,7 +79,7 @@ const TOOL_DEFINITIONS = Object.freeze({ bounce_fence_v: { id: "bounce_fence_v", itemType: "bounce_fence_v", label: "\u30d0\u30a6\u30f3\u30b9\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u4f55\u304b\u304c\u5f53\u305f\u308b\u3068\u901f\u3081\u306b\u8df3\u306d\u8fd4\u3059\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, gate_fence: { id: "gate_fence", itemType: "gate_fence", label: "\u30b2\u30fc\u30c8\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, collisionShape: "oriented_rect", tooltip: "\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u958b\u9589\u3059\u308b\u67f5\u3002Q/E\u306745\u5ea6\u3001Shift+Q/E\u30675\u5ea6\u305a\u3064\u5411\u304d\u3092\u5909\u3048\u308b\u3002" }, rotator: { id: "rotator", itemType: "rotator", label: "回転体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "↻", collisionShape: "custom_rotator", tooltip: "回転する機械。クリックすると形状や速度を設定できる。" }, - poison_block: { id: "poison_block", itemType: "poison_block", label: "毒ブロック", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "☠", collisionShape: "custom_poison_block", tooltip: "自由に形を描ける毒の物理ブロック。外力で動き、触れたたりないにダメージ。当たり判定ON/OFFを切り替えられる。" }, + poison_block: { id: "poison_block", itemType: "poison_block", label: "毒ブロック", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "☠", collisionShape: "custom_poison_block", tooltip: "当たると皮膚が痺れる。クリックで形状が変更できる。" }, reciprocator: { id: "reciprocator", itemType: "reciprocator", label: "往復体", placeable: true, scalable: false, radius: 64, amount: 999, iconText: "⇄", collisionShape: "custom_reciprocator", tooltip: "往復する機械。クリックすると形状や速度を設定できる。" }, water: { id: "water", itemType: "water", label: "水滴", placeable: true, scalable: true, radius: 12, amount: 64, iconText: "💧", tooltip: "雨の日にも現れる水滴。たりないが飲める。複製機にもセットできる。" }, trace: { id: "trace", itemType: "trace", label: "死骸", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, @@ -165,7 +166,7 @@ const TOOL_CATEGORIES = Object.freeze([ { id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, { id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard"] }, { id: "physics", label: "物理", 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", "pushpin", "oshibyo", "ant_nest"] }, + { id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "shoot", "ant_nest"] }, ]); function toolCategories() { @@ -247,21 +248,6 @@ function toolTipsFromDefinitions() { "fieldScale": 1, "accessibleShapeTraits": ["horizontal", "oval", "liquid", "silver"] }, - "water_bowl": { - "renderer": "water_bowl", - "shape": "bowl_with_horizontal_oval_water", - "palette": { - "bowlFill": "rgba(171,120,76,0.72)", - "bowlStroke": "rgba(94,69,48,0.55)", - "waterFill": "rgba(86,157,224,0.58)", - "waterStroke": "rgba(62,113,178,0.36)", - "highlight": "rgba(255,255,255,0.54)" - }, - "scale": 1, - "uiPreviewScale": 1, - "fieldScale": 1, - "accessibleShapeTraits": ["bowl", "horizontal", "oval", "water", "blue"] - }, "sleep_drug": { "renderer": "sleep_tablet", "shape": "moon_tablet", @@ -379,7 +365,6 @@ function toolTipsFromDefinitions() { zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, water: { label: "水滴", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - water_bowl: { label: "水の皿", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } }; diff --git a/js/item_render_runtime.js b/js/item_render_runtime.js index eba3de7..3fc4a49 100644 --- a/js/item_render_runtime.js +++ b/js/item_render_runtime.js @@ -209,22 +209,6 @@ draw(ctx, t, lighting = null) { ctx.stroke(); } ctx.restore(); - } else if (this.type === "water_bowl") { - const palette = visualPaletteFor("water_bowl"); - ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)"; - ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)"; - ctx.lineWidth = 2.2; - ctx.beginPath(); - ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)"; - ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)"; - ctx.lineWidth = 1.6; - ctx.beginPath(); - ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)"; - ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill(); } else if (this.type === "water") { drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water")); } else if (this.type === "grass") { @@ -661,31 +645,30 @@ draw(ctx, t, lighting = null) { ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.globalAlpha *= enabled ? 1 : 0.48; - if (!enabled) ctx.setLineDash([10, 7]); ctx.strokeStyle = styleNight ? "#4d8e4e" : "#5cac4d"; ctx.lineWidth = thick; - ctx.shadowColor = enabled ? (styleNight ? "rgba(92,190,86,0.34)" : "rgba(62,170,54,0.28)") : "rgba(70,120,70,0.16)"; + ctx.shadowColor = styleNight ? "rgba(92,190,86,0.34)" : "rgba(62,170,54,0.28)"; ctx.shadowBlur = tier === "low" ? 0 : (tier === "mid" ? Math.max(2, thick * 0.18) : Math.max(4, thick * 0.32)); strokeSegments(ctx, this, segments, "body"); ctx.shadowBlur = 0; ctx.setLineDash([]); if (tier !== "low") { - ctx.strokeStyle = enabled ? (styleNight ? "rgba(226,255,218,0.46)" : "rgba(255,255,255,0.50)") : "rgba(245,255,236,0.28)"; + ctx.strokeStyle = styleNight ? "rgba(226,255,218,0.46)" : "rgba(255,255,255,0.50)"; ctx.lineWidth = Math.max(1.4, thick * 0.22); strokeSegments(ctx, this, segments, "body"); } ctx.restore(); - ctx.fillStyle = enabled ? (styleNight ? "#263b27" : "#efffe9") : (styleNight ? "#303934" : "#eef4e8"); - ctx.strokeStyle = enabled ? (styleNight ? "#80c56e" : "#3e8f35") : "rgba(78,118,74,0.72)"; + ctx.fillStyle = styleNight ? "#263b27" : "#efffe9"; + ctx.strokeStyle = styleNight ? "#80c56e" : "#3e8f35"; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.arc(0, 0, Math.max(8, thick * 0.74), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.fillStyle = enabled ? (styleNight ? "#d8ffd0" : "#26711f") : "rgba(70,92,68,0.70)"; + ctx.fillStyle = styleNight ? "#d8ffd0" : "#26711f"; ctx.font = `bold ${Math.max(10, thick * 0.84)}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText(enabled ? "毒" : "透", 0, 0); + ctx.fillText("毒", 0, 0); } else if (this.type === "reciprocator") { const bodyAngle = typeof itemAngleFor === "function" ? itemAngleFor(this) : (Number(this.angle) || 0); const pb = window.TarinaiPhysicsBodySystem; diff --git a/js/item_type_initializers.js b/js/item_type_initializers.js index 3f40446..b33ca0a 100644 --- a/js/item_type_initializers.js +++ b/js/item_type_initializers.js @@ -7,8 +7,8 @@ function initializeItemTypeState(item, type, x, y) { const bodySchemaVersion = globalThis.TarinaiPhysicsBodySystem?.BODY_SCHEMA_VERSION || 4; const constraintSchemaVersion = globalThis.TarinaiPhysicsBodySystem?.CONSTRAINT_SCHEMA_VERSION || 4; if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) item.roles.food = true; - if (type === "water" || type === "water_bowl" || type === "zunda_juice") item.roles.drink = true; - if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; + if (type === "water" || type === "zunda_juice") item.roles.drink = true; + if (["sweet", "water", "zunda_juice"].includes(type) || isParamEffectItemType(type)) item.roles.medicine = true; if (type === "bed" || type === "nest_box") item.roles.sleepPlace = true; if (["firecracker", "genkotsu", "pushpin", "oshibyo", "poison_block", "splat"].includes(type)) item.roles.danger = true; if (type === "grass") item.roles.grassMaterial = true; diff --git a/js/item_update_policy.js b/js/item_update_policy.js index 80a2780..bcbaa78 100644 --- a/js/item_update_policy.js +++ b/js/item_update_policy.js @@ -11,7 +11,7 @@ "ball", "genkotsu", "firecracker", "pushpin", "oshibyo", "plushie", "grass_bed", "zunchi", "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", "water_bowl", + "stone", "bed", "nest_box", "ant_nest", "signboard", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "rotator", "poison_block", "reciprocator", "rope", "rod", ]); function itemUpdateInterval(item) { diff --git a/js/items.js b/js/items.js index 5543685..417acc8 100644 --- a/js/items.js +++ b/js/items.js @@ -572,7 +572,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) { }; let scale = scaleMap[type] || 0.92; if (isConsumableSpriteType(type)) scale *= 1.95; - else if (["grass", "water", "water_bowl", "zunda_juice"].includes(type)) scale *= 1.85; + else if (["grass", "water", "zunda_juice"].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", "bounce_fence", "bounce_fence_v", "gate_fence", "reciprocator", "firecracker", "genkotsu"].includes(type)) scale *= 1.08; else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) scale *= 1.35; diff --git a/js/mechanical_system.js b/js/mechanical_system.js index 30bf84f..83cf9fd 100644 --- a/js/mechanical_system.js +++ b/js/mechanical_system.js @@ -430,7 +430,7 @@ function obstacleRects(item) { if (!item || item.dead || !isMechanicalType(item)) return []; - if (item.type === "poison_block" && bodyOf(item)?.collision?.solid === false) return []; + if (bodyOf(item)?.collision?.solid === false) return []; return rectsFor(item, "obstacle"); } @@ -1018,6 +1018,17 @@ return out; } + function hasFenceNearby(item, worldRef, radius) { + if (!item || !worldRef?.items) return false; + const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items; + for (const other of source) { + if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue; + if (other.type === "gate_fence" && other.gateOpen) continue; + return true; + } + return false; + } + function resolveFenceContacts(item, dt, worldRef, radius = null) { if (!item || !worldRef || !isMechanicalType(item)) return false; // 往復体の停止・反転対象は物理アイテムに限定する。 @@ -1029,7 +1040,7 @@ const now = worldRef.time || 0; for (const mechRect of rects) { for (const fenceRect of fences) { - let info = FP().rectOverlapInfo?.(mechRect, fenceRect, 0.5); + let info = FP().rectOverlapInfo?.(mechRect, fenceRect, item.type === "rotator" ? 1.35 : 0.5); if (!info) continue; const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef); if (separated) { @@ -1042,6 +1053,7 @@ const impulse = clamp(closing * 0.18 + info.overlap * 6.6 + 4.5, 3.5, separated ? 58 : 92); applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10); applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82); + if (item.type === "rotator") brakePoweredRotatorOnMechanicalContact(item, dt, worldRef, "fence"); const key = `f:${item.id}:${fenceRect.item?.id || "?"}`; worldRef._mechanicalContactAt = worldRef._mechanicalContactAt || Object.create(null); if ((worldRef._mechanicalContactAt[key] || -999) + 0.08 <= now) { @@ -1320,7 +1332,7 @@ function shouldCollideMechanical(item) { if (!item || item.dead || !isMechanicalType(item)) return false; if (item.playerHeld || item._heldByPlayer) return false; - if (item.type === "poison_block" && bodyOf(item)?.collision?.solid === false) return false; + if (bodyOf(item)?.collision?.solid === false) return false; return obstacleRects(item).length > 0; } @@ -1633,13 +1645,21 @@ const movingWork = active.length; let maxMotion = 0; for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt)); - const substeps = Math.max(1, Math.min(Number(opts.maxSubsteps || 4) || 4, Math.max(Math.ceil(movingWork / 36), Math.ceil(maxMotion / 14)))); + let fenceSensitiveMotion = false; + for (const item of active) { + if (item?.type === "rotator" && estimatedBodyMotion(item, clampedDt) > 9 && hasFenceNearby(item, worldRef, reach(item) + 94)) { fenceSensitiveMotion = true; break; } + } + const requestedMaxSubsteps = Number(opts.maxSubsteps || 4) || 4; + const substepCap = fenceSensitiveMotion ? Math.min(6, Math.max(requestedMaxSubsteps, requestedMaxSubsteps + 2)) : requestedMaxSubsteps; + const motionSlice = fenceSensitiveMotion ? 8 : 14; + const substeps = Math.max(1, Math.min(substepCap, Math.max(Math.ceil(movingWork / 36), Math.ceil(maxMotion / motionSlice)))); const subDt = clampedDt / substeps; let ran = 0; let moved = false; let stepSolved = 0; let stepChecked = 0; let stepPairs = 0; + let stepFenceSolved = 0; // Resolve body pairs between substeps, not only once after all motion. // This keeps thin, visually-aligned rotator/reciprocator strokes from // tunneling through each other without inflating their collision width. @@ -1653,13 +1673,21 @@ else if (item.type === "poison_block") changed = updatePoisonBlock(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true }); if (changed) { moved = true; subMoved = true; ran += 1; } } - if (subMoved && active.length > 1) { - const subFrame = buildMechanicalFrame(worldRef, { bodies }); - const subStats = resolveMechanicalPairsBroadphase(worldRef, subDt, { frame: subFrame, maxPairs: opts.maxSubstepPairs || 140, focusThreshold: opts.focusThreshold || 48 }); - if (subStats.solved) moved = true; - stepSolved += subStats.solved || 0; - stepChecked += subStats.checked || 0; - stepPairs += subStats.pairs || 0; + if (subMoved) { + const subFrame = active.length > 1 || active.some(it => it?.type === "rotator") ? buildMechanicalFrame(worldRef, { bodies }) : null; + if (active.length > 1 && subFrame) { + const subStats = resolveMechanicalPairsBroadphase(worldRef, subDt, { frame: subFrame, maxPairs: opts.maxSubstepPairs || 140, focusThreshold: opts.focusThreshold || 48 }); + if (subStats.solved) moved = true; + stepSolved += subStats.solved || 0; + stepChecked += subStats.checked || 0; + stepPairs += subStats.pairs || 0; + } + // Fence contacts are checked during high-speed rotator substeps rather + // than only after the final pose. This closes the tunneling gap where + // a thin/hand-drawn rotator can pass entirely across a fence between + // two final-frame overlap checks. + const subFenceSolved = resolveMechanicalFences(worldRef, subDt, active, subFrame?.activeSet || new Set(active), { maxFenceItems: opts.maxSubstepFenceItems || 32 }); + if (subFenceSolved) { moved = true; stepFenceSolved += subFenceSolved; } } } if (moved) { @@ -1676,12 +1704,12 @@ pairStats.solved = (pairStats.solved || 0) + stepSolved; pairStats.checked = (pairStats.checked || 0) + stepChecked; pairStats.pairs = (pairStats.pairs || 0) + stepPairs; - const fenceSolved = resolveMechanicalFences(worldRef, clampedDt, bodies, frame.activeSet, opts); + const fenceSolved = stepFenceSolved + resolveMechanicalFences(worldRef, clampedDt, bodies, frame.activeSet, opts); if (pairStats.solved || fenceSolved || links) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-world-solve"); } - worldRef._mechanicalWorldStats = { bodies: bodies.length, active: frame.active, collidable: frame.collidable, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused), candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps }; + worldRef._mechanicalWorldStats = { bodies: bodies.length, active: frame.active, collidable: frame.collidable, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused), candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, fenceSensitive: fenceSensitiveMotion }; return { ran, active: frame.active, solved: pairStats.solved, checked: pairStats.checked, pairs: pairStats.pairs, fenceSolved, links, candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused) }; } finally { if (end) end(); diff --git a/js/render.js b/js/render.js index 32fa9d6..48c790e 100644 --- a/js/render.js +++ b/js/render.js @@ -6,7 +6,7 @@ const toolCursorIconImages = new Map(); function toolCursorIconDefinition(world) { const tool = String(world?.tool || ""); - if (!tool || tool === "observe" || tool === "undo" || tool === "redo") return null; + if (!tool || tool === "observe" || tool === "undo" || tool === "redo" || tool === "shoot") return null; const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null; if (!def) return null; return { tool, def, type: def.itemType || (def.placeable ? tool : "") }; @@ -120,6 +120,224 @@ function drawSelectedToolCursorIcon(ctx, world) { ctx.restore(); } + +function strokeWorldObstacleRect(ctx, rect) { + if (!rect) return false; + if (rect.oriented) { + ctx.save(); + ctx.translate(rect.cx || 0, rect.cy || 0); + ctx.rotate(rect.angle || 0); + ctx.strokeRect(-(rect.halfW || 0), -(rect.halfH || 0), (rect.halfW || 0) * 2, (rect.halfH || 0) * 2); + ctx.restore(); + return true; + } + ctx.strokeRect(rect.left || 0, rect.top || 0, Math.max(1, (rect.right || 0) - (rect.left || 0)), Math.max(1, (rect.bottom || 0) - (rect.top || 0))); + return true; +} + +function drawToolTargetHoverHighlight(ctx, world) { + if (!world) return; + const tool = String(world.tool || ""); + const target = tool === "pinch" ? world.hoverGrabTarget : (tool === "delete" ? world.hoverDeleteTarget : null); + if (!target || target.dead || target.playerHeld || target._heldByPlayer) return; + const p = world.pointer || {}; + if (!p.inside) return; + const style = tool === "delete" + ? { color: "rgba(224,82,68,0.98)", fill: "rgba(224,82,68,0.13)", label: "削除" } + : { color: "rgba(86,142,232,0.98)", fill: "rgba(86,142,232,0.12)", label: "つまむ" }; + const pulse = 0.62 + Math.sin((world.time || 0) * 9.5) * 0.12; + const worldLine = world.screenSizeToWorld ? world.screenSizeToWorld(4.6) : 4.6; + const outerLine = world.screenSizeToWorld ? world.screenSizeToWorld(2.1) : 2.1; + const drawLabel = (cx, cy, ry) => { + const labelY = cy - ry - (world.screenSizeToWorld ? world.screenSizeToWorld(14) : 14); + const labelW = Math.max(42, (world.screenSizeToWorld ? world.screenSizeToWorld(52) : 52)); + const labelH = Math.max(18, (world.screenSizeToWorld ? world.screenSizeToWorld(21) : 21)); + ctx.save(); + ctx.shadowBlur = 0; + ctx.globalAlpha = 0.96; + ctx.fillStyle = "rgba(255,255,255,0.94)"; + ctx.strokeStyle = style.color; + ctx.lineWidth = Math.max(1.2, outerLine); + if (typeof roundedRect === "function") { roundedRect(ctx, cx - labelW / 2, labelY - labelH / 2, labelW, labelH, labelH / 2); ctx.fill(); ctx.stroke(); } + else ctx.strokeRect(cx - labelW / 2, labelY - labelH / 2, labelW, labelH); + ctx.fillStyle = style.color; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(12) : 12)}px sans-serif`; + ctx.fillText(style.label, cx, labelY + 0.5); + ctx.restore(); + }; + const strokeEllipse = (cx, cy, rx, ry) => { + ctx.fillStyle = style.fill; + ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); + ctx.shadowColor = style.color; + ctx.shadowBlur = Math.max(12, world.screenSizeToWorld ? world.screenSizeToWorld(18) : 18); + ctx.strokeStyle = style.color; + ctx.lineWidth = Math.max(3.6, worldLine + pulse * 1.8); + ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.shadowBlur = 0; + ctx.setLineDash([10, 6]); + ctx.strokeStyle = "rgba(255,255,255,0.96)"; + ctx.lineWidth = Math.max(1.6, outerLine); + ctx.beginPath(); ctx.ellipse(cx, cy, rx * 1.10, ry * 1.12, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + drawLabel(cx, cy, ry * 1.12); + }; + ctx.save(); + ctx.globalAlpha = 0.92; + const isTarinaiTarget = typeof Tarinai !== "undefined" && target instanceof Tarinai; + if (isTarinaiTarget) { + const r = Math.max(17, target.radius || 22); + strokeEllipse(target.x || 0, target.y || 0, r * 1.02, r * 0.80); + } else if (target.type === "rope" || target.type === "rod") { + const runtime = TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime; + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world); + const b = runtime?.endpointWorld?.(pb?.endpoint?.(target, 1), world); + if (a && b) { + ctx.shadowColor = style.color; + ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(15) : 15); + ctx.strokeStyle = style.color; + ctx.lineWidth = Math.max(5, worldLine + 2); + ctx.lineCap = "round"; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.shadowBlur = 0; + ctx.setLineDash([10, 6]); + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.lineWidth = Math.max(2, outerLine); + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.setLineDash([]); + drawLabel((a.x + b.x) / 2, (a.y + b.y) / 2, 20); + } + } else { + const rects = world.solidObstacleRects?.(target) || []; + if (rects.length) { + ctx.strokeStyle = style.color; + ctx.shadowColor = style.color; + ctx.shadowBlur = Math.max(10, world.screenSizeToWorld ? world.screenSizeToWorld(16) : 16); + ctx.lineWidth = Math.max(3.2, worldLine); + let cx = target.x || 0, cy = target.y || 0, top = cy; + for (const rect of rects.slice(0, 14)) { + if (rect.oriented) { cx = rect.cx || cx; cy = rect.cy || cy; top = Math.min(top, cy - (rect.halfH || 18)); } + strokeWorldObstacleRect(ctx, rect); + } + ctx.shadowBlur = 0; + ctx.setLineDash([9, 6]); + ctx.strokeStyle = "rgba(255,255,255,0.96)"; + ctx.lineWidth = Math.max(1.6, outerLine); + for (const rect of rects.slice(0, 14)) strokeWorldObstacleRect(ctx, rect); + ctx.setLineDash([]); + drawLabel(cx, top, 14); + } else { + const r = Math.max(14, target.r || 16) * (target.type === "ball" ? 1.30 : 1.18); + strokeEllipse(target.x || 0, target.y || 0, r, r * 0.76); + } + } + ctx.restore(); +} + +function drawShootHoverHighlight(ctx, world) { + if (!world || world.tool !== "shoot") return; + const aim = world.shootAimPoint?.(); + if (!aim?.inside) return; + const target = world.hoverShootTarget; + if (!target || target.dead) return; + ctx.save(); + const pulse = 0.52 + Math.sin((world.time || 0) * 14) * 0.12; + ctx.globalAlpha = 0.72 + pulse * 0.12; + ctx.lineWidth = Math.max(1.8, (world.screenSizeToWorld ? world.screenSizeToWorld(2.0) : 2.0)); + ctx.strokeStyle = "rgba(255,106,92,0.94)"; + ctx.shadowColor = "rgba(255,188,146,0.44)"; + ctx.shadowBlur = Math.max(4, (world.screenSizeToWorld ? world.screenSizeToWorld(6) : 6)); + const isTarinaiTarget = typeof Tarinai !== "undefined" && target instanceof Tarinai; + const isItemTarget = Boolean(target.type); + if (isTarinaiTarget) { + const r = Math.max(16, target.radius || 22) * 1.12; + ctx.beginPath(); + ctx.ellipse(target.x || 0, target.y || 0, r * 0.95, r * 0.76, 0, 0, Math.PI * 2); + ctx.stroke(); + } else if (isItemTarget) { + const rects = world.solidObstacleRects?.(target) || []; + if (target.type === "rope" || target.type === "rod") { + const runtime = world.TarinaiLinkRuntime || TARINAI_RENDER_GLOBAL.TarinaiLinkRuntime; + const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; + const a = runtime?.endpointWorld?.(pb?.endpoint?.(target, 0), world); + const b = runtime?.endpointWorld?.(pb?.endpoint?.(target, 1), world); + if (a && b) { + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } else if (rects.length) { + for (const rect of rects.slice(0, 12)) strokeWorldObstacleRect(ctx, rect); + } else { + const r = Math.max(12, target.r || 14) * (target.type === "ball" ? 1.24 : 1.14); + ctx.beginPath(); + ctx.ellipse(target.x || 0, target.y || 0, r, r * 0.76, 0, 0, Math.PI * 2); + ctx.stroke(); + } + } else { + const r = Math.max(8, target.r || 8) * 1.45; + ctx.beginPath(); + ctx.ellipse(target.x || 0, target.y || 0, r, r * 0.86, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); +} + +function drawShootCursorFallback(ctx, x, y, size) { + const half = size * 0.5; + const tick = size * 0.16; + const gap = size * 0.11; + ctx.save(); + ctx.translate(x, y); + ctx.strokeStyle = "rgba(94,118,138,0.96)"; + ctx.lineWidth = Math.max(1.3, size * 0.045); + ctx.lineCap = "square"; + for (const sign of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(0, sign * gap); + ctx.lineTo(0, sign * (half - 2)); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(sign * gap, 0); + ctx.lineTo(sign * (half - 2), 0); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-tick * 0.55, sign * (gap + tick)); + ctx.lineTo(tick * 0.55, sign * (gap + tick)); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(sign * (gap + tick), -tick * 0.55); + ctx.lineTo(sign * (gap + tick), tick * 0.55); + ctx.stroke(); + } + ctx.restore(); +} + +function drawShootCursorOverlay(ctx, world) { + if (!world || world.tool !== "shoot") return; + const aim = world.shootAimPoint?.(); + if (!aim?.inside) return; + const screen = world.worldToScreen ? world.worldToScreen(aim.x, aim.y) : { x: aim.x, y: aim.y }; + const canvasW = world.viewportW || world.w || ctx.canvas?.width || 1; + const canvasH = world.viewportH || world.h || ctx.canvas?.height || 1; + const size = Math.max(90, Math.min(160, Math.round(Math.min(canvasW, canvasH) * 0.205))); + const def = typeof toolDefinition === "function" ? toolDefinition("shoot") : null; + const img = cachedToolCursorIconImage(def?.icon || ""); + ctx.save(); + ctx.globalAlpha = 0.95; + ctx.shadowColor = "rgba(255,255,255,0.12)"; + ctx.shadowBlur = Math.max(2, size * 0.08); + if (img && img.complete && (img.naturalWidth || img.width)) { + ctx.drawImage(img, screen.x - size / 2, screen.y - size / 2, size, size); + } else { + drawShootCursorFallback(ctx, screen.x, screen.y, size); + } + ctx.restore(); +} + function randSeed(seed, min, max) { const s = Math.sin(seed * 12.9898) * 43758.5453; return min + (s - Math.floor(s)) * (max - min); @@ -278,7 +496,7 @@ function placementPreviewFor(world) { } if ((type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence") && world.fenceRect) { const rawRect = world.fenceRect(tmp); - return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) }; + return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) }; } if (type === "rotator" && TARINAI_RENDER_GLOBAL.TarinaiMechanicalSystem) { const pb = TARINAI_RENDER_GLOBAL.TarinaiPhysicsBodySystem; @@ -366,7 +584,7 @@ function drawPlacementPreview(ctx, world) { ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top); } } - } else if (type === "water_bowl" || 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 === "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(); @@ -1414,6 +1632,8 @@ function render() { } renderPerfEnd(endAttachments); + drawToolTargetHoverHighlight(ctx, world); + const endEffects = renderPerfBegin("render.draw.effects"); for (const ef of world.effects) { if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); @@ -1444,6 +1664,7 @@ function render() { const endUiOverlay = renderPerfBegin("render.uiOverlay"); drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); drawPointerItemTooltip(ctx, world); + drawShootCursorOverlay(ctx, world); drawSelectedToolCursorIcon(ctx, world); renderPerfEnd(endUiOverlay); diff --git a/js/sim_core.js b/js/sim_core.js index 27d5d79..6c2e2e0 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -512,6 +512,28 @@ class Effect { ctx.beginPath(); ctx.arc(0, 0, this.size + (1 - alpha) * 18, 0, Math.PI * 2); ctx.stroke(); + } else if (this.type === "shoot_impact") { + const s = this.size * (0.65 + (1 - alpha) * 0.55); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = this.color || "rgba(255,226,150,0.92)"; + ctx.lineWidth = Math.max(1.2, this.size * 0.16); + for (let i = 0; i < 7; i++) { + const a = this.seed + i * 0.897; + const inner = s * randSeed(this.seed + i + 2, 0.16, 0.34); + const outer = s * randSeed(this.seed + i + 9, 0.72, 1.18) + (1 - alpha) * this.size * 1.1; + ctx.globalAlpha = alpha * randSeed(this.seed + i + 31, 0.42, 0.95); + ctx.beginPath(); + ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner); + ctx.lineTo(Math.cos(a) * outer, Math.sin(a) * outer); + ctx.stroke(); + } + ctx.globalAlpha = alpha * 0.75; + ctx.strokeStyle = "rgba(255,255,255,0.70)"; + ctx.lineWidth = Math.max(1, this.size * 0.08); + ctx.beginPath(); + ctx.arc(0, 0, s * 0.68 + (1 - alpha) * this.size * 0.65, 0, Math.PI * 2); + ctx.stroke(); } else if (this.type === "fight") { ctx.lineCap = "round"; ctx.lineJoin = "round"; @@ -790,7 +812,7 @@ class SpatialGrid { const foodMap = dynamic ? this.dynamicFoodCells : this.staticFoodCells; const hazardMap = dynamic ? this.dynamicHazardCells : this.staticHazardCells; const hasObstacle = typeof itemHasTrait === "function" ? itemHasTrait(type, "obstacle") : (type === "stone" || type === "ball" || type === "nest_box" || type === "bed" || type === "fence_v" || type === "fence_h" || type === "bounce_fence" || type === "bounce_fence_v"); - const hasFood = typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "water_bowl" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))); + const hasFood = typeof itemHasTrait === "function" ? itemHasTrait(type, "food_interest") : (type === "sweet" || type === "love_mochi" || type === "fight_mochi" || type === "grass" || type === "water" || type === "ant_corpse" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type))); const hasHazard = typeof itemHasTrait === "function" && itemHasTrait(type, "hazard"); if (type === "poison_block") { const body = (typeof window !== "undefined" ? window : globalThis).TarinaiPhysicsBodySystem?.ensureBody?.(it, null, { syncFromLegacy: false }); diff --git a/js/simulation_environment_system.js b/js/simulation_environment_system.js index 6562ce1..1061627 100644 --- a/js/simulation_environment_system.js +++ b/js/simulation_environment_system.js @@ -28,6 +28,16 @@ function updateFrameSignals(worldRef, dt) { if (worldRef.pointer) worldRef.pointer.motion = Math.max(0, (worldRef.pointer.motion || 0) - dt * 2.4); + const recoilX = Number(worldRef.shootCursorOffsetX) || 0; + const recoilY = Number(worldRef.shootCursorOffsetY) || 0; + if (recoilX || recoilY) { + // Recoil is a real aim offset, but it should settle back to the actual + // mouse/touch position in roughly 1-2 seconds instead of drifting forever. + const k = Math.exp(-Math.max(0, Number(dt) || 0) * 3.15); + worldRef.shootCursorOffsetX = Math.abs(recoilX * k) < 0.25 ? 0 : recoilX * k; + worldRef.shootCursorOffsetY = Math.abs(recoilY * k) < 0.25 ? 0 : recoilY * k; + worldRef.drawListDirty = true; + } worldRef.shakeTimer = Math.max(0, (worldRef.shakeTimer || 0) - dt); if ((worldRef.shakeTimer || 0) <= 0) { worldRef.shakeDuration = 0; worldRef.shakeStrength = 0; } } diff --git a/js/sound_pack.js b/js/sound_pack.js index e67e8ab..634df74 100644 --- a/js/sound_pack.js +++ b/js/sound_pack.js @@ -3,7 +3,7 @@ (function (global) { // External audio replacement point. Add files and edit paths here; audio.js consumes this pack at startup. global.TARINAI_SOUND_PACK = Object.freeze({ - version: "1.2", + version: "1.3", categoryGain: { voice: 3.2, notify: 18.0, ops: 16.0 }, idGain: { sfx_notify: 2.60, @@ -21,6 +21,8 @@ sfx_ground_rumble: 1.75, sfx_firecracker_fuse: 1.55, sfx_firecracker_explode: 1.70, + sfx_shoot: 0.725, + sfx_bolt_action: 1.35, }, idMinGap: { sfx_ui_click: 0.030, @@ -31,6 +33,8 @@ sfx_notify: 0.10, sfx_log_push: 0.10, voice_sunbath: 0.95, + sfx_shoot: 0.045, + sfx_bolt_action: 0.10, }, samplePaths: { voice_hau: "assets/sounds/voice_001_hau.wav", @@ -40,7 +44,9 @@ voice_stress: "assets/sounds/voice_005_stress.wav", sfx_sleep: "assets/sounds/voice_006_sleep.wav", voice_sunbath: "assets/sounds/voice_007_sunbath_pokapoka.wav", - sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3" + sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3", + sfx_shoot: "assets/sounds/shoot_pistol.mp3", + sfx_bolt_action: "assets/sounds/shoot_bolt_action.mp3" }, categories: { voice: "\u305f\u308a\u306a\u3044\u306e\u58f0", @@ -56,6 +62,7 @@ sfx_ui_click: "ops", sfx_ui_fold: "ops", sfx_place: "ops", sfx_delete: "ops", sfx_poke: "ops", sfx_grab: "ops", sfx_drop: "ops", sfx_water_hose: "ops", sfx_water_clean: "ops", sfx_drink: "ops", sfx_ball_hit: "ops", sfx_stone_impact: "ops", sfx_firecracker_fuse: "ops", sfx_firecracker_explode: "ops", + sfx_shoot: "ops", sfx_bolt_action: "ops", sfx_genkotsu_fall: "ops", sfx_genkotsu_impact: "ops", sfx_ground_rumble: "ops", sfx_grass: "ops", sfx_zunchi: "ops", sfx_ant_nest: "ops" } diff --git a/js/tarinai_consumable_behavior.js b/js/tarinai_consumable_behavior.js index 4db1db7..91d9878 100644 --- a/js/tarinai_consumable_behavior.js +++ b/js/tarinai_consumable_behavior.js @@ -33,8 +33,8 @@ function consumeBehaviorTarget(t, world, target, role = "food", dt = 0.12) { return true; } - if ((role === "drink" || role === "medicine") && (target.type === "water" || target.type === "water_bowl")) { - const text = target.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b"; + if ((role === "drink" || role === "medicine") && target.type === "water") { + const text = "\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b"; t.setActionState?.("seek_water", { target, reason: text, sleeping: false }); t.thought = text; t.applyWaterEffect?.(0.55, target.type); diff --git a/js/tarinai_contact_item_system.js b/js/tarinai_contact_item_system.js index 886ca7e..722be2c 100644 --- a/js/tarinai_contact_item_system.js +++ b/js/tarinai_contact_item_system.js @@ -11,8 +11,8 @@ if (!it || it.dead) return false; const d = it?.type === "duplicator" ? foodInteractionDistance(tarinai, it) : dist(tarinai, it); const contactReach = foodInteractionReach(tarinai, it, "food") + (it === tarinai.target && foodActionActive && it?.type !== "duplicator" ? 4 : 0); if (d > contactReach) return false; - if (it.type === "water" || it.type === "water_bowl") { - tarinai.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + if (it.type === "water") { + tarinai.thought = "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; tarinai.target = it; tarinai.applyWaterEffect(dt, it.type); if ((tarinai.world?.time || 0) > (tarinai.nextDrinkSound || -999)) { diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 64652a4..d10e507 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -201,20 +201,18 @@ freezeSleepDiseaseMotion(dt = 0) { if (!this.sleepDisease) return false; const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy); + const heldByPlayer = Boolean(this.playerHeld || this._heldByPlayer); if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x; if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y; - this.startSleeping(null, beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"); - this.vx = 0; - this.vy = 0; - if (beingDraggedByAnt) { + this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている"); + // ねむり病は意識だけを固定し、物理位置は固定しない。 + // 以前は anchor に毎回スナップしていたため、外力・つまみ・機械接触を無視していた。 + if (heldByPlayer || beingDraggedByAnt || Math.hypot(Number(this.vx || 0) + Number(this.impulseVx || 0), Number(this.vy || 0) + Number(this.impulseVy || 0)) > 0.1) { this.sleepDiseaseAnchorX = this.x; this.sleepDiseaseAnchorY = this.y; - } else { - this.x = this.sleepDiseaseAnchorX; - this.y = this.sleepDiseaseAnchorY; } this.wanderAngle = this.wanderAngle || 0; - this.thought = beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b"; + this.thought = beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている"; if (this.world && this.world.time > (this.nextSleepBubbleAt || 0)) { this.nextSleepBubbleAt = this.world.time + 5.0; this.world.spawnBubble?.(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); diff --git a/js/tarinai_item_interaction_context.js b/js/tarinai_item_interaction_context.js index b2d4d0f..04e9924 100644 --- a/js/tarinai_item_interaction_context.js +++ b/js/tarinai_item_interaction_context.js @@ -25,7 +25,7 @@ if (tarinai.target && !tarinai.target.dead && (tarinai.target.type === "duplicator" || roleMatches(tarinai.target, "food") || roleMatches(tarinai.target, "medicine"))) foodCandidates.push(tarinai.target); for (const it of nearbyItems) { if (!it || it.dead || foodCandidates.includes(it)) continue; - if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water" || it.type === "water_bowl")) continue; + if (!(it.type === "duplicator" || roleMatches(it, "food") || roleMatches(it, "medicine") || it.type === "water")) continue; foodCandidates.push(it); if (foodCandidates.length >= scanLimit) break; } diff --git a/js/tarinai_item_targeting.js b/js/tarinai_item_targeting.js index 8697e09..caf00e8 100644 --- a/js/tarinai_item_targeting.js +++ b/js/tarinai_item_targeting.js @@ -24,8 +24,8 @@ function effectiveFoodRoleMatches(item, role) { const type = effectiveFoodTypeForItem(item); if (!type) return false; if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type)); - if (role === "drink") return type === "water" || type === "water_bowl" || type === "zunda_juice"; - if (role === "medicine") return type === "sweet" || type === "water" || type === "water_bowl" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); + if (role === "drink") return type === "water" || type === "zunda_juice"; + if (role === "medicine") return type === "sweet" || type === "water" || type === "zunda_juice" || type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)); return false; } @@ -66,7 +66,7 @@ function foodPriorityRank(tarinai, item) { const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0; const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0; if (type === "sweet") return 4100 + Math.max(0, healthNeed - 40) * 6; - if (type === "water" || type === "water_bowl") return 3650 + Math.max(0, healthNeed - 42) * 7; + if (type === "water") return 3650 + Math.max(0, healthNeed - 42) * 7; if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6; if (type === "love_mochi") return 3300 + Math.max(0, socialNeed - 45) * 7; if (type === "fight_mochi") return 2600 + Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 4; @@ -103,7 +103,7 @@ function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) { if (tarinai.shouldAvoidTarget?.(it)) continue; const type = effectiveFoodTypeForItem(it); if (!type) continue; - if (["sweet", "food", "grass", "water", "water_bowl", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) { + if (["sweet", "food", "grass", "water", "zunda_juice", "ant_corpse"].includes(type) || (typeof isServingFoodType === "function" && isServingFoodType(type))) { if (it.type === "grass" && (it.amount || 0) <= 0) continue; if (typeof tarinai.foodItemHasServingLeft === "function" && ["sweet", "food", "zunda_juice"].includes(type) && !tarinai.foodItemHasServingLeft(it)) continue; return true; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index e5fa49c..8cbe6ed 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -541,14 +541,17 @@ function shouldWakeFromSleep(tarinai, world, needs) { if (!tarinai || tarinai.dead) return true; if (tarinai.sleepDisease) return false; const now = world?.time || 0; - const session = tarinai.sleepSession || { startedAt: now, minDuration: 12, targetEnergy: 82, maxDuration: 38 }; - const elapsed = Math.max(0, now - (Number(session.startedAt) || now)); + const session = tarinai.sleepSession || { startedAt: now, minDuration: 8, targetEnergy: 74, maxDuration: 28 }; + const started = Number(session.startedAt); + const elapsed = Math.max(0, now - (Number.isFinite(started) ? started : now)); if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true; if (findNearbyDanger(world, tarinai, 160)) return true; - // \u6b32\u6c42\u5024\u306e\u81ea\u7136\u5909\u52d5\u3060\u3051\u3067\u306f\u7761\u7720\u3092\u4e2d\u65ad\u3057\u306a\u3044\u3002\u76f4\u63a5\u306e\u5371\u967a\u30fb\u30c0\u30e1\u30fc\u30b8\u306e\u307f\u8d77\u5e8a\u8981\u56e0\u306b\u3059\u308b\u3002 - if (elapsed < (Number(session.minDuration) || 12)) return false; - if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 84)) return true; - if (elapsed >= (Number(session.maxDuration) || 42)) return true; + // 自然な睡眠は短い安全ロック後、十分に回復したら起きる。 + if (elapsed < (Number(session.minDuration) || 8)) return false; + const sleepNeed = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0; + if (sleepNeed <= ((typeof needThreshold === "function" ? needThreshold("sleep", "continue") : 34) + 4)) return true; + if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 74)) return true; + if (elapsed >= (Number(session.maxDuration) || 28)) return true; return false; } @@ -557,10 +560,17 @@ function protectSleepSession(tarinai, world, needs) { tarinai.sleeping = true; if (!tarinai.sleepSession) { const now = world?.time || 0; - tarinai.sleepSession = { startedAt: now, minDuration: rand(12, 22), targetEnergy: rand(80, 92), maxDuration: rand(34, 54), targetId: tarinai.target?.id || null, consumesGrassBedOnWake: tarinai.target?.type === "grass_bed" }; + const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target); + tarinai.sleepSession = { startedAt: now, minDuration: rand(bedLike ? 8 : 7, bedLike ? 14 : 12), targetEnergy: rand(bedLike ? 70 : 64, bedLike ? 82 : 76), maxDuration: rand(bedLike ? 22 : 18, bedLike ? 34 : 28), targetId: tarinai.target?.id || null, consumesGrassBedOnWake: tarinai.target?.type === "grass_bed" }; if (tarinai.target?.type === "grass_bed") tarinai.pendingGrassBedWakeId = tarinai.target.id || tarinai.pendingGrassBedWakeId || ""; } if (!shouldWakeFromSleep(tarinai, world, needs)) { + const step = Math.max(0.10, Math.min(1.0, Number(tarinai.aiTimer || 0.42) || 0.42)); + const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target); + const comfort = bedLike ? Math.max(0.5, Math.min(1.4, Number(world?.bedComfort?.(tarinai.target) || tarinai.target?.comfort || 1) || 1)) : 0.55; + const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100)); + tarinai.energy = Math.min(energyMax, (Number(tarinai.energy || 0) || 0) + step * (bedLike ? (1.2 + comfort * 1.35) : 0.65)); + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { sleep: -step * (bedLike ? (5.2 + comfort * 4.5) : 3.2), safety: bedLike ? -step * 0.9 : 0 }); tarinai.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b"; setBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reason: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", text: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" }); tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, 0.8); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 109e048..01c204b 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -266,6 +266,7 @@ const dx = spot.x - this.x; const dy = spot.y - this.y; const d = Math.hypot(dx, dy); + if (externalActive && d > 8.5 && !this.sleepDisease && marker?.wakeSleepingFromExternalMotion?.(this, "動かされて目が覚めた")) return; if (d > 2.2) { const restore = externalActive ? (2.0 + bedComfort * 2.0) : (12 + bedComfort * 14); const maxRestore = externalActive ? Math.min(d, 3.2) : d; @@ -297,6 +298,7 @@ marker?.markSleepExternalMotion?.(this, dt, "sleep-full-contact"); const cdx = (Number(this.x || 0) || 0) - beforeContactX; const cdy = (Number(this.y || 0) || 0) - beforeContactY; + if (Math.hypot(cdx, cdy) > 1.8 && !this.sleepDisease && marker?.wakeSleepingFromExternalMotion?.(this, "ぶつかって目が覚めた")) return; if (Math.hypot(cdx, cdy) > 0.05) { this.vx = clamp((Number(this.vx) || 0) + cdx / Math.max(0.016, dt) * 0.085, -170, 170); this.vy = clamp((Number(this.vy) || 0) + cdy / Math.max(0.016, dt) * 0.085, -170, 170); diff --git a/js/tarinai_update_step_frame.js b/js/tarinai_update_step_frame.js index f792513..e3a07fd 100644 --- a/js/tarinai_update_step_frame.js +++ b/js/tarinai_update_step_frame.js @@ -160,6 +160,12 @@ t.hunger += CONFIG.hungerPerMinute * minute * t.trait.hunger * hungerScale * (1 + tooCold * 0.8); t.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - t.trait.social); t.energy -= CONFIG.energyPerMinute * minute * t.trait.sleep * (t.state === "sleep" ? 0.025 : 1); + if (t.state === "sleep" || t.sleeping) { + const sleepBedLike = t.target && typeof t.isSleepFurniture === "function" && t.isSleepFurniture(t.target); + const sleepEnergyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(t) : (Number(t.maxEnergy) || 100)); + t.energy = Math.min(sleepEnergyMax, (Number(t.energy || 0) || 0) + dt * (sleepBedLike ? 0.52 : 0.24)); + if (typeof applyNeedRelief === "function") applyNeedRelief(t, { sleep: -dt * (sleepBedLike ? 2.0 : 1.1) }); + } // stress is derived from needs; no independent stress decay. if (tooCold > 0) applyNeedShock(t, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); diff --git a/js/tarinai_update_step_movement.js b/js/tarinai_update_step_movement.js index b1ac843..8dc14ca 100644 --- a/js/tarinai_update_step_movement.js +++ b/js/tarinai_update_step_movement.js @@ -66,6 +66,34 @@ return Boolean(t && (t.state === "sleep" || t.sleeping) && now < Number(t._sleepExternalMotionUntil || 0)); } + function wakeSleepingFromExternalMotion(t, reason = "外力で目が覚めた") { + if (!t || t.dead || t.sleepDisease || !(t.state === "sleep" || t.sleeping)) return false; + const world = t.world || null; + if (world?.isTarinaiHiddenInNestBox?.(t)) return false; + const now = Number(world?.time || 0) || 0; + if (now < Number(t._sleepExternalWakeSuppressUntil || -999)) return false; + t._sleepExternalWakeSuppressUntil = now + 0.45; + t._sleepExternalMotionUntil = 0; + t._sleepExternalMotionReason = ""; + t.consumePendingGrassBedOnWake?.("外力で起きた"); + t.sleeping = false; + t.sleepSession = null; + t.target = null; + t.targetKey = ""; + t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 5.2); + t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.45); + t.fearTimer = Math.max(t.fearTimer || 0, 0.18); + t.setActionState?.("idle", { target: null, clearTarget: true, reason, wake: true, sleeping: false }); + t.thought = reason; + t.pinchThoughtTimer = Math.max(t.pinchThoughtTimer || 0, 1.0); + if (t.bubble && now >= Number(t._sleepExternalWakeBubbleAt || -999) + 0.6) { + t._sleepExternalWakeBubbleAt = now; + t.bubble("!", 0.55, "rgba(88,82,118,0.78)"); + } + world?.emit?.("tarinai:woke", { tarinai: t, reason: "external-motion" }); + return true; + } + function passivePhysicalMotion(t, dt, context = {}) { if (!t || t.dead) return { done: true }; const world = t.world; @@ -182,6 +210,14 @@ world.resolveFenceCollision(t); } } + + if (state === "sleep" || t.sleeping) { + const finalDx = numeric(t.x) - beforeX; + const finalDy = numeric(t.y) - beforeY; + const rawSpeed = Math.hypot(numeric(t.vx) + numeric(t.impulseVx), numeric(t.vy) + numeric(t.impulseVy)); + const movedByExternal = Math.hypot(finalDx, finalDy) > 4.2 || rawSpeed > 26 || (sleepExternalMotionActive(t) && Math.hypot(finalDx, finalDy) > 2.2); + if (movedByExternal) wakeSleepingFromExternalMotion(t, "動かされて目が覚めた"); + } return { done: false, passive: true }; } @@ -236,5 +272,5 @@ return { done: false }; } - global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage, passivePhysicalMotion, markSleepExternalMotion, sleepExternalMotionActive }); + global.TarinaiMovementUpdateStep = Object.freeze({ update, applyVelocityShockDamage, passivePhysicalMotion, markSleepExternalMotion, sleepExternalMotionActive, wakeSleepingFromExternalMotion }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/js/text_catalog.js b/js/text_catalog.js index aad96c1..0ea7ea7 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -56,7 +56,7 @@ const TEXT_CATALOG = { { title: "コロニーの見方", image: "assets/sprites/tarinai_11_weak.webp", - text: "コロニー欄では個体数、死亡数、全体状態、地面、欲求平均、物を確認できます。たりないデータでは選択中の個体の健康、性格、関係、行動理由、記録を追えます。", + text: "コロニー欄では個体数、死亡数、全体状態、地面、欲求平均、物を確認できます。観察では選択中の個体の健康、性格、関係、行動理由、記録を追えます。", }, ],}; diff --git a/js/ui_input_mouse.js b/js/ui_input_mouse.js index ff92473..6d212a8 100644 --- a/js/ui_input_mouse.js +++ b/js/ui_input_mouse.js @@ -29,6 +29,13 @@ }, 90); return; } + if (e.button === 0 && world.tool === "shoot") { + const p = ctx.screenToWorldEvent(e); + if (!p.inside) return; + e.preventDefault(); + ctx.beginShoot(p, e.clientX, e.clientY); + return; + } if (e.button === 0 && world.tool === "pinch") { const p = ctx.screenToWorldEvent(e); if (!p.inside) return; @@ -45,6 +52,7 @@ window.addEventListener("mouseup", (e) => { if (ctx.endAreaDelete()) return; if (ctx.endHose()) { if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } return; } + if (ctx.endShoot()) return; if (ctx.endGrab(e.clientX, e.clientY)) return; ctx.endPan(e.button); }); @@ -60,6 +68,11 @@ ctx.moveHose(p, e.clientX, e.clientY); return; } + if (uiCache.shooting) { + const p = ctx.screenToWorldEvent(e); + ctx.moveShoot(p, e.clientX, e.clientY); + return; + } if (uiCache.grabbing) { const p = ctx.screenToWorldEvent(e); const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY)); @@ -83,6 +96,10 @@ uiCache.grabMoved = false; return; } + if (uiCache.shootSuppressClick) { + uiCache.shootSuppressClick = false; + return; + } if (uiCache.panMoved) { uiCache.panMoved = false; return; @@ -98,6 +115,9 @@ canvas.addEventListener("mouseleave", () => { world.pointer.inside = false; world.pointer.motion = 0; + world.hoverGrabTarget = null; + world.hoverDeleteTarget = null; + world.hoverShootTarget = null; }); return true; } diff --git a/js/ui_input_shared.js b/js/ui_input_shared.js index 06d39cc..2994808 100644 --- a/js/ui_input_shared.js +++ b/js/ui_input_shared.js @@ -3,6 +3,7 @@ (function (global) { function cursorForTool(tool) { if (tool === "water_hose") return "crosshair"; + if (tool === "shoot") return "none"; if (tool === "pinch") return "grab"; if (tool === "observe") return "default"; if (tool === "poke" || tool === "delete" || tool === "area_delete") return "pointer"; @@ -328,6 +329,89 @@ return true; } + function shootAimPoint() { + return world.shootAimPoint?.() || { x: world.pointer?.x || 0, y: world.pointer?.y || 0, inside: Boolean(world.pointer?.inside) }; + } + + function updateShootHoverTarget(point = null) { + // 射撃ツールは照準だけを表示し、対象ハイライトは出さない。 + if (world.hoverShootTarget) { + world.hoverShootTarget = null; + world.drawListDirty = true; + } + return null; + } + + function beginShoot(p, clientX, clientY, options = {}) { + if (!p?.inside) return false; + global.TarinaiHistory?.capture?.(world, "shoot"); + uiCache.shooting = true; + uiCache.shootMoved = false; + uiCache.shootSuppressClick = true; + uiCache.shootFired = false; + uiCache.shootStartX = clientX; + uiCache.shootStartY = clientY; + uiCache.shootLastClientX = clientX; + uiCache.shootLastClientY = clientY; + world.pointer.x = p.x; + world.pointer.y = p.y; + world.pointer.inside = true; + world.setShootCursorFromPointer?.(p); + uiCache.shootLastRawWorldX = p.x; + uiCache.shootLastRawWorldY = p.y; + updateShootHoverTarget(); + uiCache.shootFired = Boolean(world.fireShootTool?.()) || Boolean(uiCache.shootFired); + if (options.touchMode) uiCache.touchMode = options.touchMode; + if (uiCache.shootHoldTimer) clearInterval(uiCache.shootHoldTimer); + uiCache.shootHoldTimer = setInterval(() => { + if (!uiCache.shooting) { + clearInterval(uiCache.shootHoldTimer); + uiCache.shootHoldTimer = null; + return; + } + uiCache.shootFired = Boolean(world.fireShootTool?.()) || Boolean(uiCache.shootFired); + }, 120); + canvas.style.cursor = "none"; + render(); + return true; + } + + function moveShoot(p, clientX, clientY, options = {}) { + uiCache.shootLastClientX = clientX; + uiCache.shootLastClientY = clientY; + const rawDx = (Number(p.x) || 0) - (Number(uiCache.shootLastRawWorldX ?? p.x) || 0); + const rawDy = (Number(p.y) || 0) - (Number(uiCache.shootLastRawWorldY ?? p.y) || 0); + uiCache.shootLastRawWorldX = p.x; + uiCache.shootLastRawWorldY = p.y; + world.pointer.x = p.x; + world.pointer.y = p.y; + world.pointer.inside = !!p.inside; + if (p.inside) world.moveShootCursorBy?.(rawDx, rawDy); + const totalMove = Math.hypot(clientX - (uiCache.shootStartX || clientX), clientY - (uiCache.shootStartY || clientY)); + if (totalMove > 3) { + uiCache.shootMoved = true; + if (options.touch) uiCache.touchMoved = true; + } + updateShootHoverTarget(); + canvas.style.cursor = "none"; + render(); + return true; + } + + function endShoot() { + if (!uiCache.shooting) return false; + uiCache.shooting = false; + if (uiCache.shootHoldTimer) { + clearInterval(uiCache.shootHoldTimer); + uiCache.shootHoldTimer = null; + } + if (uiCache.shootFired) audio.boltAction?.(); + uiCache.shootFired = false; + updateShootHoverTarget(); + canvas.style.cursor = cursorForTool(world.tool); + renderStats(); + return true; + } function beginAreaDelete(p, clientX, clientY, options = {}) { @@ -417,6 +501,50 @@ const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0)); world.pointer.motion = clamp(delta / 18, 0, 2); world.pointer.movedAt = performance.now(); + if (world.tool === "shoot") { + if (p.inside) { + if (world.shootCursorActive) { + const lastRawX = Number.isFinite(Number(world.shootPointerRawX)) ? Number(world.shootPointerRawX) : prevX; + const lastRawY = Number.isFinite(Number(world.shootPointerRawY)) ? Number(world.shootPointerRawY) : prevY; + world.moveShootCursorBy?.((Number(p.x) || 0) - (Number(lastRawX) || 0), (Number(p.y) || 0) - (Number(lastRawY) || 0)); + } else { + world.setShootCursorFromPointer?.(p); + } + world.shootPointerRawX = p.x; + world.shootPointerRawY = p.y; + } + updateShootHoverTarget(); + canvas.style.cursor = "none"; + } else if (world.tool === "pinch" && p.inside && !uiCache.grabbing && !uiCache.panning && !uiCache.hosing && !uiCache.areaDeleting && !uiCache.shooting) { + const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null; + if (world.hoverGrabTarget !== target) { + world.hoverGrabTarget = target || null; + world.drawListDirty = true; + } + if (world.hoverDeleteTarget) { world.hoverDeleteTarget = null; world.drawListDirty = true; } + canvas.style.cursor = target ? "grab" : "default"; + } else if (world.tool === "delete" && p.inside && !uiCache.grabbing && !uiCache.panning && !uiCache.hosing && !uiCache.areaDeleting && !uiCache.shooting) { + const target = world.findDeleteToolTargetAt ? world.findDeleteToolTargetAt(p.x, p.y) : null; + if (world.hoverDeleteTarget !== target) { + world.hoverDeleteTarget = target || null; + world.drawListDirty = true; + } + if (world.hoverGrabTarget) { world.hoverGrabTarget = null; world.drawListDirty = true; } + canvas.style.cursor = target ? "pointer" : "default"; + } else { + if (world.hoverGrabTarget) { + world.hoverGrabTarget = null; + world.drawListDirty = true; + } + if (world.hoverDeleteTarget) { + world.hoverDeleteTarget = null; + world.drawListDirty = true; + } + if (world.hoverShootTarget) { + world.hoverShootTarget = null; + world.drawListDirty = true; + } + } } function cancelPointerActions() { @@ -424,10 +552,18 @@ uiCache.touchMoved = false; uiCache.hosing = false; if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } + uiCache.shooting = false; + uiCache.shootFired = false; + if (uiCache.shootHoldTimer) { clearInterval(uiCache.shootHoldTimer); uiCache.shootHoldTimer = null; } uiCache.areaDeleting = false; uiCache.grabbing = false; uiCache.grabTarget = null; uiCache.grabKind = ""; + if (world) { + world.hoverGrabTarget = null; + world.hoverDeleteTarget = null; + world.hoverShootTarget = null; + } } return { @@ -453,6 +589,9 @@ beginHose, moveHose, endHose, + beginShoot, + moveShoot, + endShoot, beginAreaDelete, moveAreaDelete, endAreaDelete, diff --git a/js/ui_input_touch.js b/js/ui_input_touch.js index 16f6d56..fa3a33b 100644 --- a/js/ui_input_touch.js +++ b/js/ui_input_touch.js @@ -41,6 +41,10 @@ uiCache.touchMode = "toolTapOnly"; return; } + if (world.tool === "shoot" && p.inside) { + ctx.beginShoot(p, t.clientX, t.clientY, { touchMode: "shoot" }); + return; + } if (world.tool === "area_delete" && p.inside) { ctx.beginAreaDelete(p, t.clientX, t.clientY, { touchMode: "areaDelete" }); return; @@ -95,6 +99,10 @@ if (totalMove > 8) uiCache.touchMoved = true; return; } + if (uiCache.touchMode === "shoot" && uiCache.shooting) { + ctx.moveShoot(p, t.clientX, t.clientY, { touch: true }); + return; + } if (uiCache.touchMode === "areaDelete" && uiCache.areaDeleting) { ctx.moveAreaDelete(p, t.clientX, t.clientY, { touch: true }); return; @@ -121,6 +129,7 @@ e.preventDefault(); if (uiCache.areaDeleting) ctx.endAreaDelete(); if (uiCache.hosing) { ctx.endHose(); if (uiCache.hoseHoldTimer) { clearInterval(uiCache.hoseHoldTimer); uiCache.hoseHoldTimer = null; } } + if (uiCache.shooting) ctx.endShoot(); if (uiCache.grabbing) { ctx.endGrab(e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0); } else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) { diff --git a/js/ui_layout_dialogs.js b/js/ui_layout_dialogs.js index 331ef1b..3d0bfcf 100644 --- a/js/ui_layout_dialogs.js +++ b/js/ui_layout_dialogs.js @@ -138,7 +138,7 @@ function selectTool(tool) { btn.classList.toggle("selected", btn.dataset.tool === activeTool); } syncToolSizeBadges(); - canvas.style.cursor = activeTool === "observe" ? "default" : (activeTool === "pinch" ? "grab" : (activeTool === "poke" || activeTool === "delete" ? "pointer" : "crosshair")); + canvas.style.cursor = window.TarinaiUIInputShared?.cursorForTool?.(activeTool) || (activeTool === "observe" ? "default" : (activeTool === "pinch" ? "grab" : (activeTool === "poke" || activeTool === "delete" ? "pointer" : "crosshair"))); } function showToast(text) { diff --git a/js/ui_selected.js b/js/ui_selected.js index 75b89e4..0e1a0a5 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -188,7 +188,7 @@ function renderSelected() { uiCache.selectedEmpty = true; uiCache.selectedEmptyVisible = showEmpty; ui.selectedInfo.className = "selected-info empty"; - ui.selectedInfo.textContent = "\u672a\u9078\u629e"; + ui.selectedInfo.textContent = "たりないを選択してください"; return; } @@ -224,7 +224,7 @@ function renderSelected() { ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), selectedInventoryItems(t).join(","), - (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") + (t.records || []).slice(0, 7).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") ].join("|"); if (uiCache.selectedSnapshot === snapshot) return; uiCache.selectedSnapshot = snapshot; @@ -249,11 +249,11 @@ function renderSelected() {
\u73fe\u5728\u6027\u683c${personalityRowsHtml(t.currentPersonality)}
\u4eca\u65e5\u306e\u5909\u5316
    ${recentChangeHtml}
`; - const recordHtml = (t.records || []).slice(0, 8).map(r => { + const recentMemoryHtml = (t.records || []).slice(0, 7).map(r => { const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); const recordText = quoteNameInRecordText(r.text || "", t.name); return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; - }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; + }).join("") || `
  • -まだ記憶がありません
  • `; const basicHtml = `
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    @@ -296,7 +296,7 @@ function renderSelected() { ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2\u3068\u5bb6\u65cf", relationHtml)} ${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))} - ${selectedDataCategoryHtml("records", "\u8a18\u9332", `
      ${recordHtml}
    `)} + ${selectedDataCategoryHtml("memories", "最近の記憶", `
      ${recentMemoryHtml}
    `)}
    `; diff --git a/js/version.js b/js/version.js index 20aa6af..14b453d 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "39.15.18"; - const APP_BUILD = "tarinai-nearby-constraint-reduce-v39-15-18"; + const APP_VERSION = "39.15.27"; + const APP_BUILD = "tarinai-grab-sleep-fence-perf-v39-15-19"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world.js b/js/world.js index 17572cc..91f052e 100644 --- a/js/world.js +++ b/js/world.js @@ -40,6 +40,17 @@ class World { constructor() { this.relationNotices = {}; this.resolvedFightIds = {}; this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 }; + this.hoverGrabTarget = null; + this.hoverDeleteTarget = null; + this.hoverShootTarget = null; + this.shootCursorActive = false; + this.shootCursorX = 0; + this.shootCursorY = 0; + this.shootPointerRawX = 0; + this.shootPointerRawY = 0; + this.shootCursorOffsetX = 0; + this.shootCursorOffsetY = 0; + this.lastShootAt = -999; this.lastPhase = ""; this.weather = "sunny"; this.worldSeed = window.TarinaiSeedFactory?.createWorldSeed?.() || `w${Date.now().toString(36)}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 594f374..ecc99e8 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -475,8 +475,19 @@ }, applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; - const normalized = options.cause || cause || "\u885d\u7a81"; - const physicalHit = String(normalized).includes("衝突") || String(normalized).includes("collision") || String(cause || "").includes("急激な速度変化") || String(normalized).includes("爆竹") || String(normalized).includes("落ちてきた"); + const sourceLabel = (src) => { + if (!src) return ""; + if (src.name) return String(src.name); + if (src.type && typeof toolLabel === "function") return toolLabel(src.type); + if (src.type) return String(src.type); + return ""; + }; + let normalized = options.cause || cause || "\u885d\u7a81"; + const rawCause = String(cause || ""); + const isPlainCollision = String(normalized).includes("衝突") || String(normalized).includes("collision") || rawCause.includes("急激な速度変化"); + const label = sourceLabel(options.source); + if (isPlainCollision && label) normalized = `${label}に衝突`; + const physicalHit = String(normalized).includes("衝突") || String(normalized).includes("collision") || rawCause.includes("急激な速度変化") || String(normalized).includes("爆竹") || String(normalized).includes("落ちてきた"); if (target instanceof Tarinai && (target.birthRitualTimer || 0) > 0.04 && physicalHit && amount >= (options.birthCancelDamageThreshold ?? 8)) { this.cancelBirthRitualOnForce?.(target, Math.max(180, amount * 18), { reason: "強い衝撃で繁殖が中断された", @@ -586,7 +597,7 @@ const radius = Math.max(32, Math.min(220, (it.r || 14) * 1.35 + impactSpeed * 0.055)); const damage = physicalDamageFromImpactSpeed(impactSpeed, { min: 1.2, max: isGenkotsu ? 58 : 34, scale: 8.6 }); const push = clamp((impactSpeed - 120) * 0.62, 42, isGenkotsu ? 1040 : 260); - const reason = impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"; + const reason = isStone ? "石に潰された" : (impactSpeed >= PHYSICAL_DAMAGE_SPEED_THRESHOLD ? "\u9ad8\u901f\u3067\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u885d\u7a81" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f"); if (isGenkotsu) { it.impactFlash = 1; it.lingerTimer = Math.max(it.lingerDuration || 2, 2); this.triggerGroundShake?.(2.0, 10); } let hit = 0; for (const t of this.nearbyTarinai(it.x, it.y, radius + 54)) { @@ -838,7 +849,7 @@ fearTimer: 0.95, thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", }); - this.applyImpactDamage(t, damage, "\u885d\u7a81"); + this.applyImpactDamage(t, damage, "\u885d\u7a81", { source: ball }); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); if (t.addStress) t.addStress(stressGain, { threshold: 8 }); @@ -998,8 +1009,8 @@ }); impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); - this.applyImpactDamage(target, damage, "衝突"); - if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "衝突"); + this.applyImpactDamage(target, damage, "衝突", { source: impactor }); + if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "衝突", { source: target }); for (const t of [target, impactor]) { if (!t || t.dead) continue; if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" }); diff --git a/js/world_environment.js b/js/world_environment.js index 2c79e91..1e8fe4f 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -38,6 +38,13 @@ return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); } + function rectDistanceToPointLocal(rect, x, y) { + if (!rect) return Infinity; + const px = Math.max(Number(rect.left || 0), Math.min(Number(rect.right || 0), Number(x) || 0)); + const py = Math.max(Number(rect.top || 0), Math.min(Number(rect.bottom || 0), Number(y) || 0)); + return Math.hypot((Number(x) || 0) - px, (Number(y) || 0) - py); + } + function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) { if (!candidates || typeof visitor !== "function") return 0; const source = candidates; @@ -476,8 +483,9 @@ return Boolean(aroundDoor || inDoorColumn); }, - nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { + nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24, maxRects: requestedMaxRects = 0 } = {}) { const limit = Math.max(1, Number(maxChecks || 0) || 24); + const maxRects = Math.max(12, Math.min(72, Number(requestedMaxRects || 0) || Math.ceil(limit * 2.25))); const cacheable = !include && !exclude; const stats = this.nearbyQueryStatsThisFrame || null; const q = 48; @@ -487,9 +495,9 @@ const qcy = (qy + 0.5) * q; const inflatedRadius = Math.max(1, Number(radius || 0) || 1) + q * 0.82; const qr = Math.ceil(inflatedRadius / q); - const cacheLimit = Math.min(96, Math.max(limit + 8, Math.ceil(limit * 1.55))); + const cacheLimit = Math.min(72, Math.max(limit + 6, Math.ceil(limit * 1.35))); const cacheKey = cacheable - ? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit}` + ? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}` : ""; if (cacheable) { if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map(); @@ -516,7 +524,16 @@ if (!partRects.length) return false; it._obstacleRectQueryStamp = stamp; checked += 1; - for (const rect of partRects) rects.push(rect); + const localLimit = it.type === "rotator" || it.type === "poison_block" || it.type === "reciprocator" ? maxRects : maxRects + 8; + for (const rect of partRects) { + if (rects.length >= localLimit) break; + // Dense custom mechanical shapes can produce dozens of segment rects. + // Keep only rects that can plausibly interact with this query point; + // otherwise one smooth-motion collision probe spends most of its time + // iterating far-away segments of the same object. + if (rectDistanceToPointLocal(rect, queryX, queryY) > queryRadius + 42) continue; + rects.push(rect); + } return true; }; const candidates = this.nearbyObstacles?.(queryX, queryY, queryRadius, false) || this.nearbyItems?.(queryX, queryY, queryRadius, false) || []; diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 4f4a77c..a50f508 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -232,7 +232,7 @@ const poweredLabel = poweredInput?.closest?.("label"); if (speedLabel) speedLabel.style.display = isPoisonBlock ? "none" : ""; if (poweredLabel) poweredLabel.style.display = isPoisonBlock ? "none" : ""; - if (poisonCollisionLabel) poisonCollisionLabel.style.display = isPoisonBlock ? "" : "none"; + if (poisonCollisionLabel) poisonCollisionLabel.style.display = ""; if (poisonCollisionInput) poisonCollisionInput.checked = phyScalar(item, "solid", true) !== false; if (speed) speed.value = String(Math.max(-360, Math.min(360, deg))); if (speedNum) speedNum.value = String(deg); @@ -405,8 +405,8 @@ cleanSegments(); physicsApi()?.setSegments?.(item, segments.map(seg => seg.slice(0, 4)), isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit"); phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 14 : 12)) || (isPoisonBlock ? 14 : 12))), "editor-thickness"); + phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid"); if (isPoisonBlock) { - phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid"); phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage"); } else { phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed"); @@ -452,6 +452,7 @@ +